diff --git a/DEPLOYING.md b/DEPLOYING.md index 1d4c2d3..a3708b7 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -267,6 +267,15 @@ printf '1x0000000000000000000000000000000AA' | Both `auth` account caps above still apply on top of the bot check, and the per-IP one is the only cap that can see a web signup. +`www` reaches `auth` through a **service binding**, not over `auth.`, so that the +player's real IP survives the hop: a Worker subrequest to the public hostname re-enters +the Cloudflare edge, which rewrites `CF-Connecting-IP` to Cloudflare's own address, and +`auth` would then record one shared `signupIp` for every web account and cap the whole +internet at three. Two consequences: **deploy `auth` before `www`** on a fresh account +(the binding refuses to resolve otherwise), and web accounts created before this change +carry that shared address as their permanent `signupIp` — harmless, but they are not +counted against any real network. + ## Repository Structure - `apps/` - The service workers, one deployable Worker per subdirectory. Each has diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index 2f7b564..98a9876 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -12,7 +12,12 @@ * The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including * the nested `CurrentVersion` that carries the blob name and per-version costs — * shaped after a real `GET /api/inventions/v1?inventionId=…` response. + * + * Who OWNS an invention is a separate table (`inventory_invention`, written by the + * `econ` worker at purchase time); this module only reads it, to fold bought inventions + * into the caller's own list. See @repo/domain's inventory-invention-db.ts. */ +import { getOwnedInventionIds } from '@repo/domain' /** * Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql, @@ -265,6 +270,32 @@ export async function getInventionsByCreator( .sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId) } +/** + * The player's "my inventions" shelf (`v2/mine`): everything they created, plus + * everything they BOUGHT. Ownership of a bought invention lives in the + * `inventory_invention` table the `econ` worker writes at purchase time — a creator is + * never listed there (they own theirs through `CreatorPlayerId`), so the two sets are + * disjoint in practice and merged by id anyway. + * + * Bought inventions are returned whatever their state: unpublished or hidden since the + * purchase, they are still on the shelf of the player who paid for them. An owned id + * with no invention row left (deleted) simply drops out. Newest first, like the other + * invention lists; not paginated. + */ +export async function getMyInventions(db: D1Database, playerId: number): Promise { + const [created, ownedIds] = await Promise.all([ + getInventionsByCreator(db, playerId), + getOwnedInventionIds(db, playerId), + ]) + const bought = await getInventionsByIds(db, ownedIds) + + const byId = new Map() + for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention) + return [...byId.values()].sort( + (a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId + ) +} + /** * Invention search — the browse/search list the client shows when picking an * invention to spawn. Only published, non-hidden inventions are visible here (a diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index dfac9a5..2b101f4 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -6,12 +6,12 @@ import { createInvention, getFeaturedInventions, getInventionById, - getInventionsByCreator, getInventionsByIds, getInventionsByRoom, getInventionTagFilters, getInventionTags, getInventionVersion, + getMyInventions, getTopInventions, parsePermissionLevel, publishInvention, @@ -648,16 +648,20 @@ export const avatarRoutes = new Hono({ strict: false }) } ) - // The signed-in player's saved inventions ("my inventions"), newest first. - // Auth-gated; returns a bare array (empty when the player has saved none). + // The signed-in player's invention shelf ("my inventions"), newest first — the ones + // they created AND the ones they bought (`inventory_invention`, written by the `econ` + // worker's buyInvention). A bought invention stays on the shelf whatever happens to it + // afterwards: unpublished or hidden since, the buyer paid for it. + // Auth-gated; returns a bare array (empty when the player has neither). .get( '/api/inventions/v2/mine', describeRoute({ tags: ['Inventions'], summary: 'The caller’s own inventions', description: - '“My inventions”, newest first — including unpublished ones, which nobody else can ' + - 'see. Not paginated.', + '“My inventions”, newest first — the ones the caller created plus the ones they ' + + 'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' + + 'invention listed even if it has since been unpublished or hidden. Not paginated.', security: AUTHED, responses: { 200: json(InventionDto.array(), 'The caller’s inventions'), @@ -667,7 +671,7 @@ export const avatarRoutes = new Hono({ strict: false }) async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json(await getInventionsByCreator(c.env.DB, id)) + return c.json(await getMyInventions(c.env.DB, id)) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index a622295..b2bae5b 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import { GAME_VERSION, + grantInvention, + INVENTORY_INVENTION_SCHEMA_DDL, ROOM_SCHEMA_DDL, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL, @@ -91,6 +93,9 @@ beforeAll(async () => { // Inventions table (owned by the api worker) — invention save/mine use it. for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in. + for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Reports table (owned by the api worker) — player reports are recorded here. for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -453,6 +458,49 @@ describe('public endpoints', () => { expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId }) }) + test('GET /api/inventions/v2/mine lists bought inventions alongside the caller’s own', async () => { + // Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes + // exactly this row) and also creates one of their own. + const save = async (sub: string, name: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }), + }) + expect(res.status).toBe(200) + return ((await res.json()) as InventionSaveResult).Invention + } + const mine = async (sub: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, { + headers: await bearer(sub), + }) + expect(res.status).toBe(200) + return (await res.json()) as SavedInvention[] + } + + const bought = await save('6100', 'bought-invention') + const own = await save('6101', 'own-invention') + await grantInvention(env.DB, 6101, bought.InventionId) + + // Newest first, whichever set it came from: 6101 saved theirs after buying. + const list = await mine('6101') + expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId]) + // A bought invention is still the creator's — it is listed, not re-attributed. + expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100) + // It is unpublished (a fresh save is), and stays on the buyer's shelf regardless. + expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false) + + // The seller's own list is unaffected by the sale. + expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId]) + + // An ownership row pointing at an invention that no longer exists just drops out. + await grantInvention(env.DB, 6101, 999_888) + expect((await mine('6101')).map((i) => i.InventionId)).toEqual([ + own.InventionId, + bought.InventionId, + ]) + }) + test('POST /api/inventions/v6/save 401s without a bearer token', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 2043410..cf348a7 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -58,6 +58,17 @@ import type { PlatformLink } from './platform-db' const TOKEN_SCOPE = 'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage' +/** + * The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded + * build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded + * headset reports this same value. Two things follow, and both are enforced below: + * - it is never verifiable (`verifyPlatformProof` refuses it outright), and + * - it is therefore never LINKED to an account. A link is a password-free way in, so + * one link on a shared id would open that account to every sideloaded build. + * It exists only to get such a client onto the username/password login screen. + */ +const SIDELOAD_PLATFORM_ID = '1' + /** * The canned entry served for the one Oculus cached-login lookup below — the sideloaded * APK's way onto the password login screen. Not backed by a link, an account or a @@ -65,7 +76,7 @@ const TOKEN_SCOPE = */ const FAKE_OCULUS_CACHED_LOGIN = { platform: PlatformType.Oculus, - platformId: '1', + platformId: SIDELOAD_PLATFORM_ID, accountId: 1, lastLoginTime: '2026-07-19T17:13:29.225Z', requirePassword: true, @@ -306,6 +317,20 @@ async function verifyPlatformProof( platformAuth: string, postedPlatformId: string ): Promise { + // A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it + // has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no + // caller downstream can treat it as an identity — above all `linkLoginIdentity` on the + // password grant, which is the path such a client actually takes. Linking it would + // hand every sideloaded headset a password-free login into that account, since they + // all report this same id. + // + // Refusing costs a sideloaded player nothing: their password login still succeeds (a + // password grant carries its own credential and only *links* on a verified proof), it + // just never gets a cached login, so they type their password each launch. That is + // the intended shape of the sideload flow. + if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) { + return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' } + } if (platform === PlatformType.Steam) { const verified = platformAuth ? await verifySteamTicket(platformAuth) : null if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' } @@ -418,7 +443,7 @@ const app = new Hono() // Scoped to that ONE identity rather than to all of platform 1 — store builds do // real Meta logins, and shadowing the whole platform would hide genuine links from // their pickers. - if (platformInt === PlatformType.Oculus && id === '1') { + if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) { return c.json([FAKE_OCULUS_CACHED_LOGIN]) } // Listed straight from the link table, which is also what the `cached_login` @@ -506,6 +531,11 @@ const app = new Hono() 'when it is unset. The first identity linked also becomes the account’s primary', '(what the account DTO and a refreshed token report); later ones only link.', '', + 'The one platform id that is never verified and never linked is `1` on platform `1`', + '— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such', + 'build reports it, so it identifies nobody. A password login that carries it still', + 'succeeds; it simply links nothing, and the player types their password each launch.', + '', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator', 'powers refresh on every login and every refresh grant.', ].join('\n'), diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 27db45a..4001487 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -893,6 +893,26 @@ describe('auth worker routes', () => { expect(await getLinksForAccount(env.DB, 7103)).toEqual([]) }) + test('a sideloaded APK (platform id 1) logs in but is never linked', async () => { + // The sideload placeholder identifies nobody — every sideloaded headset reports + // `1`, so a link on it would be a password-free way into this account from any of + // them. The password login still stands; Meta is never even asked, since there is + // nothing there to validate. + await seedPasswordAccount(7105, 'sideloader') + const login = await metaLogin( + `grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` + + `&platform=1&platform_id=1` + + `&platform_auth=${encodeURIComponent(metaPlatformAuth())}`, + true // even with Meta answering yes to everything + ) + expect(login.status).toBe(200) + expect(login.graphCalls).toHaveLength(0) + expect(await getLinksForAccount(env.DB, 7105)).toEqual([]) + // And so the picker never offers this account off the placeholder — only the + // canned stub entry is there. + expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1]) + }) + test('linking obeys the per-identity account cap, without failing the login', async () => { // Otherwise the signup cap would be trivially bypassable: create accounts with a // password, then link the capped identity into all of them. diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 92e5086..f31c417 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -2,7 +2,14 @@ import { Hono } from 'hono' import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' -import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain' +import { + consumeGift, + createGift, + getGift, + getPendingGifts, + grantInvention, + ownsInvention, +} from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' @@ -35,7 +42,6 @@ import { } from './consumables-db' import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' import { getInventory, grantItem } from './inventory-db' -import { grantInvention, ownsInvention } from './inventory-invention-db' import { AUTHED, AvatarV2Dto, diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 2ca82bf..a8d0ad6 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -4,7 +4,11 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../econ.app' -import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain' +import { + getOwnedInventionIds, + INVENTORY_INVENTION_SCHEMA_DDL, + RECEIVED_GIFT_SCHEMA_DDL, +} from '@repo/domain' // The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL // is built here too (see the same cross-worker import in econ.app.ts). @@ -20,7 +24,6 @@ import { import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' -import { getOwnedInventionIds, INVENTORY_INVENTION_SCHEMA_DDL } from '../../inventory-invention-db' import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import type { Env } from '../../context' diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 0c4df20..f2903ba 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -7,4 +7,5 @@ export * from './rooms-db' export * from './room-instance-db' export * from './presence-db' export * from './gifts-db' +export * from './inventory-invention-db' export * from './relationships-db' diff --git a/apps/econ/src/inventory-invention-db.ts b/packages/domain/src/inventory-invention-db.ts similarity index 86% rename from apps/econ/src/inventory-invention-db.ts rename to packages/domain/src/inventory-invention-db.ts index 70b10f5..997371e 100644 --- a/apps/econ/src/inventory-invention-db.ts +++ b/packages/domain/src/inventory-invention-db.ts @@ -1,7 +1,7 @@ /** * Owned inventions on the shared `recflare` D1 database — the inventions a player has - * bought. One row per (account, invention), written at purchase time by - * `GET /api/storefronts/v2/buyInvention`. + * bought. One row per (account, invention), written at purchase time by the `econ` + * worker's `GET /api/storefronts/v2/buyInvention`. * * Only the invention id is stored: the invention record itself lives in the `invention` * table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on @@ -9,8 +9,10 @@ * creator is not listed here either — they own their invention through its * `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator. * - * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ - * 0008_inventory_invention.sql. + * The `econ` worker owns the schema/migration (apps/econ/migrations/ + * 0008_inventory_invention.sql) and is the only writer; `api` only reads, to fold bought + * inventions into `GET /api/inventions/v2/mine`. Both import these helpers so the table + * name and row shape live in one place — the same split as gifts-db.ts. */ /** Schema DDL (mirror of migrations 0008_inventory_invention.sql) — also builds the table in tests. */