openvibers/openvibe.games Commits
Games: a creator world draft can be shared with specific people (sharing step 5) (#296)
Changes
@@@ -100,6 +100,8 @@ import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldHimport { createWorldRoutes } from './worlds/worldRoutes.js'import { createAssetRoutes } from './worlds/assetRoutes.js'import { createWorldPage } from './worlds/worldPage.js'++import { createWorldSharing } from './worlds/sharing.js'++import { createWorldShareRoutes } from './worlds/shareRoutes.js'import { createSteamCatalog } from './steam/steamCatalog.js'import { createSteamRoutes } from './steam/steamPage.js'import { createAiGenerate, createBench } from './aiMatches/services.js'@@@ -735,6 +737,63 @@ async function main(): Promise<void> {// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is// sandboxed: no NPCs, no world events, no rewards, no events, and characters are never saved there.const creatorWorlds = createCreatorWorldStore(db)++ // ── Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 5): the share.v1 row next to each world ──++ // A draft is Only me; its Share sheet can name people who may open /worlds/<id> and join to test it++ // before publishing. Network lookups and notification pushes use the `games` principal's token.++ const worldSharingUrl = config.oauth?.selfUrl ?? 'https://openvibe.games'++ async function worldSharingNetwork(input: { path: string; method?: string; json?: unknown; query?: Record<string, string> }): Promise<unknown> {++ if (!platform) throw new Error('Network is not configured for this service')++ const url = new URL(`${config.platform.networkUrl.replace(/\/+$/, '')}${input.path}`)++ for (const [k, v] of Object.entries(input.query ?? {})) url.searchParams.set(k, String(v))++ const token = await platform.tokens.getToken({ audience: 'openvibe.network', scope: 'identity.subject.resolve network.notifications.push' })++ const headers: Record<string, string> = { accept: 'application/json', authorization: `Bearer ${token}` }++ if (input.json !== undefined) headers['content-type'] = 'application/json'++ const res = await fetch(url, {++ method: input.method ?? 'GET',++ headers,++ ...(input.json === undefined ? {} : { body: JSON.stringify(input.json) }),++ signal: AbortSignal.timeout(8_000),++ })++ const answer = await res.json().catch(() => null)++ if (!res.ok) throw Object.assign(new Error(`Network ${input.method ?? 'GET'} ${input.path} ${res.status}`), { status: res.status })++ return answer++ }++ const worldSharing = platform++ ? createWorldSharing({++ db,++ store: creatorWorlds,++ log: log.child({ system: 'world-sharing' }),++ network: { json: worldSharingNetwork },++ events: {++ async publish(event) {++ if (!sink.enabled) return++ await db.tx(async (t) => {++ await sink.enqueue(t, event as Parameters<EventSink['enqueue']>[1])++ })++ },++ },++ async notify(notification) {++ const to = (notification as { to?: { type?: string; id?: string } }).to++ if (!to || to.type !== 'user' || !to.id) return++ const words: Record<string, [string, string]> = {++ 'share.added': ['A world was shared with you', 'Open it on OpenVibe.Games.'],++ 'share.access_requested': ['Someone asked for access to your world', 'Review the request on OpenVibe.Games.'],++ 'share.transfer.requested': ['Someone wants to give you a world', 'Accept or decline it on OpenVibe.Games.'],++ 'share.transfer.answered': ['A world transfer was answered', 'See what happened on OpenVibe.Games.'],++ }++ const [title, message] = words[String((notification as { kind?: string }).kind)] ?? ['Sharing on OpenVibe.Games', 'There is something new for you.']++ try {++ await worldSharingNetwork({++ path: '/internal/notifications/push', method: 'POST',++ json: { subject: to.id, type: 'GENERIC', title, message, service: 'games', url: worldSharingUrl, category: 'social' },++ })++ } catch (err) {++ log.warn('sharing notification not sent', { error: String((err as Error | undefined)?.message ?? err) })++ }++ },++ })++ : null++// A world's def set: the creator pack, plus its own scripts when they run here (GAMES_CREATOR_SCRIPTS). The client// builds the same set from /api/v1/content?world= (its packs carry the same mods), so the digests agree.const creatorContent = new Map<string, Promise<ContentRegistry>>()@@@ -775,6 +834,12 @@ async function main(): Promise<void> {log: log.child({ system: 'creator-worlds' }),maxRunning: config.creatorWorldsMax,idleMs: config.creatorWorldIdleSeconds * 1000,++ ...(worldSharing++ ? {++ canViewShared: (id: string, subject: string) =>++ worldSharing.canView({ id: '', name: '', rank: null, subjectId: subject }, id),++ }++ : {}),})const router = new PlaceRouter(hubs,@@@ -947,7 +1012,12 @@ async function main(): Promise<void> {: nullworldSearch?.start()// GET /worlds/<id>: a published world's own page (the one Search links to).-- const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })++ const serveWorldPage = createWorldPage({++ store: creatorWorlds,++ siteUrl: gamesSiteUrl,++ ...(worldSharing ? { sharing: worldSharing } : {}),++ networkAuthUrl: config.networkAuthUrl,++ })// GET /steam and /api/steam: Steam's featured lists from OpenVibe.Sources (steam/steamCatalog.ts), synced hourly.const steamCatalog = createSteamCatalog({db,@@@ -991,7 +1061,21 @@ async function main(): Promise<void> {perPerson: config.creatorWorldsPerPerson,events: { db, sink, siteUrl: gamesSiteUrl },...(worldSearch ? { search: worldSearch } : {}),++ ...(worldSharing ? { sharing: worldSharing } : {}),})++ // The uniform share API for worlds: /api/v1/world/<id>/share and the shared-with-me listing.++ const serveWorldShare = worldSharing++ ? createWorldShareRoutes({++ sharing: worldSharing,++ store: creatorWorlds,++ resolveUser: (token) =>++ config.networkAuthUrl++ ? resolveNetworkUser(config.networkAuthUrl, token)++ : Promise.resolve(null),++ publicUrl: gamesSiteUrl,++ log: log.child({ system: 'world-sharing' }),++ })++ : null// The asset store's JSON API (worlds/assetRoutes.ts): builds and scripts creators publish for others' worlds.const serveAssets = createAssetRoutes({store: createCreatorAssetStore(db),@@@ -1186,6 +1270,7 @@ async function main(): Promise<void> {}if (serveCasual(req, res)) return trueif (serveAiMatches(req, res)) return true++ if (serveWorldShare?.(req, res)) return trueif (serveWorlds(req, res)) return trueif (serveAssets(req, res)) return trueif (serveWorldPage(req, res)) return truennew file mode 100644@@@ -0,0 +1,58 @@++import type { IncomingMessage, ServerResponse } from 'node:http'++import type { CreatorWorldStore } from '@openvibe/persistence'++import type { Logger } from '@openvibe/shared'++import type { NetworkUser } from '../net/networkAuth.js'++import type { ResourceRef, Shares, Viewer } from 'openvibe-sdk/share'++import { describeWorldRef, shareUser, type WorldSharing } from './sharing.js'++++/**++ * The uniform share API for creator worlds (openvibe-sdk/share): `/api/v1/world/<id>/share`, its++ * requests and transfer endpoints, `/api/v1/share/people?name=` and `/api/v1/shared-with-me`.++ *++ * The SDK router answers only its own paths, so it sits before the worlds API and hands everything++ * else back. A person is the signed-in Games account (Bearer token, or the ovg_sso cookie the site++ * leaves); a guest is nobody here, as with every other share operation.++ */++export function createWorldShareRoutes(opts: {++ sharing: WorldSharing++ store: CreatorWorldStore++ resolveUser: (token: string) => Promise<NetworkUser | null>++ publicUrl: string++ log: Logger++}): (req: IncomingMessage, res: ServerResponse) => boolean {++ const routed: Shares = Object.create(opts.sharing.shares) as Shares++ routed.getWithRevision = async (viewer: Viewer, ref: ResourceRef) => {++ await opts.sharing.ensure(ref.id)++ return await opts.sharing.shares.getWithRevision(viewer, ref)++ }++ routed.update = async (viewer: Viewer, ref: ResourceRef, body: Parameters<Shares['update']>[2], extra: Parameters<Shares['update']>[3]) => {++ await opts.sharing.ensure(ref.id)++ return await opts.sharing.shares.update(viewer, ref, body, extra)++ }++ routed.requestAccess = async (viewer: Viewer, ref: ResourceRef) => {++ await opts.sharing.ensure(ref.id)++ return await opts.sharing.shares.requestAccess(viewer, ref)++ }++ const handler = opts.sharing.shares.router({++ service: 'games',++ authenticate: async (req: IncomingMessage) => {++ const user = await shareUser(req, opts.resolveUser)++ return opts.sharing.viewer(user)++ },++ describe: describeWorldRef(opts.store, opts.publicUrl),++ })++ const matches = (path: string) => path === '/api/v1/shared-with-me' || path === '/api/v1/share/people'++ || /^\/api\/v1\/world\/[^/]+\/share(?:\/|$)/.test(path)++ return (req: IncomingMessage, res: ServerResponse): boolean => {++ const path = (req.url ?? '/').split('?')[0] ?? '/'++ if (!matches(path)) return false++ handler(req, res).catch((err: unknown) => {++ opts.log.warn('world share request failed', { error: String((err as Error | undefined)?.message ?? err) })++ if (!res.headersSent) {++ res.writeHead(500, { 'content-type': 'application/json', 'cache-control': 'no-store' })++ res.end('{"code":"share.internal","error":"Sharing unavailable"}')++ }++ })++ return true++ }++}nnew file mode 100644@@@ -0,0 +1,96 @@++import { emptyMapV2 } from '@openvibe/content'++import { createCreatorWorldStore } from '@openvibe/persistence'++import { openTestDb } from '@openvibe/persistence/testing'++import { createConsoleLogger } from '@openvibe/shared'++import { expect, it } from 'vitest'++import { createWorldSharing } from './sharing.js'++import { mayJoin } from './worldHost.js'++import type { GameConnection } from '../game/systems/context.js'++++const KIM = 'usr_01J0000000000000000000000K'++const LEE = 'usr_01J0000000000000000000000M'++const SAM = 'usr_01J0000000000000000000000N'++const PEOPLE: Record<string, { subject: { type: 'user'; id: string }; username: string; display_name: string }> = {++ [LEE]: { subject: { type: 'user', id: LEE }, username: 'lee', display_name: 'Lee' },++ [SAM]: { subject: { type: 'user', id: SAM }, username: 'sam', display_name: 'Sam' },++ [KIM]: { subject: { type: 'user', id: KIM }, username: 'kim', display_name: 'Kim' },++}++const log = createConsoleLogger({ app: 'world-sharing-test' }, 'error')++++it('a draft shared with Specific people is visible to them and joinable, and only to them', async () => {++ const db = await openTestDb()++ const store = createCreatorWorldStore(db)++ const world = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Secret test world',++ summary: '',++ map: emptyMapV2(),++ now: 1,++ })++ const sent: unknown[] = []++ const sharing = createWorldSharing({++ db,++ store,++ log,++ network: {++ async json(input) {++ const { path, json, query } = input as { path: string; json?: unknown; query?: Record<string, string> }++ if (path === '/internal/identity/resolve') {++ const name = String(query?.username ?? '').toLowerCase()++ const person = Object.values(PEOPLE).find((p) => p.username === name)++ if (!person) throw Object.assign(new Error('not found'), { status: 404 })++ return { ...person, banned: false, deleted: false }++ }++ if (path === '/internal/identity/resolve-batch') {++ const results: Record<string, unknown> = {}++ for (const id of ((json as { subject_ids?: string[] }).subject_ids ?? [])) if (PEOPLE[id]) results[id] = { ...PEOPLE[id], banned: false, deleted: false }++ return { results }++ }++ if (path === '/internal/notifications/push') return { queued: true }++ throw Object.assign(new Error('not found'), { status: 404 })++ },++ },++ events: { async publish(event) { sent.push(event) } },++ notify: async () => {},++ })++ await sharing.register(world)++ expect(await sharing.audienceOf(world)).toBe('private')++++ const kim = { id: '', name: 'kim', rank: null, subjectId: KIM }++ const lee = { id: '', name: 'lee', rank: null, subjectId: LEE }++ const sam = { id: '', name: 'sam', rank: null, subjectId: SAM }++ expect(await sharing.canView(lee, world.id)).toBe(false)++++ const kimViewer = sharing.viewer(kim)++ if (!kimViewer) throw new Error('no view')++ await sharing.shares.update(kimViewer, { service: 'games', kind: 'world', id: world.id }, {++ audience: 'people',++ members: [{ name: '@lee', role: 'viewer' }],++ })++ expect((await sharing.audienceOf({ ...world, status: 'draft' }))).toBe('people')++ expect(await sharing.canView(lee, world.id)).toBe(true)++ expect(await sharing.canView(sam, world.id)).toBe(false)++ expect(await sharing.canView(null, world.id)).toBe(false)++ expect(await sharing.canEdit(lee, world.id)).toBe(false)++++ const as = (subject: string | null, rank: GameConnection['identity'] extends never ? never : 'admin' | null = null): GameConnection => ({++ identity: subject ? { subject, rank, authIat: null } : { guestKeyHash: 'a'.repeat(64) },++ send: () => {},++ close: () => {},++ })++ // The join still needs the world to be a draft and the person on its list.++ expect(mayJoin({ ...world, status: 'draft' }, as(LEE), true)).toBe(true)++ expect(mayJoin({ ...world, status: 'draft' }, as(SAM), false)).toBe(false)++ expect(mayJoin({ ...world, status: 'draft' }, as(null), false)).toBe(false)++++ // Publishing widens it to Everyone; unpublishing narrows it to Only me.++ await sharing.syncFromStatus(world.id, 'published')++ expect((await sharing.audienceOf({ ...world, status: 'published' }))).toBe('public')++ expect(await sharing.canView(sam, world.id)).toBe(true)++ await sharing.syncFromStatus(world.id, 'draft')++ expect(await sharing.canView(sam, world.id)).toBe(false)++ await sharing.work()++ expect(sent.length).toBeGreaterThan(0)++ expect(JSON.stringify(sent)).not.toContain('Secret test world')++})nnew file mode 100644@@@ -0,0 +1,178 @@++import { createRequire } from 'node:module'++import type { IncomingMessage } from 'node:http'++import type { Db, Tx } from 'openvibe-sdk/db'++import type { Role, Shares, ShareOptions, ResourceRef } from 'openvibe-sdk/share'++import type { SubjectRef } from 'openvibe-sdk/core'++import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'++import type { Logger } from '@openvibe/shared'++import type { NetworkUser } from '../net/networkAuth.js'++import { viewerAccountKey } from '../net/viewer.js'++++/**++ * Sharing for creator worlds (design 2026-11-06, VISIBILITY-EVERYWHERE step 5; openvibe-sdk/share).++ *++ * A draft is Only me. Its Share sheet can name people (`people`) so friends can open its page and join++ * and play it before it is published; publishing widens it to Everyone, and the public list, the++ * sitemap and Search still show published worlds only. The `shares` row is the access authority for++ * every world read path (the API, /worlds/<id>, the join), with `status = 'published'` as the public++ * fact the gathered pages keep using.++ */++const req = createRequire(import.meta.url)++type ShareModule = {++ createShares(options: ShareOptions): Shares++}++const { createShares } = req('openvibe-sdk/share') as ShareModule++++const SERVICE = 'games'++const KIND = 'world'++const AUDIENCES = ['public', 'link', 'people', 'private'] as const++++export interface WorldSharingOptions {++ db: Db++ store: CreatorWorldStore++ network: { json(options: { service: string; audience: string; path: string; method?: string; json?: unknown; query?: Record<string, string> }): Promise<unknown> }++ events: { publish(event: unknown): Promise<unknown> }++ notify(notification: Record<string, unknown>): Promise<unknown>++ log: Logger++ publicUrl?: string++}++++export interface WorldSharing {++ shares: Shares++ service: string++ kind: string++ /** The SDK viewer for a Games account, or null. */++ viewer(user: NetworkUser | null): { subject: { type: 'user'; id: string }; staff: boolean } | null++ canView(user: NetworkUser | null, id: string): Promise<boolean>++ canEdit(user: NetworkUser | null, id: string): Promise<boolean>++ /** The world's audience: its share row, else published → public and draft/hidden → private. */++ audienceOf(world: CreatorWorld | null): Promise<'public' | 'link' | 'people' | 'private'>++ /** Register a world that has no share row yet (a legacy world, or one created before its sheet opened). */++ ensure(id: string): Promise<void>++ /** A new world starts Only me; publishing through the owner's PATCH widens it to the status's audience. */++ register(world: CreatorWorld): Promise<void>++ syncFromStatus(id: string, status: string): Promise<void>++ /** The viewer's account key (usr_… or guest:…), as the page resolver returns it. */++ viewerFromRequest(req: IncomingMessage, networkAuthUrl: string | null): Promise<{ subject: { type: 'user'; id: string }; staff: boolean } | null>++ work(): Promise<{ expired: number; sent: number }>++}++++const subjectOf = (value: string | null | undefined) =>++ value && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(value) ? { type: 'user' as const, id: value } : null++++export function createWorldSharing(opts: WorldSharingOptions): WorldSharing {++ const { db, store, log } = opts++ const audiences: string[] = [...AUDIENCES]++++ const shares = createShares({++ db,++ network: opts.network,++ events: opts.events,++ notify: opts.notify,++ async onOwnerChange(ref: ResourceRef, _from: SubjectRef, to: SubjectRef, tx: Tx) {++ if (ref.service !== SERVICE || ref.kind !== KIND || to?.type !== 'user') return++ await tx.exec('UPDATE creator_worlds SET owner_subject = $1 WHERE id = $2', [to.id, ref.id])++ },++ } as unknown as ShareOptions)++++ const viewer = (user: NetworkUser | null) => {++ const subject = subjectOf(user?.subjectId ?? null)++ if (!subject) return null++ return { subject, staff: user?.rank === 'owner' || user?.rank === 'admin' }++ }++++ const refOf = (id: string) => ({ service: SERVICE, kind: KIND, id: String(id) })++++ async function shareOf(id: string): Promise<{ audience: string } | null> {++ return await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)])++ }++++ async function audienceOf(world: CreatorWorld | null): Promise<'public' | 'link' | 'people' | 'private'> {++ if (!world) return 'private'++ const row = await shareOf(world.id)++ if (row && audiences.includes(row.audience)) return row.audience as 'public' | 'link' | 'people' | 'private'++ return world.status === 'published' ? 'public' : 'private'++ }++++ async function ensure(id: string): Promise<void> {++ if (await shareOf(id)) return++ const world = await store.get(id)++ if (!world) return++ await register(world)++ }++++ async function register(world: CreatorWorld): Promise<void> {++ const owner = subjectOf(world.ownerSubject) || { type: 'service' as const, id: SERVICE }++ try {++ await shares.register(refOf(world.id), { owner_subject: owner, audience: world.status === 'published' ? 'public' : 'private' })++ } catch (err) {++ if ((err as { status?: number }).status !== 409) throw err // somebody registered it first: use theirs++ }++ }++++ /**++ * Publishing through the owner's PATCH widens the world to Everyone; unpublishing narrows it to Only me.++ * It goes through the SDK as the owner, so the change is logged and announced like one made in the sheet.++ */++ async function syncFromStatus(id: string, status: string): Promise<void> {++ const next = status === 'published' ? 'public' : 'private'++ const world = await store.get(id)++ const owner = subjectOf(world?.ownerSubject ?? null)++ if (!owner) return++ await ensure(id)++ const current = await shareOf(id)++ if (!current || current.audience === next) return++ await shares.setAudience({ subject: owner, staff: false }, refOf(id), next)++ }++++ return {++ shares,++ service: SERVICE,++ kind: KIND,++ viewer,++ canView: async (user, id) => await shares.canView(viewer(user), refOf(id)),++ canEdit: async (user, id) => await shares.canEdit(viewer(user), refOf(id)),++ audienceOf,++ ensure,++ register,++ syncFromStatus,++ async viewerFromRequest(request, networkAuthUrl) {++ const key = await viewerAccountKey(request, { networkAuthUrl }).catch(() => null)++ const subject = subjectOf(key)++ return subject ? { subject, staff: false } : null++ },++ async work() {++ let expired = 0++ try { expired = await shares.expireMembers() } catch (err) { log.warn('sharing expiry sweep failed', { error: String((err as Error | undefined)?.message ?? err) }) }++ let sent = 0++ try { sent = await shares.flush() } catch (err) { log.warn('sharing deliveries failed', { error: String((err as Error | undefined)?.message ?? err) }) }++ return { expired, sent }++ },++ }++}++++/** The uniform API's describe(ref): the sheet shows the world's title and page to its owner. */++export function describeWorldRef(store: CreatorWorldStore, publicUrl: string) {++ return async (ref: { service: string; kind: string; id: string }) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {}++ const world = await store.get(ref.id)++ if (!world) return {}++ return {++ title: world.title,++ link: `${publicUrl.replace(/\/+$/, '')}/worlds/${world.id}`,++ roles: ['viewer'] as Role[],++ audiences: [...AUDIENCES],++ transferable: false,++ world: { status: world.status, revision: world.revision },++ }++ }++}++++/** The signed-in Games account a share request names (bearer first, then the ovg_sso cookie). */++export async function shareUser(req: IncomingMessage, resolveUser: (token: string) => Promise<NetworkUser | null>): Promise<NetworkUser | null> {++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]++ const cookie = /(?:^|;\s*)ovg_sso=([^;]+)/.exec(req.headers.cookie ?? '')?.[1]++ const token = bearer ?? (cookie ? decodeURIComponent(cookie) : null)++ if (!token) return null++ return await resolveUser(token).catch(() => null)++}@@@ -23,6 +23,8 @@ export interface WorldHostOptions {maxRunning: number/** A world with nobody in it stops after this long. */idleMs: number++ /** A draft shared with this signed-in person (share.v1 `people`): they may join and test it. */++ canViewShared?: (id: string, subject: string) => Promise<boolean>now?: () => number}@@@ -33,7 +35,7 @@ interface Running {activeAt: number}--/** Who may join a world that is not published: its owner, and staff. */++/** Who may join a world that is not published: its owner, its team, the people its owner shared it with, and staff. */export function mayJoin(world: CreatorWorld, conn: GameConnection, onTeam = false): boolean {if (world.status === 'published') return trueconst identity = conn.identity@@@ -78,13 +80,16 @@ export class CreatorWorldHost implements DynamicPlaces {await this.stopping.get(id)const world = await this.opts.store.get(id)const identity = conn.identity++ const subject = !!identity && 'subject' in identity ? identity.subject : nullconst onTeam =!!world &&world.status === 'draft' &&-- !!identity &&-- 'subject' in identity &&-- (await this.opts.store.isMember(id, identity.subject))-- if (!world || !mayJoin(world, conn, onTeam)) return null++ !!subject &&++ (await this.opts.store.isMember(id, subject))++ const onList =++ !!world && world.status === 'draft' && !!subject && !!this.opts.canViewShared &&++ (await this.opts.canViewShared(id, subject).catch(() => false))++ if (!world || !mayJoin(world, conn, onTeam || onList)) return nullconst current = this.running.get(id)if (current) {current.activeAt = this.now()@@@ -3,12 +3,14 @@ import { createRequire } from 'node:module'import { CREATOR_WORLD_ID } from '@openvibe/content'import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'import { escapeHtml, sendNotFound, type NotFoundLink } from '../net/notFound.js'++import type { WorldSharing } from './sharing.js'/**-- * GET /worlds/<id>: a published creator world's own page inside the OpenVibe Frame (openvibe-shared/shell), the page++ * GET /worlds/<id>: a creator world's own page inside the OpenVibe Frame (openvibe-shared/shell), the page* OpenVibe.Search links to and the link a creator shares: its title, who made it, their summary, how many have-- * visited, and the button that opens it in /play. Anything not published is a real 404 (a draft is its owner's, and-- * the owner reaches it from /worlds).++ * visited, and the button that opens it in /play. A published world is everyone's; a draft is its owner's, its++ * team's and the people the owner shared it with (share.v1 `people`), who may read the page and join to test it.++ * Anyone else gets the real 404 the page has always given a draft.*/interface Shell {page(o: Record<string, unknown>): string@@@ -40,7 +42,7 @@ const STYLE = `<style>.world .note{margin-top:22px;font-size:14px;color:var(--text-faint,#7386a3)}</style>`--export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell, kitLib: Kit = kit()): string {++export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell, kitLib: Kit = kit(), { draft = false }: { draft?: boolean } = {}): string {const url = `${siteUrl}/worlds/${world.id}`const description =world.summary ||@@@ -62,7 +64,8 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: ShellsiteName: SITE_NAME,description: description.slice(0, 300),canonical: url,-- robots: 'index, follow',++ // A draft is not in Search, the sitemap or the public list; only the people on its list may read it.++ robots: draft ? 'noindex, nofollow' : 'index, follow',head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n<link rel="stylesheet" href="/shared/kit.css">\n${STYLE}`,navbar: {service: 'games',@@@ -89,7 +92,7 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell<p class="stats">${escapeHtml(visits)}</p><div class="row"><a class="play" href="/play?world=${world.id}">▶ Join this world</a>-- ${kitLib.share({ title: world.title, url, text: description })}++ ${kitLib.share({ title: world.title, url, text: description, kind: 'world', id: world.id })}<a class="more" href="/worlds?remix=${world.id}">Remix it</a><a class="more" href="/worlds">More worlds</a></div>@@@ -103,6 +106,10 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shellexport function createWorldPage(opts: {store: CreatorWorldStoresiteUrl: string++ /** The share.v1 row: a draft's Specific-people list, and the one check (worlds/sharing.ts). */++ sharing?: WorldSharing++ /** The Network's auth base, to read the signed-in person from the ovg_sso cookie. */++ networkAuthUrl?: string | nullrequire?: NodeRequire}) {const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell@@@ -114,14 +121,19 @@ export function createWorldPage(opts: {const id = match[1] ?? ''void (async () => {const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null-- if (!world || world.status !== 'published') {++ const viewer = world && world.status !== 'published' && opts.sharing++ ? await opts.sharing.viewerFromRequest(req, opts.networkAuthUrl ?? null).catch(() => null)++ : null++ const draftView = !!world && world.status === 'draft' && !!viewer && !!opts.sharing &&++ await opts.sharing.canView({ subjectId: viewer.subject.id, name: '', rank: null, id: '' }, world.id).catch(() => false)++ if (!world || (world.status !== 'published' && !draftView)) {sendNotFound(req, res, path, LINKS)return}-- const body = worldPageHtml(world, siteUrl, shell)++ const body = worldPageHtml(world, siteUrl, shell, kit(), { draft: world.status !== 'published' })res.writeHead(200, {'content-type': 'text/html; charset=utf-8',-- 'cache-control': 'public, max-age=60',++ 'cache-control': world.status === 'published' ? 'public, max-age=60' : 'no-store','content-length': String(Buffer.byteLength(body)),})res.end(req.method === 'HEAD' ? undefined : body)@@@ -31,7 +31,7 @@ afterEach(async () => {server = null})--async function setup(perPerson = 2) {++async function setup(perPerson = 2, sharing?: unknown) {const db = await openTestDb()const store = createCreatorWorldStore(db)const calls = {@@@ -65,6 +65,7 @@ async function setup(perPerson = 2) {address: () => '127.0.0.1',log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),perPerson,++ ...(sharing ? { sharing: sharing as never } : {}),events: {db,sink: {@@@ -128,6 +129,31 @@ describe('/api/worlds', () => {expect(parseIfMatch('')).toBeNull()})++ it('opens a draft shared with Specific people, and only to them', async () => {++ const people = new Set([LEE])++ const sharing = {++ canView: async (user: NetworkUser | null, _id: string) => !!user?.subjectId && people.has(user.subjectId),++ register: async () => {},++ syncFromStatus: async () => {},++ }++ const { call, store } = await setup(2, sharing)++ const world = await store.create({++ ownerSubject: KIM,++ ownerName: 'kim',++ title: 'Secret test world',++ summary: '',++ map: emptyMapV2(),++ now: 1,++ })++ // The draft is still not in the public list, and a stranger gets the missing answer.++ expect((await call('GET', `/api/worlds/${world.id}`, 'lee')).status).toBe(200)++ expect((await call('GET', `/api/worlds/${world.id}`, 'sam')).status).toBe(404)++ expect((await call('GET', `/api/worlds/${world.id}/map`, 'lee')).status).toBe(200)++ expect((await call('GET', `/api/worlds/${world.id}/map`, 'sam')).status).toBe(404)++ const list = await call('GET', '/api/worlds')++ expect(list.body.worlds).toEqual([])++ })++it('builds a world as a team: an invite link adds editors who save its map and scripts; the owner keeps the rest', async () => {const { call, store } = await setup(2)const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Team base' })).body.id)@@@ -28,6 +28,7 @@ import {import type { GamesActorLimits } from '../net/actorLimits.js'import type { NetworkUser } from '../net/networkAuth.js'import type { WorldSearchIndex } from './searchIndex.js'++import type { WorldSharing } from './sharing.js'/*** /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a@@@ -74,6 +75,8 @@ export interface WorldRouteOptions {events?: { db: Db; sink: EventSink; siteUrl: string }/** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */search?: WorldSearchIndex++ /** The share.v1 row next to each world (worlds/sharing.ts): a draft's Specific-people list, and the one check. */++ sharing?: WorldSharing/** Whether worlds' scripts run here (GAMES_CREATOR_SCRIPTS); they are kept either way. */scriptsRun?: booleannow?: () => number@@@ -251,13 +254,19 @@ export function createWorldRoutes(opts: WorldRouteOptions) {return user as NetworkUser & { subjectId: string }}-- /** A world the caller may see: published, or theirs, or staff. Anything else is not found. */++ /**++ * A world the caller may see: published, theirs, staff, on its team (migration 0015), or a draft the++ * owner shared with them before publishing (share.v1, `people`). A hidden world stays staff-only.++ * Anything else is not found.++ */const visible = async (id: string, user: NetworkUser | null): Promise<CreatorWorld> => {const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : nullif (!world) return fail(404, 'world_not_found')if (world.status === 'published' || isStaff(user) || world.ownerSubject === user?.subjectId)return world++ if (world.status !== 'draft') return fail(404, 'world_not_found')if (user?.subjectId && (await opts.store.isMember(world.id, user.subjectId))) return world++ if (user?.subjectId && opts.sharing && (await opts.sharing.canView(user, world.id).catch(() => false))) return worldreturn fail(404, 'world_not_found')}@@@ -336,6 +345,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {map: starterCreatorMap(),now: now(),})++ await opts.sharing?.register(world).catch((err) => opts.log.warn('world share row not registered', { world: world.id, error: String(err?.message ?? err) }))opts.log.info('creator world created', { world: world.id })send(res, 201, ownerView(world, 0))return@@@ -368,6 +378,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },view: src.view,})++ await opts.sharing?.register(world).catch((err) => opts.log.warn('remix share row not registered', { world: world.id, error: String(err?.message ?? err) }))if (!own) await opts.store.countRemix(src.id)opts.log.info('creator world remixed', { world: world.id, from: src.id })send(res, 201, ownerView(world, 0))@@@ -625,6 +636,9 @@ export function createWorldRoutes(opts: WorldRouteOptions) {return next})if (!updated) fail(404, 'world_not_found')++ if (patch.status) {++ await opts.sharing?.syncFromStatus(id, patch.status).catch((err) => opts.log.warn('world share audience not synced', { world: id, error: String(err?.message ?? err) }))++ }if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)if (patch.status && patch.status !== world.status) {opts.log.info('creator world status', {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 Games' 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,12 @@++-- phase: migrate++-- Every creator world that existed before sharing is registered once. A published world is Everyone;++-- a draft or a hidden world is Only me. `people` is never in the column: it is a share.v1 audience++-- only, set by the Share sheet before a world is published.++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT 'games', 'world', w.id,++ CASE WHEN w.owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'++ THEN jsonb_build_object('type', 'user', 'id', w.owner_subject)++ ELSE jsonb_build_object('type', 'service', 'id', 'games') END,++ CASE WHEN w.status = 'published' THEN 'public' ELSE 'private' END++FROM creator_worlds w++ON CONFLICT (service, kind, resource_id) DO NOTHING;nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/worlds/sharing.test.ts'++// covers: 'apps/server/src/worlds'++// Generated by node test/run.js --index; do not edit.
@@ -100,6 +100,8 @@ import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldH
@@ -100,6 +100,8 @@ import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldH
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createWorldRoutes } from './worlds/worldRoutes.js'
import { createAssetRoutes } from './worlds/assetRoutes.js'
import { createAssetRoutes } from './worlds/assetRoutes.js'
import { createWorldPage } from './worlds/worldPage.js'
import { createWorldPage } from './worlds/worldPage.js'
+import { createWorldSharing } from './worlds/sharing.js'
+import { createWorldShareRoutes } from './worlds/shareRoutes.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
import { createSteamCatalog } from './steam/steamCatalog.js'
import { createSteamRoutes } from './steam/steamPage.js'
import { createSteamRoutes } from './steam/steamPage.js'
import { createAiGenerate, createBench } from './aiMatches/services.js'
import { createAiGenerate, createBench } from './aiMatches/services.js'
@@ -735,6 +737,63 @@ async function main(): Promise<void> {
@@ -735,6 +737,63 @@ async function main(): Promise<void> {
// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is
// A creator world is the place `world:<id>` with the plain creator pack and its map as the override. Its room is
// sandboxed: no NPCs, no world events, no rewards, no events, and characters are never saved there.
// sandboxed: no NPCs, no world events, no rewards, no events, and characters are never saved there.
const creatorWorlds = createCreatorWorldStore(db)
const creatorWorlds = createCreatorWorldStore(db)
+ // ── Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 5): the share.v1 row next to each world ──
+ // A draft is Only me; its Share sheet can name people who may open /worlds/<id> and join to test it
+ // before publishing. Network lookups and notification pushes use the `games` principal's token.
+ const worldSharingUrl = config.oauth?.selfUrl ?? 'https://openvibe.games'
+ async function worldSharingNetwork(input: { path: string; method?: string; json?: unknown; query?: Record<string, string> }): Promise<unknown> {
+ if (!platform) throw new Error('Network is not configured for this service')
+ const url = new URL(`${config.platform.networkUrl.replace(/\/+$/, '')}${input.path}`)
+ for (const [k, v] of Object.entries(input.query ?? {})) url.searchParams.set(k, String(v))
+ const token = await platform.tokens.getToken({ audience: 'openvibe.network', scope: 'identity.subject.resolve network.notifications.push' })
+ const headers: Record<string, string> = { accept: 'application/json', authorization: `Bearer ${token}` }
+ if (input.json !== undefined) headers['content-type'] = 'application/json'
+ const res = await fetch(url, {
+ method: input.method ?? 'GET',
+ headers,
+ ...(input.json === undefined ? {} : { body: JSON.stringify(input.json) }),
+ signal: AbortSignal.timeout(8_000),
+ })
+ const answer = await res.json().catch(() => null)
+ if (!res.ok) throw Object.assign(new Error(`Network ${input.method ?? 'GET'} ${input.path} ${res.status}`), { status: res.status })
+ return answer
+ }
+ const worldSharing = platform
+ ? createWorldSharing({
+ db,
+ store: creatorWorlds,
+ log: log.child({ system: 'world-sharing' }),
+ network: { json: worldSharingNetwork },
+ events: {
+ async publish(event) {
+ if (!sink.enabled) return
+ await db.tx(async (t) => {
+ await sink.enqueue(t, event as Parameters<EventSink['enqueue']>[1])
+ })
+ },
+ },
+ async notify(notification) {
+ const to = (notification as { to?: { type?: string; id?: string } }).to
+ if (!to || to.type !== 'user' || !to.id) return
+ const words: Record<string, [string, string]> = {
+ 'share.added': ['A world was shared with you', 'Open it on OpenVibe.Games.'],
+ 'share.access_requested': ['Someone asked for access to your world', 'Review the request on OpenVibe.Games.'],
+ 'share.transfer.requested': ['Someone wants to give you a world', 'Accept or decline it on OpenVibe.Games.'],
+ 'share.transfer.answered': ['A world transfer was answered', 'See what happened on OpenVibe.Games.'],
+ }
+ const [title, message] = words[String((notification as { kind?: string }).kind)] ?? ['Sharing on OpenVibe.Games', 'There is something new for you.']
+ try {
+ await worldSharingNetwork({
+ path: '/internal/notifications/push', method: 'POST',
+ json: { subject: to.id, type: 'GENERIC', title, message, service: 'games', url: worldSharingUrl, category: 'social' },
+ })
+ } catch (err) {
+ log.warn('sharing notification not sent', { error: String((err as Error | undefined)?.message ?? err) })
+ }
+ },
+ })
+ : null
+
// A world's def set: the creator pack, plus its own scripts when they run here (GAMES_CREATOR_SCRIPTS). The client
// A world's def set: the creator pack, plus its own scripts when they run here (GAMES_CREATOR_SCRIPTS). The client
// builds the same set from /api/v1/content?world= (its packs carry the same mods), so the digests agree.
// builds the same set from /api/v1/content?world= (its packs carry the same mods), so the digests agree.
const creatorContent = new Map<string, Promise<ContentRegistry>>()
const creatorContent = new Map<string, Promise<ContentRegistry>>()
@@ -775,6 +834,12 @@ async function main(): Promise<void> {
@@ -775,6 +834,12 @@ async function main(): Promise<void> {
log: log.child({ system: 'creator-worlds' }),
log: log.child({ system: 'creator-worlds' }),
maxRunning: config.creatorWorldsMax,
maxRunning: config.creatorWorldsMax,
idleMs: config.creatorWorldIdleSeconds * 1000,
idleMs: config.creatorWorldIdleSeconds * 1000,
+ ...(worldSharing
+ ? {
+ canViewShared: (id: string, subject: string) =>
+ worldSharing.canView({ id: '', name: '', rank: null, subjectId: subject }, id),
+ }
+ : {}),
})
})
const router = new PlaceRouter(
const router = new PlaceRouter(
hubs,
hubs,
@@ -947,7 +1012,12 @@ async function main(): Promise<void> {
@@ -947,7 +1012,12 @@ async function main(): Promise<void> {
: null
: null
worldSearch?.start()
worldSearch?.start()
// GET /worlds/<id>: a published world's own page (the one Search links to).
// GET /worlds/<id>: a published world's own page (the one Search links to).
- const serveWorldPage = createWorldPage({ store: creatorWorlds, siteUrl: gamesSiteUrl })
+ const serveWorldPage = createWorldPage({
+ store: creatorWorlds,
+ siteUrl: gamesSiteUrl,
+ ...(worldSharing ? { sharing: worldSharing } : {}),
+ networkAuthUrl: config.networkAuthUrl,
+ })
// GET /steam and /api/steam: Steam's featured lists from OpenVibe.Sources (steam/steamCatalog.ts), synced hourly.
// GET /steam and /api/steam: Steam's featured lists from OpenVibe.Sources (steam/steamCatalog.ts), synced hourly.
const steamCatalog = createSteamCatalog({
const steamCatalog = createSteamCatalog({
db,
db,
@@ -991,7 +1061,21 @@ async function main(): Promise<void> {
@@ -991,7 +1061,21 @@ async function main(): Promise<void> {
perPerson: config.creatorWorldsPerPerson,
perPerson: config.creatorWorldsPerPerson,
events: { db, sink, siteUrl: gamesSiteUrl },
events: { db, sink, siteUrl: gamesSiteUrl },
...(worldSearch ? { search: worldSearch } : {}),
...(worldSearch ? { search: worldSearch } : {}),
+ ...(worldSharing ? { sharing: worldSharing } : {}),
})
})
+ // The uniform share API for worlds: /api/v1/world/<id>/share and the shared-with-me listing.
+ const serveWorldShare = worldSharing
+ ? createWorldShareRoutes({
+ sharing: worldSharing,
+ store: creatorWorlds,
+ resolveUser: (token) =>
+ config.networkAuthUrl
+ ? resolveNetworkUser(config.networkAuthUrl, token)
+ : Promise.resolve(null),
+ publicUrl: gamesSiteUrl,
+ log: log.child({ system: 'world-sharing' }),
+ })
+ : null
// The asset store's JSON API (worlds/assetRoutes.ts): builds and scripts creators publish for others' worlds.
// The asset store's JSON API (worlds/assetRoutes.ts): builds and scripts creators publish for others' worlds.
const serveAssets = createAssetRoutes({
const serveAssets = createAssetRoutes({
store: createCreatorAssetStore(db),
store: createCreatorAssetStore(db),
@@ -1186,6 +1270,7 @@ async function main(): Promise<void> {
@@ -1186,6 +1270,7 @@ async function main(): Promise<void> {
}
}
if (serveCasual(req, res)) return true
if (serveCasual(req, res)) return true
if (serveAiMatches(req, res)) return true
if (serveAiMatches(req, res)) return true
+ if (serveWorldShare?.(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveAssets(req, res)) return true
if (serveAssets(req, res)) return true
if (serveWorldPage(req, res)) return true
if (serveWorldPage(req, res)) return true
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import type { CreatorWorldStore } from '@openvibe/persistence'
+import type { Logger } from '@openvibe/shared'
+import type { NetworkUser } from '../net/networkAuth.js'
+import type { ResourceRef, Shares, Viewer } from 'openvibe-sdk/share'
+import { describeWorldRef, shareUser, type WorldSharing } from './sharing.js'
+
+/**
+ * The uniform share API for creator worlds (openvibe-sdk/share): `/api/v1/world/<id>/share`, its
+ * requests and transfer endpoints, `/api/v1/share/people?name=` and `/api/v1/shared-with-me`.
+ *
+ * The SDK router answers only its own paths, so it sits before the worlds API and hands everything
+ * else back. A person is the signed-in Games account (Bearer token, or the ovg_sso cookie the site
+ * leaves); a guest is nobody here, as with every other share operation.
+ */
+export function createWorldShareRoutes(opts: {
+ sharing: WorldSharing
+ store: CreatorWorldStore
+ resolveUser: (token: string) => Promise<NetworkUser | null>
+ publicUrl: string
+ log: Logger
+}): (req: IncomingMessage, res: ServerResponse) => boolean {
+ const routed: Shares = Object.create(opts.sharing.shares) as Shares
+ routed.getWithRevision = async (viewer: Viewer, ref: ResourceRef) => {
+ await opts.sharing.ensure(ref.id)
+ return await opts.sharing.shares.getWithRevision(viewer, ref)
+ }
+ routed.update = async (viewer: Viewer, ref: ResourceRef, body: Parameters<Shares['update']>[2], extra: Parameters<Shares['update']>[3]) => {
+ await opts.sharing.ensure(ref.id)
+ return await opts.sharing.shares.update(viewer, ref, body, extra)
+ }
+ routed.requestAccess = async (viewer: Viewer, ref: ResourceRef) => {
+ await opts.sharing.ensure(ref.id)
+ return await opts.sharing.shares.requestAccess(viewer, ref)
+ }
+ const handler = opts.sharing.shares.router({
+ service: 'games',
+ authenticate: async (req: IncomingMessage) => {
+ const user = await shareUser(req, opts.resolveUser)
+ return opts.sharing.viewer(user)
+ },
+ describe: describeWorldRef(opts.store, opts.publicUrl),
+ })
+ const matches = (path: string) => path === '/api/v1/shared-with-me' || path === '/api/v1/share/people'
+ || /^\/api\/v1\/world\/[^/]+\/share(?:\/|$)/.test(path)
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
+ const path = (req.url ?? '/').split('?')[0] ?? '/'
+ if (!matches(path)) return false
+ handler(req, res).catch((err: unknown) => {
+ opts.log.warn('world share request failed', { error: String((err as Error | undefined)?.message ?? err) })
+ if (!res.headersSent) {
+ res.writeHead(500, { 'content-type': 'application/json', 'cache-control': 'no-store' })
+ res.end('{"code":"share.internal","error":"Sharing unavailable"}')
+ }
+ })
+ return true
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+import { emptyMapV2 } from '@openvibe/content'
+import { createCreatorWorldStore } from '@openvibe/persistence'
+import { openTestDb } from '@openvibe/persistence/testing'
+import { createConsoleLogger } from '@openvibe/shared'
+import { expect, it } from 'vitest'
+import { createWorldSharing } from './sharing.js'
+import { mayJoin } from './worldHost.js'
+import type { GameConnection } from '../game/systems/context.js'
+
+const KIM = 'usr_01J0000000000000000000000K'
+const LEE = 'usr_01J0000000000000000000000M'
+const SAM = 'usr_01J0000000000000000000000N'
+const PEOPLE: Record<string, { subject: { type: 'user'; id: string }; username: string; display_name: string }> = {
+ [LEE]: { subject: { type: 'user', id: LEE }, username: 'lee', display_name: 'Lee' },
+ [SAM]: { subject: { type: 'user', id: SAM }, username: 'sam', display_name: 'Sam' },
+ [KIM]: { subject: { type: 'user', id: KIM }, username: 'kim', display_name: 'Kim' },
+}
+const log = createConsoleLogger({ app: 'world-sharing-test' }, 'error')
+
+it('a draft shared with Specific people is visible to them and joinable, and only to them', async () => {
+ const db = await openTestDb()
+ const store = createCreatorWorldStore(db)
+ const world = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Secret test world',
+ summary: '',
+ map: emptyMapV2(),
+ now: 1,
+ })
+ const sent: unknown[] = []
+ const sharing = createWorldSharing({
+ db,
+ store,
+ log,
+ network: {
+ async json(input) {
+ const { path, json, query } = input as { path: string; json?: unknown; query?: Record<string, string> }
+ if (path === '/internal/identity/resolve') {
+ const name = String(query?.username ?? '').toLowerCase()
+ const person = Object.values(PEOPLE).find((p) => p.username === name)
+ if (!person) throw Object.assign(new Error('not found'), { status: 404 })
+ return { ...person, banned: false, deleted: false }
+ }
+ if (path === '/internal/identity/resolve-batch') {
+ const results: Record<string, unknown> = {}
+ for (const id of ((json as { subject_ids?: string[] }).subject_ids ?? [])) if (PEOPLE[id]) results[id] = { ...PEOPLE[id], banned: false, deleted: false }
+ return { results }
+ }
+ if (path === '/internal/notifications/push') return { queued: true }
+ throw Object.assign(new Error('not found'), { status: 404 })
+ },
+ },
+ events: { async publish(event) { sent.push(event) } },
+ notify: async () => {},
+ })
+ await sharing.register(world)
+ expect(await sharing.audienceOf(world)).toBe('private')
+
+ const kim = { id: '', name: 'kim', rank: null, subjectId: KIM }
+ const lee = { id: '', name: 'lee', rank: null, subjectId: LEE }
+ const sam = { id: '', name: 'sam', rank: null, subjectId: SAM }
+ expect(await sharing.canView(lee, world.id)).toBe(false)
+
+ const kimViewer = sharing.viewer(kim)
+ if (!kimViewer) throw new Error('no view')
+ await sharing.shares.update(kimViewer, { service: 'games', kind: 'world', id: world.id }, {
+ audience: 'people',
+ members: [{ name: '@lee', role: 'viewer' }],
+ })
+ expect((await sharing.audienceOf({ ...world, status: 'draft' }))).toBe('people')
+ expect(await sharing.canView(lee, world.id)).toBe(true)
+ expect(await sharing.canView(sam, world.id)).toBe(false)
+ expect(await sharing.canView(null, world.id)).toBe(false)
+ expect(await sharing.canEdit(lee, world.id)).toBe(false)
+
+ const as = (subject: string | null, rank: GameConnection['identity'] extends never ? never : 'admin' | null = null): GameConnection => ({
+ identity: subject ? { subject, rank, authIat: null } : { guestKeyHash: 'a'.repeat(64) },
+ send: () => {},
+ close: () => {},
+ })
+ // The join still needs the world to be a draft and the person on its list.
+ expect(mayJoin({ ...world, status: 'draft' }, as(LEE), true)).toBe(true)
+ expect(mayJoin({ ...world, status: 'draft' }, as(SAM), false)).toBe(false)
+ expect(mayJoin({ ...world, status: 'draft' }, as(null), false)).toBe(false)
+
+ // Publishing widens it to Everyone; unpublishing narrows it to Only me.
+ await sharing.syncFromStatus(world.id, 'published')
+ expect((await sharing.audienceOf({ ...world, status: 'published' }))).toBe('public')
+ expect(await sharing.canView(sam, world.id)).toBe(true)
+ await sharing.syncFromStatus(world.id, 'draft')
+ expect(await sharing.canView(sam, world.id)).toBe(false)
+ await sharing.work()
+ expect(sent.length).toBeGreaterThan(0)
+ expect(JSON.stringify(sent)).not.toContain('Secret test world')
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+import { createRequire } from 'node:module'
+import type { IncomingMessage } from 'node:http'
+import type { Db, Tx } from 'openvibe-sdk/db'
+import type { Role, Shares, ShareOptions, ResourceRef } from 'openvibe-sdk/share'
+import type { SubjectRef } from 'openvibe-sdk/core'
+import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'
+import type { Logger } from '@openvibe/shared'
+import type { NetworkUser } from '../net/networkAuth.js'
+import { viewerAccountKey } from '../net/viewer.js'
+
+/**
+ * Sharing for creator worlds (design 2026-11-06, VISIBILITY-EVERYWHERE step 5; openvibe-sdk/share).
+ *
+ * A draft is Only me. Its Share sheet can name people (`people`) so friends can open its page and join
+ * and play it before it is published; publishing widens it to Everyone, and the public list, the
+ * sitemap and Search still show published worlds only. The `shares` row is the access authority for
+ * every world read path (the API, /worlds/<id>, the join), with `status = 'published'` as the public
+ * fact the gathered pages keep using.
+ */
+const req = createRequire(import.meta.url)
+type ShareModule = {
+ createShares(options: ShareOptions): Shares
+}
+const { createShares } = req('openvibe-sdk/share') as ShareModule
+
+const SERVICE = 'games'
+const KIND = 'world'
+const AUDIENCES = ['public', 'link', 'people', 'private'] as const
+
+export interface WorldSharingOptions {
+ db: Db
+ store: CreatorWorldStore
+ network: { json(options: { service: string; audience: string; path: string; method?: string; json?: unknown; query?: Record<string, string> }): Promise<unknown> }
+ events: { publish(event: unknown): Promise<unknown> }
+ notify(notification: Record<string, unknown>): Promise<unknown>
+ log: Logger
+ publicUrl?: string
+}
+
+export interface WorldSharing {
+ shares: Shares
+ service: string
+ kind: string
+ /** The SDK viewer for a Games account, or null. */
+ viewer(user: NetworkUser | null): { subject: { type: 'user'; id: string }; staff: boolean } | null
+ canView(user: NetworkUser | null, id: string): Promise<boolean>
+ canEdit(user: NetworkUser | null, id: string): Promise<boolean>
+ /** The world's audience: its share row, else published → public and draft/hidden → private. */
+ audienceOf(world: CreatorWorld | null): Promise<'public' | 'link' | 'people' | 'private'>
+ /** Register a world that has no share row yet (a legacy world, or one created before its sheet opened). */
+ ensure(id: string): Promise<void>
+ /** A new world starts Only me; publishing through the owner's PATCH widens it to the status's audience. */
+ register(world: CreatorWorld): Promise<void>
+ syncFromStatus(id: string, status: string): Promise<void>
+ /** The viewer's account key (usr_… or guest:…), as the page resolver returns it. */
+ viewerFromRequest(req: IncomingMessage, networkAuthUrl: string | null): Promise<{ subject: { type: 'user'; id: string }; staff: boolean } | null>
+ work(): Promise<{ expired: number; sent: number }>
+}
+
+const subjectOf = (value: string | null | undefined) =>
+ value && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(value) ? { type: 'user' as const, id: value } : null
+
+export function createWorldSharing(opts: WorldSharingOptions): WorldSharing {
+ const { db, store, log } = opts
+ const audiences: string[] = [...AUDIENCES]
+
+ const shares = createShares({
+ db,
+ network: opts.network,
+ events: opts.events,
+ notify: opts.notify,
+ async onOwnerChange(ref: ResourceRef, _from: SubjectRef, to: SubjectRef, tx: Tx) {
+ if (ref.service !== SERVICE || ref.kind !== KIND || to?.type !== 'user') return
+ await tx.exec('UPDATE creator_worlds SET owner_subject = $1 WHERE id = $2', [to.id, ref.id])
+ },
+ } as unknown as ShareOptions)
+
+ const viewer = (user: NetworkUser | null) => {
+ const subject = subjectOf(user?.subjectId ?? null)
+ if (!subject) return null
+ return { subject, staff: user?.rank === 'owner' || user?.rank === 'admin' }
+ }
+
+ const refOf = (id: string) => ({ service: SERVICE, kind: KIND, id: String(id) })
+
+ async function shareOf(id: string): Promise<{ audience: string } | null> {
+ return await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)])
+ }
+
+ async function audienceOf(world: CreatorWorld | null): Promise<'public' | 'link' | 'people' | 'private'> {
+ if (!world) return 'private'
+ const row = await shareOf(world.id)
+ if (row && audiences.includes(row.audience)) return row.audience as 'public' | 'link' | 'people' | 'private'
+ return world.status === 'published' ? 'public' : 'private'
+ }
+
+ async function ensure(id: string): Promise<void> {
+ if (await shareOf(id)) return
+ const world = await store.get(id)
+ if (!world) return
+ await register(world)
+ }
+
+ async function register(world: CreatorWorld): Promise<void> {
+ const owner = subjectOf(world.ownerSubject) || { type: 'service' as const, id: SERVICE }
+ try {
+ await shares.register(refOf(world.id), { owner_subject: owner, audience: world.status === 'published' ? 'public' : 'private' })
+ } catch (err) {
+ if ((err as { status?: number }).status !== 409) throw err // somebody registered it first: use theirs
+ }
+ }
+
+ /**
+ * Publishing through the owner's PATCH widens the world to Everyone; unpublishing narrows it to Only me.
+ * It goes through the SDK as the owner, so the change is logged and announced like one made in the sheet.
+ */
+ async function syncFromStatus(id: string, status: string): Promise<void> {
+ const next = status === 'published' ? 'public' : 'private'
+ const world = await store.get(id)
+ const owner = subjectOf(world?.ownerSubject ?? null)
+ if (!owner) return
+ await ensure(id)
+ const current = await shareOf(id)
+ if (!current || current.audience === next) return
+ await shares.setAudience({ subject: owner, staff: false }, refOf(id), next)
+ }
+
+ return {
+ shares,
+ service: SERVICE,
+ kind: KIND,
+ viewer,
+ canView: async (user, id) => await shares.canView(viewer(user), refOf(id)),
+ canEdit: async (user, id) => await shares.canEdit(viewer(user), refOf(id)),
+ audienceOf,
+ ensure,
+ register,
+ syncFromStatus,
+ async viewerFromRequest(request, networkAuthUrl) {
+ const key = await viewerAccountKey(request, { networkAuthUrl }).catch(() => null)
+ const subject = subjectOf(key)
+ return subject ? { subject, staff: false } : null
+ },
+ async work() {
+ let expired = 0
+ try { expired = await shares.expireMembers() } catch (err) { log.warn('sharing expiry sweep failed', { error: String((err as Error | undefined)?.message ?? err) }) }
+ let sent = 0
+ try { sent = await shares.flush() } catch (err) { log.warn('sharing deliveries failed', { error: String((err as Error | undefined)?.message ?? err) }) }
+ return { expired, sent }
+ },
+ }
+}
+
+/** The uniform API's describe(ref): the sheet shows the world's title and page to its owner. */
+export function describeWorldRef(store: CreatorWorldStore, publicUrl: string) {
+ return async (ref: { service: string; kind: string; id: string }) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {}
+ const world = await store.get(ref.id)
+ if (!world) return {}
+ return {
+ title: world.title,
+ link: `${publicUrl.replace(/\/+$/, '')}/worlds/${world.id}`,
+ roles: ['viewer'] as Role[],
+ audiences: [...AUDIENCES],
+ transferable: false,
+ world: { status: world.status, revision: world.revision },
+ }
+ }
+}
+
+/** The signed-in Games account a share request names (bearer first, then the ovg_sso cookie). */
+export async function shareUser(req: IncomingMessage, resolveUser: (token: string) => Promise<NetworkUser | null>): Promise<NetworkUser | null> {
+ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
+ const cookie = /(?:^|;\s*)ovg_sso=([^;]+)/.exec(req.headers.cookie ?? '')?.[1]
+ const token = bearer ?? (cookie ? decodeURIComponent(cookie) : null)
+ if (!token) return null
+ return await resolveUser(token).catch(() => null)
+}
@@ -23,6 +23,8 @@ export interface WorldHostOptions {
@@ -23,6 +23,8 @@ export interface WorldHostOptions {
maxRunning: number
maxRunning: number
/** A world with nobody in it stops after this long. */
/** A world with nobody in it stops after this long. */
idleMs: number
idleMs: number
+ /** A draft shared with this signed-in person (share.v1 `people`): they may join and test it. */
+ canViewShared?: (id: string, subject: string) => Promise<boolean>
now?: () => number
now?: () => number
}
}
@@ -33,7 +35,7 @@ interface Running {
@@ -33,7 +35,7 @@ interface Running {
activeAt: number
activeAt: number
}
}
-/** Who may join a world that is not published: its owner, and staff. */
+/** Who may join a world that is not published: its owner, its team, the people its owner shared it with, and staff. */
export function mayJoin(world: CreatorWorld, conn: GameConnection, onTeam = false): boolean {
export function mayJoin(world: CreatorWorld, conn: GameConnection, onTeam = false): boolean {
if (world.status === 'published') return true
if (world.status === 'published') return true
const identity = conn.identity
const identity = conn.identity
@@ -78,13 +80,16 @@ export class CreatorWorldHost implements DynamicPlaces {
@@ -78,13 +80,16 @@ export class CreatorWorldHost implements DynamicPlaces {
await this.stopping.get(id)
await this.stopping.get(id)
const world = await this.opts.store.get(id)
const world = await this.opts.store.get(id)
const identity = conn.identity
const identity = conn.identity
+ const subject = !!identity && 'subject' in identity ? identity.subject : null
const onTeam =
const onTeam =
!!world &&
!!world &&
world.status === 'draft' &&
world.status === 'draft' &&
- !!identity &&
- 'subject' in identity &&
- (await this.opts.store.isMember(id, identity.subject))
- if (!world || !mayJoin(world, conn, onTeam)) return null
+ !!subject &&
+ (await this.opts.store.isMember(id, subject))
+ const onList =
+ !!world && world.status === 'draft' && !!subject && !!this.opts.canViewShared &&
+ (await this.opts.canViewShared(id, subject).catch(() => false))
+ if (!world || !mayJoin(world, conn, onTeam || onList)) return null
const current = this.running.get(id)
const current = this.running.get(id)
if (current) {
if (current) {
current.activeAt = this.now()
current.activeAt = this.now()
@@ -3,12 +3,14 @@ import { createRequire } from 'node:module'
@@ -3,12 +3,14 @@ import { createRequire } from 'node:module'
import { CREATOR_WORLD_ID } from '@openvibe/content'
import { CREATOR_WORLD_ID } from '@openvibe/content'
import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'
import type { CreatorWorld, CreatorWorldStore } from '@openvibe/persistence'
import { escapeHtml, sendNotFound, type NotFoundLink } from '../net/notFound.js'
import { escapeHtml, sendNotFound, type NotFoundLink } from '../net/notFound.js'
+import type { WorldSharing } from './sharing.js'
/**
/**
- * GET /worlds/<id>: a published creator world's own page inside the OpenVibe Frame (openvibe-shared/shell), the page
+ * GET /worlds/<id>: a creator world's own page inside the OpenVibe Frame (openvibe-shared/shell), the page
* OpenVibe.Search links to and the link a creator shares: its title, who made it, their summary, how many have
* OpenVibe.Search links to and the link a creator shares: its title, who made it, their summary, how many have
- * visited, and the button that opens it in /play. Anything not published is a real 404 (a draft is its owner's, and
- * the owner reaches it from /worlds).
+ * visited, and the button that opens it in /play. A published world is everyone's; a draft is its owner's, its
+ * team's and the people the owner shared it with (share.v1 `people`), who may read the page and join to test it.
+ * Anyone else gets the real 404 the page has always given a draft.
*/
*/
interface Shell {
interface Shell {
page(o: Record<string, unknown>): string
page(o: Record<string, unknown>): string
@@ -40,7 +42,7 @@ const STYLE = `<style>
@@ -40,7 +42,7 @@ const STYLE = `<style>
.world .note{margin-top:22px;font-size:14px;color:var(--text-faint,#7386a3)}
.world .note{margin-top:22px;font-size:14px;color:var(--text-faint,#7386a3)}
</style>`
</style>`
-export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell, kitLib: Kit = kit()): string {
+export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell, kitLib: Kit = kit(), { draft = false }: { draft?: boolean } = {}): string {
const url = `${siteUrl}/worlds/${world.id}`
const url = `${siteUrl}/worlds/${world.id}`
const description =
const description =
world.summary ||
world.summary ||
@@ -62,7 +64,8 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
@@ -62,7 +64,8 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
siteName: SITE_NAME,
siteName: SITE_NAME,
description: description.slice(0, 300),
description: description.slice(0, 300),
canonical: url,
canonical: url,
- robots: 'index, follow',
+ // A draft is not in Search, the sitemap or the public list; only the people on its list may read it.
+ robots: draft ? 'noindex, nofollow' : 'index, follow',
head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n<link rel="stylesheet" href="/shared/kit.css">\n${STYLE}`,
head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n<link rel="stylesheet" href="/shared/kit.css">\n${STYLE}`,
navbar: {
navbar: {
service: 'games',
service: 'games',
@@ -89,7 +92,7 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
@@ -89,7 +92,7 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
<p class="stats">${escapeHtml(visits)}</p>
<p class="stats">${escapeHtml(visits)}</p>
<div class="row">
<div class="row">
<a class="play" href="/play?world=${world.id}">▶ Join this world</a>
<a class="play" href="/play?world=${world.id}">▶ Join this world</a>
- ${kitLib.share({ title: world.title, url, text: description })}
+ ${kitLib.share({ title: world.title, url, text: description, kind: 'world', id: world.id })}
<a class="more" href="/worlds?remix=${world.id}">Remix it</a>
<a class="more" href="/worlds?remix=${world.id}">Remix it</a>
<a class="more" href="/worlds">More worlds</a>
<a class="more" href="/worlds">More worlds</a>
</div>
</div>
@@ -103,6 +106,10 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
@@ -103,6 +106,10 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
export function createWorldPage(opts: {
export function createWorldPage(opts: {
store: CreatorWorldStore
store: CreatorWorldStore
siteUrl: string
siteUrl: string
+ /** The share.v1 row: a draft's Specific-people list, and the one check (worlds/sharing.ts). */
+ sharing?: WorldSharing
+ /** The Network's auth base, to read the signed-in person from the ovg_sso cookie. */
+ networkAuthUrl?: string | null
require?: NodeRequire
require?: NodeRequire
}) {
}) {
const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell
const shell = (opts.require ?? createRequire(import.meta.url))('openvibe-shared/shell') as Shell
@@ -114,14 +121,19 @@ export function createWorldPage(opts: {
@@ -114,14 +121,19 @@ export function createWorldPage(opts: {
const id = match[1] ?? ''
const id = match[1] ?? ''
void (async () => {
void (async () => {
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
- if (!world || world.status !== 'published') {
+ const viewer = world && world.status !== 'published' && opts.sharing
+ ? await opts.sharing.viewerFromRequest(req, opts.networkAuthUrl ?? null).catch(() => null)
+ : null
+ const draftView = !!world && world.status === 'draft' && !!viewer && !!opts.sharing &&
+ await opts.sharing.canView({ subjectId: viewer.subject.id, name: '', rank: null, id: '' }, world.id).catch(() => false)
+ if (!world || (world.status !== 'published' && !draftView)) {
sendNotFound(req, res, path, LINKS)
sendNotFound(req, res, path, LINKS)
return
return
}
}
- const body = worldPageHtml(world, siteUrl, shell)
+ const body = worldPageHtml(world, siteUrl, shell, kit(), { draft: world.status !== 'published' })
res.writeHead(200, {
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'content-type': 'text/html; charset=utf-8',
- 'cache-control': 'public, max-age=60',
+ 'cache-control': world.status === 'published' ? 'public, max-age=60' : 'no-store',
'content-length': String(Buffer.byteLength(body)),
'content-length': String(Buffer.byteLength(body)),
})
})
res.end(req.method === 'HEAD' ? undefined : body)
res.end(req.method === 'HEAD' ? undefined : body)
@@ -31,7 +31,7 @@ afterEach(async () => {
@@ -31,7 +31,7 @@ afterEach(async () => {
server = null
server = null
})
})
-async function setup(perPerson = 2) {
+async function setup(perPerson = 2, sharing?: unknown) {
const db = await openTestDb()
const db = await openTestDb()
const store = createCreatorWorldStore(db)
const store = createCreatorWorldStore(db)
const calls = {
const calls = {
@@ -65,6 +65,7 @@ async function setup(perPerson = 2) {
@@ -65,6 +65,7 @@ async function setup(perPerson = 2) {
address: () => '127.0.0.1',
address: () => '127.0.0.1',
log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),
log: createConsoleLogger({ app: 'world-routes-test' }, 'error'),
perPerson,
perPerson,
+ ...(sharing ? { sharing: sharing as never } : {}),
events: {
events: {
db,
db,
sink: {
sink: {
@@ -128,6 +129,31 @@ describe('/api/worlds', () => {
@@ -128,6 +129,31 @@ describe('/api/worlds', () => {
expect(parseIfMatch('')).toBeNull()
expect(parseIfMatch('')).toBeNull()
})
})
+ it('opens a draft shared with Specific people, and only to them', async () => {
+ const people = new Set([LEE])
+ const sharing = {
+ canView: async (user: NetworkUser | null, _id: string) => !!user?.subjectId && people.has(user.subjectId),
+ register: async () => {},
+ syncFromStatus: async () => {},
+ }
+ const { call, store } = await setup(2, sharing)
+ const world = await store.create({
+ ownerSubject: KIM,
+ ownerName: 'kim',
+ title: 'Secret test world',
+ summary: '',
+ map: emptyMapV2(),
+ now: 1,
+ })
+ // The draft is still not in the public list, and a stranger gets the missing answer.
+ expect((await call('GET', `/api/worlds/${world.id}`, 'lee')).status).toBe(200)
+ expect((await call('GET', `/api/worlds/${world.id}`, 'sam')).status).toBe(404)
+ expect((await call('GET', `/api/worlds/${world.id}/map`, 'lee')).status).toBe(200)
+ expect((await call('GET', `/api/worlds/${world.id}/map`, 'sam')).status).toBe(404)
+ const list = await call('GET', '/api/worlds')
+ expect(list.body.worlds).toEqual([])
+ })
+
it('builds a world as a team: an invite link adds editors who save its map and scripts; the owner keeps the rest', async () => {
it('builds a world as a team: an invite link adds editors who save its map and scripts; the owner keeps the rest', async () => {
const { call, store } = await setup(2)
const { call, store } = await setup(2)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Team base' })).body.id)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Team base' })).body.id)
@@ -28,6 +28,7 @@ import {
@@ -28,6 +28,7 @@ import {
import type { GamesActorLimits } from '../net/actorLimits.js'
import type { GamesActorLimits } from '../net/actorLimits.js'
import type { NetworkUser } from '../net/networkAuth.js'
import type { NetworkUser } from '../net/networkAuth.js'
import type { WorldSearchIndex } from './searchIndex.js'
import type { WorldSearchIndex } from './searchIndex.js'
+import type { WorldSharing } from './sharing.js'
/**
/**
* /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a
* /api/worlds: creator worlds (migration 0010). Anyone reads the published list and plays a published world; a
@@ -74,6 +75,8 @@ export interface WorldRouteOptions {
@@ -74,6 +75,8 @@ export interface WorldRouteOptions {
events?: { db: Db; sink: EventSink; siteUrl: string }
events?: { db: Db; sink: EventSink; siteUrl: string }
/** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */
/** Published worlds in OpenVibe.Search (searchIndex.ts), synced in the same transaction as the events. */
search?: WorldSearchIndex
search?: WorldSearchIndex
+ /** The share.v1 row next to each world (worlds/sharing.ts): a draft's Specific-people list, and the one check. */
+ sharing?: WorldSharing
/** Whether worlds' scripts run here (GAMES_CREATOR_SCRIPTS); they are kept either way. */
/** Whether worlds' scripts run here (GAMES_CREATOR_SCRIPTS); they are kept either way. */
scriptsRun?: boolean
scriptsRun?: boolean
now?: () => number
now?: () => number
@@ -251,13 +254,19 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -251,13 +254,19 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
return user as NetworkUser & { subjectId: string }
return user as NetworkUser & { subjectId: string }
}
}
- /** A world the caller may see: published, or theirs, or staff. Anything else is not found. */
+ /**
+ * A world the caller may see: published, theirs, staff, on its team (migration 0015), or a draft the
+ * owner shared with them before publishing (share.v1, `people`). A hidden world stays staff-only.
+ * Anything else is not found.
+ */
const visible = async (id: string, user: NetworkUser | null): Promise<CreatorWorld> => {
const visible = async (id: string, user: NetworkUser | null): Promise<CreatorWorld> => {
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
if (!world) return fail(404, 'world_not_found')
if (!world) return fail(404, 'world_not_found')
if (world.status === 'published' || isStaff(user) || world.ownerSubject === user?.subjectId)
if (world.status === 'published' || isStaff(user) || world.ownerSubject === user?.subjectId)
return world
return world
+ if (world.status !== 'draft') return fail(404, 'world_not_found')
if (user?.subjectId && (await opts.store.isMember(world.id, user.subjectId))) return world
if (user?.subjectId && (await opts.store.isMember(world.id, user.subjectId))) return world
+ if (user?.subjectId && opts.sharing && (await opts.sharing.canView(user, world.id).catch(() => false))) return world
return fail(404, 'world_not_found')
return fail(404, 'world_not_found')
}
}
@@ -336,6 +345,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -336,6 +345,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
map: starterCreatorMap(),
map: starterCreatorMap(),
now: now(),
now: now(),
})
})
+ await opts.sharing?.register(world).catch((err) => opts.log.warn('world share row not registered', { world: world.id, error: String(err?.message ?? err) }))
opts.log.info('creator world created', { world: world.id })
opts.log.info('creator world created', { world: world.id })
send(res, 201, ownerView(world, 0))
send(res, 201, ownerView(world, 0))
return
return
@@ -368,6 +378,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -368,6 +378,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },
remixedFrom: { id: src.id, title: src.title, owner: src.ownerName },
view: src.view,
view: src.view,
})
})
+ await opts.sharing?.register(world).catch((err) => opts.log.warn('remix share row not registered', { world: world.id, error: String(err?.message ?? err) }))
if (!own) await opts.store.countRemix(src.id)
if (!own) await opts.store.countRemix(src.id)
opts.log.info('creator world remixed', { world: world.id, from: src.id })
opts.log.info('creator world remixed', { world: world.id, from: src.id })
send(res, 201, ownerView(world, 0))
send(res, 201, ownerView(world, 0))
@@ -625,6 +636,9 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -625,6 +636,9 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
return next
return next
})
})
if (!updated) fail(404, 'world_not_found')
if (!updated) fail(404, 'world_not_found')
+ if (patch.status) {
+ await opts.sharing?.syncFromStatus(id, patch.status).catch((err) => opts.log.warn('world share audience not synced', { world: id, error: String(err?.message ?? err) }))
+ }
if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
if (mine) void opts.store.rename(user.subjectId, user.name).catch(() => undefined)
if (patch.status && patch.status !== world.status) {
if (patch.status && patch.status !== world.status) {
opts.log.info('creator world status', {
opts.log.info('creator world status', {
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 Games' 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,12 @@
@@ -0,0 +1,12 @@
+-- phase: migrate
+-- Every creator world that existed before sharing is registered once. A published world is Everyone;
+-- a draft or a hidden world is Only me. `people` is never in the column: it is a share.v1 audience
+-- only, set by the Share sheet before a world is published.
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT 'games', 'world', w.id,
+ CASE WHEN w.owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
+ THEN jsonb_build_object('type', 'user', 'id', w.owner_subject)
+ ELSE jsonb_build_object('type', 'service', 'id', 'games') END,
+ CASE WHEN w.status = 'published' THEN 'public' ELSE 'private' END
+FROM creator_worlds w
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/worlds/sharing.test.ts'
+// covers: 'apps/server/src/worlds'
+// Generated by node test/run.js --index; do not edit.