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)
Changes
@@@ -20,10 +20,14 @@ interface World {edit_url?: stringremixed_from?: { id: string; title: string; owner: string; url: string } | nullremixes?: 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: numberstaff: 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.staffsignedOut.hidden = truesignedIn.hidden = false-- mineGrid.replaceChildren(...mine.worlds.map(mineCard))++ mineGrid.replaceChildren(...mine.worlds.map(mineCard), ...(mine.team ?? []).map(teamCard))const full = mine.worlds.length >= mine.maxcreateButton.disabled = fullmineStatus.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)) {@@@ -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;}@@@ -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 CreatorWorldexpect([kim, admin].map((c) => mayJoin(hidden, c))).toEqual([false, true])++ expect(mayJoin(hidden, lee, true)).toBe(false)const published = { ...world, status: 'published' } as CreatorWorldexpect(mayJoin(published, guest)).toBe(true)})@@@ -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 trueconst identity = conn.identityif (!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 nullawait 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 nullconst current = this.running.get(id)if (current) {current.activeAt = this.now()@@@ -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)@@@ -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 * 1024const TITLE_MAX = 60const 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 worldreturn 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}`))) returnconst { 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}`))) returnconst { text, bytes } = await readBody(req, CREATOR_MAP_LIMITS.bytes + 64 * 1024)const parsed = parseMapFile(parseJson(text))@@@ -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 installedpack'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 ownercannot publish it again. Deleting a world removes its place's rows. A world's first publication is the eventnnew file mode 100644@@@ -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;@@@ -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)})})@@@ -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[]): Preturn 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}@@@ -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,}},@@@ -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
@@ -20,10 +20,14 @@ interface World {
@@ -20,10 +20,14 @@ interface World {
edit_url?: string
edit_url?: string
remixed_from?: { id: string; title: string; owner: string; url: string } | null
remixed_from?: { id: string; title: string; owner: string; url: string } | null
remixes?: number
remixes?: number
+ /** 'editor' on a world of someone else's team (migration 0015). */
+ role?: 'owner' | 'editor'
}
}
interface Mine {
interface Mine {
worlds: World[]
worlds: World[]
+ /** Worlds on someone else's team. */
+ team?: World[]
max: number
max: number
staff: boolean
staff: boolean
}
}
@@ -206,6 +210,7 @@ function mineCard(world: World): HTMLElement {
@@ -206,6 +210,7 @@ function mineCard(world: World): HTMLElement {
actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))
actions.append(button('Unpublish', (b) => void change(b, world.id, { status: 'draft' })))
actions.append(
actions.append(
button('Scripts', () => void openScripts(world)),
button('Scripts', () => void openScripts(world)),
+ button('Team', () => void openTeam(world)),
button('Rename', (b) => {
button('Rename', (b) => {
const title = prompt('New title (up to 60 characters)', world.title)
const title = prompt('New title (up to 60 characters)', world.title)
if (title !== null && title.trim() && title !== world.title)
if (title !== null && title.trim() && title !== world.title)
@@ -385,6 +390,195 @@ async function openScripts(world: World): Promise<void> {
@@ -385,6 +390,195 @@ async function openScripts(world: World): Promise<void> {
dialog.showModal()
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(
async function change(
b: HTMLButtonElement,
b: HTMLButtonElement,
id: string,
id: string,
@@ -425,7 +619,7 @@ async function loadMine(): Promise<void> {
@@ -425,7 +619,7 @@ async function loadMine(): Promise<void> {
staff = mine.staff
staff = mine.staff
signedOut.hidden = true
signedOut.hidden = true
signedIn.hidden = false
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
const full = mine.worlds.length >= mine.max
createButton.disabled = full
createButton.disabled = full
mineStatus.textContent = mine.worlds.length
mineStatus.textContent = mine.worlds.length
@@ -485,6 +679,20 @@ moreButton.addEventListener('click', () => {
@@ -485,6 +679,20 @@ moreButton.addEventListener('click', () => {
})
})
void loadMine().then(() => {
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.
// 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')
const want = new URLSearchParams(location.search).get('remix')
if (want && /^w[a-z0-9]{10}$/.test(want)) {
if (want && /^w[a-z0-9]{10}$/.test(want)) {
@@ -211,6 +211,10 @@
@@ -211,6 +211,10 @@
color: var(--bad);
color: var(--bad);
border-color: rgba(248, 113, 113, 0.45);
border-color: rgba(248, 113, 113, 0.45);
}
}
+ .badge.team {
+ color: var(--accent-light);
+ border-color: var(--accent-light);
+ }
.button,
.button,
button {
button {
display: inline-flex;
display: inline-flex;
@@ -334,6 +338,15 @@
@@ -334,6 +338,15 @@
label.check input {
label.check input {
width: auto;
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 {
fieldset.script > button {
justify-self: start;
justify-self: start;
}
}
@@ -52,8 +52,11 @@ it('decides who joins a world that is not published', () => {
@@ -52,8 +52,11 @@ it('decides who joins a world that is not published', () => {
const admin = as({ subject: LEE, rank: 'admin', authIat: null })
const admin = as({ subject: LEE, rank: 'admin', authIat: null })
const guest = as({ guestKeyHash: 'a'.repeat(64) })
const guest = as({ guestKeyHash: 'a'.repeat(64) })
expect([kim, lee, admin, guest].map((c) => mayJoin(world, c))).toEqual([true, false, true, false])
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
const hidden = { ...world, status: 'hidden' } as CreatorWorld
expect([kim, admin].map((c) => mayJoin(hidden, c))).toEqual([false, true])
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
const published = { ...world, status: 'published' } as CreatorWorld
expect(mayJoin(published, guest)).toBe(true)
expect(mayJoin(published, guest)).toBe(true)
})
})
@@ -34,11 +34,12 @@ interface Running {
@@ -34,11 +34,12 @@ interface Running {
}
}
/** Who may join a world that is not published: its owner, and staff. */
/** 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
if (world.status === 'published') return true
const identity = conn.identity
const identity = conn.identity
if (!identity || !('subject' in identity)) return false
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'
return identity.rank === 'owner' || identity.rank === 'admin'
}
}
@@ -76,7 +77,14 @@ export class CreatorWorldHost implements DynamicPlaces {
@@ -76,7 +77,14 @@ export class CreatorWorldHost implements DynamicPlaces {
if (!id) return null
if (!id) return null
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)
- 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)
const current = this.running.get(id)
if (current) {
if (current) {
current.activeAt = this.now()
current.activeAt = this.now()
@@ -128,6 +128,81 @@ describe('/api/worlds', () => {
@@ -128,6 +128,81 @@ describe('/api/worlds', () => {
expect(parseIfMatch('')).toBeNull()
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 () => {
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 { call, calls } = await setup(2)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Scripted' })).body.id)
const id = String((await call('POST', '/api/worlds', 'kim', { title: 'Scripted' })).body.id)
@@ -1,3 +1,4 @@
@@ -1,3 +1,4 @@
+import { createHash, randomBytes } from 'node:crypto'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import {
import {
CREATOR_MAP_LIMITS,
CREATOR_MAP_LIMITS,
@@ -74,6 +75,10 @@ export interface WorldRouteOptions {
@@ -74,6 +75,10 @@ export interface WorldRouteOptions {
}
}
const PAGE_SIZE = 24
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 META_BYTES = 16 * 1024
const TITLE_MAX = 60
const TITLE_MAX = 60
const SUMMARY_MAX = 280
const SUMMARY_MAX = 280
@@ -242,16 +247,20 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -242,16 +247,20 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
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 (user?.subjectId && (await opts.store.isMember(world.id, user.subjectId))) return world
return fail(404, 'world_not_found')
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,
id: string,
user: NetworkUser & { subjectId: 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
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 = (
const limited = (
@@ -281,8 +290,11 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -281,8 +290,11 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
if (path === '/api/worlds/mine' && method === 'GET') {
if (path === '/api/worlds/mine' && method === 'GET') {
const user = await signedIn(req)
const user = await signedIn(req)
const worlds = await opts.store.listByOwner(user.subjectId)
const worlds = await opts.store.listByOwner(user.subjectId)
+ const team = await opts.store.listByMember(user.subjectId)
send(res, 200, {
send(res, 200, {
worlds: worlds.map((w) => ownerView(w, online(w.id))),
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,
max: opts.perPerson,
limits: CREATOR_MAP_LIMITS,
limits: CREATOR_MAP_LIMITS,
staff: isStaff(user),
staff: isStaff(user),
@@ -360,7 +372,8 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -360,7 +372,8 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
const sid = scriptsMatch[1] ?? ''
const sid = scriptsMatch[1] ?? ''
if (method === 'GET') {
if (method === 'GET') {
const world = CREATOR_WORLD_ID.test(sid) ? await opts.store.get(sid) : null
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')
fail(404, 'world_not_found')
send(res, 200, {
send(res, 200, {
scripts: (await opts.store.scripts(sid)) ?? [],
scripts: (await opts.store.scripts(sid)) ?? [],
@@ -369,7 +382,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -369,7 +382,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
})
})
return
return
}
}
- await owned(sid, user)
+ await editable(sid, user)
if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
const { text } = await readBody(req, CREATOR_SCRIPT_LIMITS.bytes + 16 * 1024)
const { text } = await readBody(req, CREATOR_SCRIPT_LIMITS.bytes + 16 * 1024)
const body = parseJson(text) as { scripts?: unknown } | null
const body = parseJson(text) as { scripts?: unknown } | null
@@ -386,6 +399,92 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -386,6 +399,92 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
return
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)
const match = /^\/api\/worlds\/([^/]+)(\/map)?$/.exec(path)
if (!match) fail(404, 'not_found')
if (!match) fail(404, 'not_found')
const id = match?.[1] ?? ''
const id = match?.[1] ?? ''
@@ -403,7 +502,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
@@ -403,7 +502,7 @@ export function createWorldRoutes(opts: WorldRouteOptions) {
if (isMap && (method === 'PUT' || method === 'POST')) {
if (isMap && (method === 'PUT' || method === 'POST')) {
const user = await signedIn(req)
const user = await signedIn(req)
- await owned(id, user)
+ await editable(id, user)
if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
if (!(await limited('mapSave', req, res, `world:${user.subjectId}`))) return
const { text, bytes } = await readBody(req, CREATOR_MAP_LIMITS.bytes + 64 * 1024)
const { text, bytes } = await readBody(req, CREATOR_MAP_LIMITS.bytes + 64 * 1024)
const parsed = parseMapFile(parseJson(text))
const parsed = parseMapFile(parseJson(text))
@@ -97,6 +97,12 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -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
- **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
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".
`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
- **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
(`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
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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;
@@ -54,6 +54,22 @@ describe('creator worlds store', () => {
@@ -54,6 +54,22 @@ describe('creator worlds store', () => {
await store.countRemix(world.id)
await store.countRemix(world.id)
expect((await store.get(world.id))?.remixes).toBe(1)
expect((await store.get(world.id))?.remixes).toBe(1)
await store.remove(remix.id)
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.countByOwner(KIM)).toBe(1)
expect(await store.listPublished(10, 0)).toEqual([])
expect(await store.listPublished(10, 0)).toEqual([])
@@ -103,11 +119,41 @@ describe('creator worlds store', () => {
@@ -103,11 +119,41 @@ describe('creator worlds store', () => {
expect(exported.creatorWorlds[0]).toMatchObject({ id: kims.id, title: 'A' })
expect(exported.creatorWorlds[0]).toMatchObject({ id: kims.id, title: 'A' })
expect(exported.creatorWorlds[0]).not.toHaveProperty('owner_subject')
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)
await identity.mergeSubject(KIM, LEE, 'merge-1', 10)
expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)
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])
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()
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)
})
})
})
})
@@ -92,6 +92,13 @@ function placeMatch(worldId: string) {
@@ -92,6 +92,13 @@ function placeMatch(worldId: string) {
return sql`(place_id = ${place} OR place_id LIKE ${`${place}:instance:%`})`
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 {
export interface CreatorWorldStore {
create(input: {
create(input: {
ownerSubject: string
ownerSubject: string
@@ -134,6 +141,20 @@ export interface CreatorWorldStore {
@@ -134,6 +141,20 @@ export interface CreatorWorldStore {
countPlay(id: string): Promise<void>
countPlay(id: string): Promise<void>
/** One more remix of this world by someone else. */
/** One more remix of this world by someone else. */
countRemix(id: string): Promise<void>
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. */
/** Removes the world and its place's rows. */
remove(id: string): Promise<boolean>
remove(id: string): Promise<boolean>
}
}
@@ -236,6 +257,57 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
@@ -236,6 +257,57 @@ export function createCreatorWorldStore(db: Db): CreatorWorldStore {
async countRemix(id) {
async countRemix(id) {
await db.exec(sql`UPDATE creator_worlds SET remixes = remixes + 1 WHERE id = ${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) {
async remove(id) {
return db.tx(async () => {
return db.tx(async () => {
const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)
const gone = await db.exec(sql`DELETE FROM creator_worlds WHERE id = ${id}`)
@@ -275,9 +347,21 @@ export async function exportCreatorWorlds(
@@ -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> {
export async function eraseCreatorWorlds(db: Db, subjects: readonly string[]): Promise<number> {
if (!subjects.length) return 0
if (!subjects.length) return 0
+ await db.exec(sql`DELETE FROM creator_world_members WHERE subject IN (${idList(subjects)})`)
const ids = (
const ids = (
await db.many<{ id: string }>(
await db.many<{ id: string }>(
sql`SELECT id FROM creator_worlds WHERE owner_subject IN (${idList(subjects)})`,
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
@@ -289,10 +373,24 @@ export async function eraseCreatorWorlds(db: Db, subjects: readonly string[]): P
return ids.length
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> {
export async function moveCreatorWorlds(db: Db, from: string, into: string): Promise<number> {
if (from === into) return 0
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}`,
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
}
}
@@ -13,7 +13,12 @@ import type {
@@ -13,7 +13,12 @@ import type {
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
import type { IdentityRepository, MediaMirrorRepository, ModRepository } from '../repositories.js'
import { idList } from './sqlUtils.js'
import { idList } from './sqlUtils.js'
import { eraseCasual, exportCasual, moveCasualSaves } from './casualData.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). */
/** The character places of one account, in order (protocol MAX_CHARACTERS). */
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
@@ -198,6 +203,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -198,6 +203,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
)
)
const casual = await exportCasual(db, subject)
const casual = await exportCasual(db, subject)
const creatorWorlds = await exportCreatorWorlds(db, subject)
const creatorWorlds = await exportCreatorWorlds(db, subject)
+ const creatorWorldTeams = await exportWorldTeams(db, subject)
return {
return {
characters: characters.map(withoutAccountKey),
characters: characters.map(withoutAccountKey),
structures,
structures,
@@ -208,6 +214,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -208,6 +214,7 @@ export function createIdentityRepository(db: Db): IdentityRepository {
casualSaves: casual.saves,
casualSaves: casual.saves,
casualLedger: casual.ledger,
casualLedger: casual.ledger,
creatorWorlds,
creatorWorlds,
+ creatorWorldTeams,
}
}
},
},
@@ -93,6 +93,8 @@ export interface IdentityRepository {
@@ -93,6 +93,8 @@ export interface IdentityRepository {
casualLedger: Record<string, unknown>[]
casualLedger: Record<string, unknown>[]
/** The person's creator worlds, maps included. */
/** The person's creator worlds, maps included. */
creatorWorlds: Record<string, unknown>[]
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
* Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their