openvibers/openvibe.games Commits

Games: a creator world draft can be shared with specific people (sharing step 5) (#296)

OP OpenVibers committed 6 days ago · b0802b6fb1 · 11 files, +576 −17

* Games: a creator world draft can be shared with specific people (sharing step 5)

A share.v1 row next to every creator world, registered Only me for a new draft and Everyone once published, alongside the team the world already had. The world API, the draft's page and the join ask canView, so the people the owner names can open and test a draft before it is published; everybody else gets the same 404 the draft always gave. The uniform /api/v1/world/:id/share router sits behind the signed-in Games account, the page grows the one Share sheet, and the public list, sitemap and Search still show published worlds only.

* Review: type the share wiring so lint and typecheck stay clean

Use openvibe-sdk/share's Shares, ShareOptions, Viewer, ResourceRef and Role types instead of any; the Network JSON answer is unknown and callback parameters are explicit, because a cast drops contextual typing.

Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 4e874c2..8a0f8b4 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -100,6 +100,8 @@ import { createWorldHubStarter, worldScriptsFactory } from './worlds/startWorldH
import { 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> {
: null
worldSearch?.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 true
if (serveAiMatches(req, res)) return true
++ if (serveWorldShare?.(req, res)) return true
if (serveWorlds(req, res)) return true
if (serveAssets(req, res)) return true
if (serveWorldPage(req, res)) return true
ddiff --git a/apps/server/src/worlds/shareRoutes.ts b/apps/server/src/worlds/shareRoutes.ts
nnew file mode 100644
iindex 0000000..afd77c1
---- /dev/null
++++ b/apps/server/src/worlds/shareRoutes.ts
@@@ -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
++ }
++}
ddiff --git a/apps/server/src/worlds/sharing.test.ts b/apps/server/src/worlds/sharing.test.ts
nnew file mode 100644
iindex 0000000..fd5fe9d
---- /dev/null
++++ b/apps/server/src/worlds/sharing.test.ts
@@@ -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')
++})
ddiff --git a/apps/server/src/worlds/sharing.ts b/apps/server/src/worlds/sharing.ts
nnew file mode 100644
iindex 0000000..31b4dfd
---- /dev/null
++++ b/apps/server/src/worlds/sharing.ts
@@@ -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)
++}
ddiff --git a/apps/server/src/worlds/worldHost.ts b/apps/server/src/worlds/worldHost.ts
iindex 4d22dd8..7faaaf7 100644
---- a/apps/server/src/worlds/worldHost.ts
++++ b/apps/server/src/worlds/worldHost.ts
@@@ -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 true
const 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 : null
const 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 null
const current = this.running.get(id)
if (current) {
current.activeAt = this.now()
ddiff --git a/apps/server/src/worlds/worldPage.ts b/apps/server/src/worlds/worldPage.ts
iindex 1a14814..e8430c5 100644
---- a/apps/server/src/worlds/worldPage.ts
++++ b/apps/server/src/worlds/worldPage.ts
@@@ -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: Shell
siteName: 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: Shell
export function createWorldPage(opts: {
store: CreatorWorldStore
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
}) {
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)
ddiff --git a/apps/server/src/worlds/worldRoutes.test.ts b/apps/server/src/worlds/worldRoutes.test.ts
iindex 5f44a6f..15dce62 100644
---- a/apps/server/src/worlds/worldRoutes.test.ts
++++ b/apps/server/src/worlds/worldRoutes.test.ts
@@@ -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)
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex 87d6b7b..87cb41e 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -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?: boolean
now?: () => 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) : null
if (!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 world
return 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', {
ddiff --git a/packages/persistence/migrations/0021_sharing.sql b/packages/persistence/migrations/0021_sharing.sql
nnew file mode 100644
iindex 0000000..41733c9
---- /dev/null
++++ b/packages/persistence/migrations/0021_sharing.sql
@@@ -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;
ddiff --git a/packages/persistence/migrations/0022_sharing_backfill.sql b/packages/persistence/migrations/0022_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..7e544c2
---- /dev/null
++++ b/packages/persistence/migrations/0022_sharing_backfill.sql
@@@ -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;
ddiff --git a/test/apps__server__src__worlds__sharing.test.js b/test/apps__server__src__worlds__sharing.test.js
nnew file mode 100644
iindex 0000000..2e23466
---- /dev/null
++++ b/test/apps__server__src__worlds__sharing.test.js
@@@ -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.