openvibers/openvibe.games Commits

Teams for creator worlds: an owner's invite link adds editors who save the map and scripts and try the draft; the owner keeps publishing, renaming, deleting and the team; memberships follow account export, merge and deletion (#161)

OP OpenVibers committed 17 days ago · 193fe131aa · 12 files, +597 −16

Changes

ddiff --git a/apps/client/src/pages/worlds.ts b/apps/client/src/pages/worlds.ts
iindex 0f00ffb..f6d51a7 100644
---- a/apps/client/src/pages/worlds.ts
++++ b/apps/client/src/pages/worlds.ts
@@@ -20,10 +20,14 @@ interface World {
edit_url?: string
remixed_from?: { id: string; title: string; owner: string; url: string } | null
remixes?: number
++ /** 'editor' on a world of someone else's team (migration 0015). */
++ role?: 'owner' | 'editor'
}
interface Mine {
worlds: World[]
++ /** Worlds on someone else's team. */
++ team?: World[]
max: number
staff: boolean
}
@@@ -206,6 +210,7 @@ function mineCard(world: World): HTMLElement {
actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))
actions.append(
button('Scripts', () => void openScripts(world)),
++ button('Team', () => void openTeam(world)),
button('Rename', (b) => {
const title = prompt('New title (up to 60 characters)', world.title)
if (title !== null && title.trim() && title !== world.title)
@@@ -385,6 +390,195 @@ async function openScripts(world: World): Promise<void> {
dialog.showModal()
}
++/** A world on someone else's team: build it, try it, edit its scripts, or leave the team. */
++function teamCard(world: World): HTMLElement {
++ const card = el(
++ 'article',
++ { className: 'card' },
++ el('span', { className: 'badge team', text: 'Team' }),
++ titleOf(world),
++ el('p', { className: 'by', text: `by ${world.owner}` }),
++ el('p', { className: 'stats', text: stats(world) }),
++ )
++ const actions = el('div', { className: 'actions' })
++ if (world.edit_url) actions.append(link('Edit', world.edit_url, true))
++ if ((world.status ?? 'draft') !== 'hidden') actions.append(link('Try it', world.play_url))
++ actions.append(
++ button('Scripts', () => void openScripts(world)),
++ button(
++ 'Leave',
++ (b) =>
++ void (async () => {
++ if (!confirm(`Leave the team of “${world.title}”?`)) return
++ b.disabled = true
++ try {
++ await api(`/api/worlds/${world.id}/team/me`, { method: 'DELETE' })
++ } catch (err) {
++ mineStatus.textContent = err instanceof Error ? err.message : 'That did not work.'
++ } finally {
++ await loadMine()
++ }
++ })(),
++ 'danger',
++ ),
++ )
++ card.append(actions)
++ return card
++}
++
++/** Your world's team: who builds it with you, and the invite link that adds people. */
++async function openTeam(world: World): Promise<void> {
++ const dialog = el('dialog', { className: 'scripts' })
++ const body = el('div', { className: 'script-list' })
++ const status = el('p', { className: 'status' })
++ const draw = async () => {
++ let data: {
++ owner: string
++ members: { subject: string; name: string; added_at: string }[]
++ max: number
++ invite_expires_at?: string | null
++ }
++ try {
++ data = await api(`/api/worlds/${world.id}/team`)
++ } catch (err) {
++ status.textContent = err instanceof Error ? err.message : 'The team could not be loaded.'
++ return
++ }
++ const rows = data.members.map((m) =>
++ el(
++ 'div',
++ { className: 'row member' },
++ el('span', { text: m.name }),
++ button(
++ 'Remove',
++ (b) =>
++ void (async () => {
++ b.disabled = true
++ await api(`/api/worlds/${world.id}/team/${encodeURIComponent(m.subject)}`, {
++ method: 'DELETE',
++ })
++ await draw()
++ })(),
++ 'danger',
++ ),
++ ),
++ )
++ const invite = el('div', { className: 'invite' })
++ const showLink = (url: string, expires: string) => {
++ const field = el('input')
++ field.readOnly = true
++ field.value = new URL(url, location.origin).toString()
++ field.setAttribute('aria-label', 'Invite link')
++ invite.replaceChildren(
++ el(
++ 'label',
++ { text: `Invite link (works until ${new Date(expires).toLocaleDateString()})` },
++ field,
++ ),
++ el(
++ 'div',
++ { className: 'row' },
++ button('Copy link', () => {
++ field.select()
++ void navigator.clipboard
++ ?.writeText(field.value)
++ .then(() => (status.textContent = 'Link copied.'))
++ }),
++ button(
++ 'Stop the link',
++ (b) =>
++ void (async () => {
++ b.disabled = true
++ await api(`/api/worlds/${world.id}/invite`, { method: 'DELETE' })
++ await draw()
++ })(),
++ ),
++ ),
++ )
++ }
++ const make = button(
++ data.invite_expires_at ? 'Make a new invite link' : 'Make an invite link',
++ (b) =>
++ void (async () => {
++ b.disabled = true
++ try {
++ const made = await api<{ url: string; expires_at: string }>(
++ `/api/worlds/${world.id}/invite`,
++ {
++ method: 'POST',
++ },
++ )
++ showLink(made.url, made.expires_at)
++ status.textContent =
++ 'Send this link to the people you want to build with. Anyone signed in who opens it joins.'
++ } finally {
++ b.disabled = false
++ }
++ })(),
++ 'primary',
++ )
++ invite.replaceChildren(
++ el('p', {
++ className: 'status',
++ text: data.invite_expires_at
++ ? `An invite link works until ${new Date(data.invite_expires_at).toLocaleDateString()}. For safety it is shown only when made; make a new one to share it again.`
++ : 'No invite link yet.',
++ }),
++ el('div', { className: 'row' }, make),
++ )
++ if (data.invite_expires_at) {
++ invite.querySelector('.row')?.append(
++ button(
++ 'Stop the link',
++ (b) =>
++ void (async () => {
++ b.disabled = true
++ await api(`/api/worlds/${world.id}/invite`, { method: 'DELETE' })
++ await draw()
++ })(),
++ ),
++ )
++ }
++ body.replaceChildren(
++ el('p', {
++ className: 'status',
++ text: `Your team edits this world's map and scripts with you (up to ${data.max} people). You alone publish, rename and delete it.`,
++ }),
++ ...(rows.length ? rows : [el('p', { className: 'status', text: 'Nobody on the team yet.' })]),
++ invite,
++ )
++ }
++ dialog.append(
++ el('h2', { text: `Team for ${world.title}` }),
++ body,
++ el(
++ 'div',
++ { className: 'row' },
++ button('Close', () => dialog.close()),
++ ),
++ status,
++ )
++ dialog.addEventListener('close', () => dialog.remove())
++ document.body.append(dialog)
++ dialog.showModal()
++ await draw()
++}
++
++/** Join a team from an invite link, then open the world in the editor. */
++async function joinTeam(token: string): Promise<void> {
++ mineStatus.textContent = 'Joining the team…'
++ try {
++ const world = await api<World>('/api/worlds/join', {
++ method: 'POST',
++ body: JSON.stringify({ token }),
++ })
++ mineStatus.textContent = ''
++ if (world.edit_url) location.href = world.edit_url
++ } catch (err) {
++ mineStatus.textContent = err instanceof Error ? err.message : 'That invite did not work.'
++ }
++}
++
async function change(
b: HTMLButtonElement,
id: string,
@@@ -425,7 +619,7 @@ async function loadMine(): Promise<void> {
staff = mine.staff
signedOut.hidden = true
signedIn.hidden = false
-- mineGrid.replaceChildren(...mine.worlds.map(mineCard))
++ mineGrid.replaceChildren(...mine.worlds.map(mineCard), ...(mine.team ?? []).map(teamCard))
const full = mine.worlds.length >= mine.max
createButton.disabled = full
mineStatus.textContent = mine.worlds.length
@@@ -485,6 +679,20 @@ moreButton.addEventListener('click', () => {
})
void loadMine().then(() => {
++ // An invite link: join the team and open the world, or sign in first and come back here to do it.
++ const joinToken = new URLSearchParams(location.search).get('join')
++ if (joinToken && /^[A-Za-z0-9_-]{20,64}$/.test(joinToken)) {
++ if (!signedIn.hidden) {
++ history.replaceState(null, '', '/worlds')
++ void joinTeam(joinToken)
++ } else {
++ const signIn = signedOut.querySelector<HTMLAnchorElement>('a.button')
++ if (signIn)
++ signIn.href = `/auth/login?next=${encodeURIComponent(`/worlds?join=${joinToken}`)}`
++ const note = signedOut.querySelector('.status')
++ if (note) note.textContent = "Sign in with your OpenVibe account to join this world's team."
++ }
++ }
// A world page's Remix link: make the copy and open it, or sign in first and come back here to do it.
const want = new URLSearchParams(location.search).get('remix')
if (want && /^w[a-z0-9]{10}$/.test(want)) {
ddiff --git a/apps/client/worlds.html b/apps/client/worlds.html
iindex f68f8ce..3e54455 100644
---- a/apps/client/worlds.html
++++ b/apps/client/worlds.html
@@@ -211,6 +211,10 @@
color: var(--bad);
border-color: rgba(248, 113, 113, 0.45);
}
++ .badge.team {
++ color: var(--accent-light);
++ border-color: var(--accent-light);
++ }
.button,
button {
display: inline-flex;
@@@ -334,6 +338,15 @@
label.check input {
width: auto;
}
++ .row.member {
++ justify-content: space-between;
++ padding: 6px 0;
++ border-bottom: 1px solid var(--border);
++ }
++ .invite {
++ display: grid;
++ gap: 8px;
++ }
fieldset.script > button {
justify-self: start;
}
ddiff --git a/apps/server/src/worlds/creatorWorlds.test.ts b/apps/server/src/worlds/creatorWorlds.test.ts
iindex 54334e5..56d9942 100644
---- a/apps/server/src/worlds/creatorWorlds.test.ts
++++ b/apps/server/src/worlds/creatorWorlds.test.ts
@@@ -52,8 +52,11 @@ it('decides who joins a world that is not published', () => {
const admin = as({ subject: LEE, rank: 'admin', authIat: null })
const guest = as({ guestKeyHash: 'a'.repeat(64) })
expect([kim, lee, admin, guest].map((c) => mayJoin(world, c))).toEqual([true, false, true, false])
++ // On the world's team, a draft is lee's to try too (migration 0015); a hidden one is still not.
++ expect(mayJoin(world, lee, true)).toBe(true)
const hidden = { ...world, status: 'hidden' } as CreatorWorld
expect([kim, admin].map((c) => mayJoin(hidden, c))).toEqual([false, true])
++ expect(mayJoin(hidden, lee, true)).toBe(false)
const published = { ...world, status: 'published' } as CreatorWorld
expect(mayJoin(published, guest)).toBe(true)
})
ddiff --git a/apps/server/src/worlds/worldHost.ts b/apps/server/src/worlds/worldHost.ts
iindex 734d567..4d22dd8 100644
---- a/apps/server/src/worlds/worldHost.ts
++++ b/apps/server/src/worlds/worldHost.ts
@@@ -34,11 +34,12 @@ interface Running {
}
/** Who may join a world that is not published: its owner, and staff. */
--export function mayJoin(world: CreatorWorld, conn: GameConnection): boolean {
++export function mayJoin(world: CreatorWorld, conn: GameConnection, onTeam = false): boolean {
if (world.status === 'published') return true
const identity = conn.identity
if (!identity || !('subject' in identity)) return false
-- if (identity.subject === world.ownerSubject) return world.status === 'draft'
++ // A draft is its builders': its owner and its team (migration 0015).
++ if (identity.subject === world.ownerSubject || onTeam) return world.status === 'draft'
return identity.rank === 'owner' || identity.rank === 'admin'
}
@@@ -76,7 +77,14 @@ export class CreatorWorldHost implements DynamicPlaces {
if (!id) return null
await this.stopping.get(id)
const world = await this.opts.store.get(id)
-- if (!world || !mayJoin(world, conn)) return null
++ const identity = conn.identity
++ 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
const current = this.running.get(id)
if (current) {
current.activeAt = this.now()
ddiff --git a/apps/server/src/worlds/worldRoutes.test.ts b/apps/server/src/worlds/worldRoutes.test.ts
iindex 55003a3..65ee3e2 100644
---- a/apps/server/src/worlds/worldRoutes.test.ts
++++ b/apps/server/src/worlds/worldRoutes.test.ts
@@@ -128,6 +128,81 @@ describe('/api/worlds', () => {
expect(parseIfMatch('')).toBeNull()
})
++ 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)
++
++ // Not yet on the team: the draft, its map and its team are not lee's to see.
++ expect((await call('GET', `/api/worlds/${id}/map`, 'lee')).status).toBe(404)
++ expect((await call('GET', `/api/worlds/${id}/team`, 'lee')).status).toBe(404)
++ expect((await call('POST', `/api/worlds/${id}/invite`, 'lee')).status).toBe(404)
++
++ // The owner makes a link; anyone signed in who opens it joins (a guest cannot).
++ const invite = await call('POST', `/api/worlds/${id}/invite`, 'kim')
++ expect(invite.status).toBe(201)
++ const token = String(invite.body.token)
++ expect(invite.body.url).toBe(`/worlds?join=${token}`)
++ expect((await call('POST', '/api/worlds/join', 'guest', { token })).status).toBe(401)
++ expect((await call('POST', '/api/worlds/join', 'lee', { token: 'x'.repeat(32) })).status).toBe(
++ 404,
++ )
++ const joined = await call('POST', '/api/worlds/join', 'lee', { token })
++ expect(joined.body).toMatchObject({ id, role: 'editor', edit_url: `/editor?world=${id}` })
++ expect((await call('GET', '/api/worlds/mine', 'lee')).body.team).toMatchObject([
++ { id, role: 'editor' },
++ ])
++
++ // An editor builds: the map (the revision still guards two saves at once) and the scripts.
++ const map = await call('GET', `/api/worlds/${id}/map`, 'lee')
++ expect(map.status).toBe(200)
++ const next: MapFileV2 = { ...emptyMapV2(), spawn: [5, 1, 5] }
++ expect(
++ (await call('PUT', `/api/worlds/${id}/map`, 'lee', next, { 'if-match': map.etag ?? '' }))
++ .status,
++ ).toBe(200)
++ expect(
++ (await call('PUT', `/api/worlds/${id}/map`, 'kim', next, { 'if-match': map.etag ?? '' }))
++ .status,
++ ).toBe(409)
++ expect((await call('PUT', `/api/worlds/${id}/scripts`, 'lee', { scripts: [] })).status).toBe(
++ 200,
++ )
++ // The owner keeps publishing, renaming, deleting and the team.
++ expect((await call('PATCH', `/api/worlds/${id}`, 'lee', { status: 'published' })).status).toBe(
++ 404,
++ )
++ expect((await call('DELETE', `/api/worlds/${id}`, 'lee')).status).toBe(404)
++ expect((await call('POST', `/api/worlds/${id}/invite`, 'lee')).status).toBe(404)
++ const team = await call('GET', `/api/worlds/${id}/team`, 'kim')
++ expect(team.body).toMatchObject({
++ owner: 'kim',
++ role: 'owner',
++ members: [{ subject: LEE, name: 'lee' }],
++ })
++ expect(team.body.invite_expires_at).toBeTruthy()
++ expect(
++ (await call('GET', `/api/worlds/${id}/team`, 'lee')).body.invite_expires_at,
++ ).toBeUndefined()
++
++ // An editor leaves; the owner can remove one; a stopped link adds nobody.
++ expect((await call('DELETE', `/api/worlds/${id}/team/me`, 'lee')).status).toBe(200)
++ expect((await call('GET', `/api/worlds/${id}/map`, 'lee')).status).toBe(404)
++ await call('POST', '/api/worlds/join', 'lee', { token })
++ expect((await call('DELETE', `/api/worlds/${id}/team/${LEE}`, 'kim')).status).toBe(200)
++ expect((await call('DELETE', `/api/worlds/${id}/invite`, 'kim')).status).toBe(200)
++ expect((await call('POST', '/api/worlds/join', 'lee', { token })).status).toBe(404)
++
++ // A link that ran out adds nobody; a full team (8 besides the owner) takes no one new.
++ const fresh = String((await call('POST', `/api/worlds/${id}/invite`, 'kim')).body.token)
++ for (let i = 0; i < 8; i++)
++ await store.addMember(id, `usr_${String(i).padStart(26, '0')}`, `m${i}`, 1)
++ const full = await call('POST', '/api/worlds/join', 'lee', { token: fresh })
++ expect(full.status).toBe(409)
++ expect(full.body.error).toBe('team_full')
++ await store.setInvite(id, 'gone', 1)
++ expect((await call('POST', '/api/worlds/join', 'lee', { token: fresh })).status).toBe(404)
++ })
++
it("keeps a world's scripts: the owner saves them through the content checks, staff read them, a save restarts it", async () => {
const { call, calls } = await setup(2)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Scripted' })).body.id)
ddiff --git a/apps/server/src/worlds/worldRoutes.ts b/apps/server/src/worlds/worldRoutes.ts
iindex 35368da..b04371a 100644
---- a/apps/server/src/worlds/worldRoutes.ts
++++ b/apps/server/src/worlds/worldRoutes.ts
@@@ -1,3 +1,4 @@
++import { createHash, randomBytes } from 'node:crypto'
import type { IncomingMessage, ServerResponse } from 'node:http'
import {
CREATOR_MAP_LIMITS,
@@@ -74,6 +75,10 @@ export interface WorldRouteOptions {
}
const PAGE_SIZE = 24
++/** A world's editors besides its owner; an invite link works this long (owner, 2026-10-27: "make teams"). */
++const TEAM_MAX = 8
++const INVITE_MS = 7 * 24 * 60 * 60 * 1000
++const inviteHash = (token: string): string => createHash('sha256').update(token).digest('hex')
const META_BYTES = 16 * 1024
const TITLE_MAX = 60
const SUMMARY_MAX = 280
@@@ -242,16 +247,20 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
if (!world) return fail(404, 'world_not_found')
if (world.status === 'published' || isStaff(user) || world.ownerSubject === user?.subjectId)
return world
++ if (user?.subjectId && (await opts.store.isMember(world.id, user.subjectId))) return world
return fail(404, 'world_not_found')
}
-- const owned = async (
++ /** A world the caller may build: theirs, or one whose team they are on. */
++ const editable = async (
id: string,
user: NetworkUser & { subjectId: string },
-- ): Promise<CreatorWorld> => {
++ ): Promise<{ world: CreatorWorld; role: 'owner' | 'editor' }> => {
const world = CREATOR_WORLD_ID.test(id) ? await opts.store.get(id) : null
-- if (!world || world.ownerSubject !== user.subjectId) return fail(404, 'world_not_found')
-- return world
++ if (!world) return fail(404, 'world_not_found')
++ if (world.ownerSubject === user.subjectId) return { world, role: 'owner' }
++ if (await opts.store.isMember(world.id, user.subjectId)) return { world, role: 'editor' }
++ return fail(404, 'world_not_found')
}
const limited = (
@@@ -281,8 +290,11 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
if (path === '/api/worlds/mine' && method === 'GET') {
const user = await signedIn(req)
const worlds = await opts.store.listByOwner(user.subjectId)
++ const team = await opts.store.listByMember(user.subjectId)
send(res, 200, {
worlds: worlds.map((w) => ownerView(w, online(w.id))),
++ // Worlds on someone else's team: edit and try them; their owner publishes, renames and deletes.
++ team: team.map((w) => ({ ...ownerView(w, online(w.id)), role: 'editor' })),
max: opts.perPerson,
limits: CREATOR_MAP_LIMITS,
staff: isStaff(user),
@@@ -360,7 +372,8 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const sid = scriptsMatch[1] ?? ''
if (method === 'GET') {
const world = CREATOR_WORLD_ID.test(sid) ? await opts.store.get(sid) : null
-- if (!world || (world.ownerSubject !== user.subjectId && !isStaff(user)))
++ const onTeam = !!world && (await opts.store.isMember(world.id, user.subjectId))
++ if (!world || (world.ownerSubject !== user.subjectId && !onTeam && !isStaff(user)))
fail(404, 'world_not_found')
send(res, 200, {
scripts: (await opts.store.scripts(sid)) ?? [],
@@@ -369,7 +382,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
})
return
}
-- await owned(sid, user)
++ await editable(sid, user)
if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
const { text } = await readBody(req, CREATOR_SCRIPT_LIMITS.bytes + 16 * 1024)
const body = parseJson(text) as { scripts?: unknown } | null
@@@ -386,6 +399,92 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
return
}
++ // ── The team (migration 0015): editors besides the owner, who join through the owner's invite link ──
++ // GET /api/worlds/:id/team → { owner, members, invite_expires_at? } (owner and editors)
++ // POST /api/worlds/:id/invite → { token, url, expires_at } (owner; a new link replaces the old)
++ // DELETE /api/worlds/:id/invite → stops the link (owner)
++ // POST /api/worlds/join { token } → joins the team; answers the world as its editor sees it
++ // DELETE /api/worlds/:id/team/:subject → the owner removes an editor, or an editor leaves
++ if (path === '/api/worlds/join' && method === 'POST') {
++ const user = await signedIn(req)
++ if (!(await opts.limits.worldWrite(req, res, `world:${user.subjectId}`))) return
++ const body = parseJson((await readBody(req, META_BYTES)).text) as { token?: unknown } | null
++ const token = typeof body?.token === 'string' ? body.token : ''
++ const world = /^[A-Za-z0-9_-]{20,64}$/.test(token)
++ ? await opts.store.byInvite(inviteHash(token), now())
++ : null
++ if (!world)
++ fail(404, 'invite_not_found', { detail: 'That invite link has run out or was stopped.' })
++ const w = world as CreatorWorld
++ if (w.ownerSubject !== user.subjectId) {
++ const members = await opts.store.members(w.id)
++ if (!members.some((m) => m.subject === user.subjectId) && members.length >= TEAM_MAX)
++ fail(409, 'team_full', {
++ detail: `A world's team takes ${TEAM_MAX} people besides its owner.`,
++ })
++ await opts.store.addMember(w.id, user.subjectId, user.name, now())
++ opts.log.info('creator world team joined', { world: w.id })
++ }
++ send(res, 200, {
++ ...ownerView(w, online(w.id)),
++ role: w.ownerSubject === user.subjectId ? 'owner' : 'editor',
++ })
++ return
++ }
++ const teamMatch = /^\/api\/worlds\/([^/]+)\/(team|invite)(?:\/([^/]+))?$/.exec(path)
++ if (teamMatch) {
++ const user = await signedIn(req)
++ const [, tid = '', what, who] = teamMatch
++ const { world, role } = await editable(tid, user)
++ if (what === 'team' && method === 'GET' && who === undefined) {
++ const members = await opts.store.members(world.id)
++ send(res, 200, {
++ owner: world.ownerName,
++ role,
++ max: TEAM_MAX,
++ members: members.map((m) => ({
++ subject: m.subject,
++ name: m.name,
++ added_at: new Date(m.addedAt).toISOString(),
++ })),
++ ...(role === 'owner'
++ ? {
++ invite_expires_at: await opts.store
++ .inviteExpires(world.id, now())
++ .then((at) => (at ? new Date(at).toISOString() : null)),
++ }
++ : {}),
++ })
++ return
++ }
++ if (what === 'team' && method === 'DELETE' && who !== undefined) {
++ // `me` is the caller (an editor leaving); the owner removes anyone by subject.
++ const target = who === 'me' ? user.subjectId : who
++ if (role !== 'owner' && target !== user.subjectId) fail(404, 'not_found')
++ await opts.store.removeMember(world.id, target)
++ send(res, 200, { ok: true })
++ return
++ }
++ if (what === 'invite' && who === undefined && (method === 'POST' || method === 'DELETE')) {
++ if (role !== 'owner') fail(404, 'not_found')
++ if (method === 'DELETE') {
++ await opts.store.setInvite(world.id, null, null)
++ send(res, 200, { ok: true })
++ return
++ }
++ const token = randomBytes(24).toString('base64url')
++ const expires = now() + INVITE_MS
++ await opts.store.setInvite(world.id, inviteHash(token), expires)
++ send(res, 201, {
++ token,
++ url: `/worlds?join=${token}`,
++ expires_at: new Date(expires).toISOString(),
++ })
++ return
++ }
++ fail(404, 'not_found')
++ }
++
const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)
if (!match) fail(404, 'not_found')
const id = match?.[1] ?? ''
@@@ -403,7 +502,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
if (isMap && (method === 'PUT' || method === 'POST')) {
const user = await signedIn(req)
-- await owned(id, user)
++ await editable(id, user)
if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
const { text, bytes } = await readBody(req, CREATOR_MAP_LIMITS.bytes + 64 * 1024)
const parsed = parseMapFile(parseJson(text))
ddiff --git a/docs/games.md b/docs/games.md
iindex 89c6ec4..4e83066 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -97,6 +97,12 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
- **Scripts:** a world's own `games-quickjs@1` mods, written at `/worlds` → **Scripts**, checked like an installed
pack's within the creator limits, and run in its rooms when `GAMES_CREATOR_SCRIPTS=1` (off by default); see
`mods.md` "Scripts in creator worlds".
++- **Team:** an owner adds editors (up to 8) through an invite link (`POST /api/worlds/:id/invite` →
++ `/worlds?join=<token>`, the token kept only as its hash, good for 7 days until stopped or replaced; migration 0015).
++ Editors save the world's map and scripts and try its draft; the owner alone publishes, renames, deletes and runs the
++ team (`GET /api/worlds/:id/team`, `DELETE /api/worlds/:id/team/:subject`, an editor leaves with `…/team/me`).
++ Two saves at once are still told apart by the map's revision (409). Team places follow account export, merge and
++ deletion. `/api/worlds/mine` lists them as `team`.
- **Who joins:** anyone for a published world; the owner for a draft; games staff for any. Staff
(`staff.games.manage` or `staff.moderation.chat`) take a world down with `PATCH {status: "hidden"}`; the owner
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
ddiff --git a/packages/persistence/migrations/0015_world_team.sql b/packages/persistence/migrations/0015_world_team.sql
nnew file mode 100644
iindex 0000000..4719053
---- /dev/null
++++ b/packages/persistence/migrations/0015_world_team.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- A creator world's team (owner, 2026-10-27: "make teams"): people its owner invited to build it with them. An editor
++-- saves the world's map and scripts (the map's revision guards two saves at once, as it does for one person); the
++-- owner keeps publishing, renaming, deleting and the team. An invite is a link: invite_hash is the sha-256 of its token,
++-- good until invite_expires (ms) for anyone signed in, until the owner stops it or makes a new one.
++CREATE TABLE IF NOT EXISTS creator_world_members (
++ world_id text COLLATE "C" NOT NULL REFERENCES creator_worlds(id) ON DELETE CASCADE,
++ subject text COLLATE "C" NOT NULL,
++ name text NOT NULL,
++ added_at bigint NOT NULL,
++ PRIMARY KEY (world_id, subject)
++);
++CREATE INDEX IF NOT EXISTS creator_world_members_subject ON creator_world_members (subject);
++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS invite_hash text COLLATE "C";
++ALTER TABLE creator_worlds ADD COLUMN IF NOT EXISTS invite_expires bigint;
++CREATE UNIQUE INDEX IF NOT EXISTS creator_worlds_invite ON creator_worlds (invite_hash) WHERE invite_hash IS NOT NULL;
ddiff --git a/packages/persistence/src/pg/creatorWorlds.test.ts b/packages/persistence/src/pg/creatorWorlds.test.ts
iindex 1117f9f..253365d 100644
---- a/packages/persistence/src/pg/creatorWorlds.test.ts
++++ b/packages/persistence/src/pg/creatorWorlds.test.ts
@@@ -54,6 +54,22 @@ describe('creator worlds store', () => {
await store.countRemix(world.id)
expect((await store.get(world.id))?.remixes).toBe(1)
await store.remove(remix.id)
++
++ // A team (migration 0015): editors, the worlds they edit, an invite that runs out.
++ await store.addMember(world.id, LEE, 'lee', 5)
++ await store.addMember(world.id, LEE, 'lee b', 6)
++ expect(await store.members(world.id)).toEqual([{ subject: LEE, name: 'lee b', addedAt: 5 }])
++ expect(await store.isMember(world.id, LEE)).toBe(true)
++ expect((await store.listByMember(LEE)).map((w) => w.id)).toEqual([world.id])
++ await store.setInvite(world.id, 'h1', 2000)
++ expect(await store.inviteExpires(world.id, 1500)).toBe(2000)
++ expect((await store.byInvite('h1', 1500))?.id).toBe(world.id)
++ expect(await store.byInvite('h1', 2500)).toBeNull()
++ expect(await store.inviteExpires(world.id, 2500)).toBeNull()
++ await store.setInvite(world.id, null, null)
++ expect(await store.byInvite('h1', 1500)).toBeNull()
++ expect(await store.removeMember(world.id, LEE)).toBe(true)
++ expect(await store.isMember(world.id, LEE)).toBe(false)
expect(await store.countByOwner(KIM)).toBe(1)
expect(await store.listPublished(10, 0)).toEqual([])
@@@ -103,11 +119,41 @@ describe('creator worlds store', () => {
expect(exported.creatorWorlds[0]).toMatchObject({ id: kims.id, title: 'A' })
expect(exported.creatorWorlds[0]).not.toHaveProperty('owner_subject')
++ // Team places follow the account too (migration 0015): KIM edits ZED's world and LEE's world.
++ const ZED = 'usr_01J0000000000000000000000Z'
++ const zeds = await store.create({
++ ownerSubject: ZED,
++ ownerName: 'zed',
++ title: 'Z',
++ summary: '',
++ map: MAP,
++ now: 2,
++ })
++ const lees = await store.create({
++ ownerSubject: LEE,
++ ownerName: 'lee',
++ title: 'L',
++ summary: '',
++ map: MAP,
++ now: 3,
++ })
++ await store.addMember(zeds.id, KIM, 'kim', 4)
++ await store.addMember(lees.id, KIM, 'kim', 4)
++ expect(
++ (await identity.exportSubject(KIM)).creatorWorldTeams.map((t) => t.world_id).sort(),
++ ).toEqual([zeds.id, lees.id].sort())
++
await identity.mergeSubject(KIM, LEE, 'merge-1', 10)
expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)
++ // KIM's place on ZED's team is LEE's now; on LEE's own world it is gone (an owner needs no team place).
++ expect((await store.members(zeds.id)).map((m) => m.subject)).toEqual([LEE])
++ expect(await store.members(lees.id)).toEqual([])
const erased = await identity.eraseSubjects([LEE])
-- expect(erased.creator_worlds).toBe(1)
++ expect(erased.creator_worlds).toBe(2)
expect(await store.get(kims.id)).toBeNull()
++ // Erased from every team, and ZED's world stays ZED's.
++ expect(await store.members(zeds.id)).toEqual([])
++ expect((await store.get(zeds.id))?.ownerSubject).toBe(ZED)
})
})
ddiff --git a/packages/persistence/src/pg/creatorWorlds.ts b/packages/persistence/src/pg/creatorWorlds.ts
iindex 8e9461a..3d0f33a 100644
---- a/packages/persistence/src/pg/creatorWorlds.ts
++++ b/packages/persistence/src/pg/creatorWorlds.ts
@@@ -92,6 +92,13 @@ function placeMatch(worldId: string) {
return sql`(place_id = ${place} OR place_id LIKE ${`${place}:instance:%`})`
}
++/** One of a world's editors besides its owner (migration 0015). */
++export interface CreatorWorldMember {
++ subject: string
++ name: string
++ addedAt: number
++}
++
export interface CreatorWorldStore {
create(input: {
ownerSubject: string
@@@ -134,6 +141,20 @@ export interface CreatorWorldStore {
countPlay(id: string): Promise<void>
/** One more remix of this world by someone else. */
countRemix(id: string): Promise<void>
++ /** The world's editors besides its owner, longest-standing first. */
++ members(id: string): Promise<CreatorWorldMember[]>
++ isMember(id: string, subject: string): Promise<boolean>
++ /** Adds (or renames) an editor. */
++ addMember(id: string, subject: string, name: string, now: number): Promise<void>
++ removeMember(id: string, subject: string): Promise<boolean>
++ /** Worlds this person edits as a team member (not their own), most recently changed first. */
++ listByMember(subject: string): Promise<CreatorWorld[]>
++ /** Sets the world's invite (the hash of its token and when it stops working), or clears it. */
++ setInvite(id: string, hash: string | null, expires: number | null): Promise<void>
++ /** When the world's invite stops working, or null when it has none (or it has run out). */
++ inviteExpires(id: string, now: number): Promise<number | null>
++ /** The world an invite token's hash opens, while it still works. */
++ byInvite(hash: string, now: number): Promise<CreatorWorld | null>
/** Removes the world and its place's rows. */
remove(id: string): Promise<boolean>
}
@@@ -236,6 +257,57 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
async countRemix(id) {
await db.exec(sql`UPDATE creator_worlds SET remixes = remixes + 1 WHERE id = ${id}`)
},
++ async members(id) {
++ const rows = await db.many<{ subject: string; name: string; added_at: number | string }>(
++ sql`SELECT subject, name, added_at FROM creator_world_members WHERE world_id = ${id} ORDER BY added_at, subject`,
++ )
++ return rows.map((r) => ({ subject: r.subject, name: r.name, addedAt: Number(r.added_at) }))
++ },
++ async isMember(id, subject) {
++ const row = await db.maybe<{ one: number }>(
++ sql`SELECT 1 AS one FROM creator_world_members WHERE world_id = ${id} AND subject = ${subject}`,
++ )
++ return !!row
++ },
++ async addMember(id, subject, name, now) {
++ await db.exec(sql`
++ INSERT INTO creator_world_members (world_id, subject, name, added_at) VALUES (${id}, ${subject}, ${name}, ${now})
++ ON CONFLICT (world_id, subject) DO UPDATE SET name = excluded.name
++ `)
++ },
++ async removeMember(id, subject) {
++ return (
++ (await db.exec(
++ sql`DELETE FROM creator_world_members WHERE world_id = ${id} AND subject = ${subject}`,
++ )) > 0
++ )
++ },
++ async listByMember(subject) {
++ const rows = await db.many<Row>(sql`
++ SELECT ${COLUMNS} FROM creator_worlds
++ WHERE id IN (SELECT world_id FROM creator_world_members WHERE subject = ${subject})
++ ORDER BY updated_at DESC
++ `)
++ return rows.map(fromRow)
++ },
++ async setInvite(id, hash, expires) {
++ await db.exec(
++ sql`UPDATE creator_worlds SET invite_hash = ${hash}, invite_expires = ${expires} WHERE id = ${id}`,
++ )
++ },
++ async inviteExpires(id, now) {
++ const row = await db.maybe<{ invite_expires: number | string | null }>(
++ sql`SELECT invite_expires FROM creator_worlds WHERE id = ${id} AND invite_hash IS NOT NULL`,
++ )
++ const at = row && row.invite_expires !== null ? Number(row.invite_expires) : null
++ return at !== null && at > now ? at : null
++ },
++ async byInvite(hash, now) {
++ const row = await db.maybe<Row>(sql`
++ SELECT ${COLUMNS} FROM creator_worlds WHERE invite_hash = ${hash} AND invite_expires > ${now}
++ `)
++ return row ? fromRow(row) : null
++ },
async remove(id) {
return db.tx(async () => {
const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)
@@@ -275,9 +347,21 @@ export async function exportCreatorWorlds(
`)
}
--/** Erasure (ADR-033): the worlds go with their places. Answers how many. */
++/** The account export's team memberships (ADR-033): the worlds this person edits for someone else. */
++export async function exportWorldTeams(
++ db: Db,
++ subject: string,
++): Promise<Record<string, unknown>[]> {
++ return db.many<Record<string, unknown>>(sql`
++ SELECT m.world_id, w.title, m.name, m.added_at FROM creator_world_members m
++ JOIN creator_worlds w ON w.id = m.world_id WHERE m.subject = ${subject} ORDER BY m.added_at
++ `)
++}
++
++/** Erasure (ADR-033): the worlds go with their places, and the person leaves every team. Answers how many worlds. */
export async function eraseCreatorWorlds(db: Db, subjects: readonly string[]): Promise<number> {
if (!subjects.length) return 0
++ await db.exec(sql`DELETE FROM creator_world_members WHERE subject IN (${idList(subjects)})`)
const ids = (
await db.many<{ id: string }>(
sql`SELECT id FROM creator_worlds WHERE owner_subject IN (${idList(subjects)})`,
@@@ -289,10 +373,24 @@ export async function eraseCreatorWorlds(db: Db, subjects: readonly string[]): P
return ids.length
}
--/** Account merge (ADR-029): the folded-in account's worlds belong to the survivor. */
++/**
++ * Account merge (ADR-029): the folded-in account's worlds belong to the survivor, and so do its team places (one per
++ * world; none on a world the survivor now owns).
++ */
export async function moveCreatorWorlds(db: Db, from: string, into: string): Promise<number> {
if (from === into) return 0
-- return db.exec(
++ const moved = await db.exec(
sql`UPDATE creator_worlds SET owner_subject = ${into} WHERE owner_subject = ${from}`,
)
++ await db.exec(sql`
++ UPDATE creator_world_members m SET subject = ${into}
++ WHERE m.subject = ${from}
++ AND NOT EXISTS (SELECT 1 FROM creator_world_members o WHERE o.world_id = m.world_id AND o.subject = ${into})
++ `)
++ await db.exec(sql`DELETE FROM creator_world_members WHERE subject = ${from}`)
++ await db.exec(sql`
++ DELETE FROM creator_world_members m USING creator_worlds w
++ WHERE m.world_id = w.id AND m.subject = w.owner_subject
++ `)
++ return moved
}
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex f73024f..21722d5 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -13,7 +13,12 @@ import type {
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
import { idList } from './sqlUtils.js'
import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.js'
--import { eraseCreatorWorlds, exportCreatorWorlds, moveCreatorWorlds } from './creatorWorlds.js'
++import {
++ eraseCreatorWorlds,
++ exportCreatorWorlds,
++ exportWorldTeams,
++ moveCreatorWorlds,
++} from './creatorWorlds.js'
/** The character places of one account, in order (protocol MAX_CHARACTERS). */
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
@@@ -198,6 +203,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
)
const casual = await exportCasual(db, subject)
const creatorWorlds = await exportCreatorWorlds(db, subject)
++ const creatorWorldTeams = await exportWorldTeams(db, subject)
return {
characters: characters.map(withoutAccountKey),
structures,
@@@ -208,6 +214,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
casualSaves: casual.saves,
casualLedger: casual.ledger,
creatorWorlds,
++ creatorWorldTeams,
}
},
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 0c4cd03..42141e3 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -93,6 +93,8 @@ export interface IdentityRepository {
casualLedger: Record<string, unknown>[]
/** The person's creator worlds, maps included. */
creatorWorlds: Record<string, unknown>[]
++ /** Worlds this person edits on someone else's team (migration 0015). */
++ creatorWorldTeams: Record<string, unknown>[]
}>
/**
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their