track owned inventions

This commit is contained in:
Devin Zuczek
2026-08-04 22:33:47 -04:00
parent dfb1e9ab21
commit 9f4ce07aca
10 changed files with 170 additions and 16 deletions
+9
View File
@@ -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 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. the only cap that can see a web signup.
`www` reaches `auth` through a **service binding**, not over `auth.<DOMAIN>`, 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 ## Repository Structure
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has - `apps/` - The service workers, one deployable Worker per subdirectory. Each has
+31
View File
@@ -12,7 +12,12 @@
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including * The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
* the nested `CurrentVersion` that carries the blob name and per-version costs — * the nested `CurrentVersion` that carries the blob name and per-version costs —
* shaped after a real `GET /api/inventions/v1?inventionId=…` response. * 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, * 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) .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<SavedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
])
const bought = await getInventionsByIds(db, ownedIds)
const byId = new Map<number, SavedInvention>()
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 search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a * invention to spawn. Only published, non-hidden inventions are visible here (a
+10 -6
View File
@@ -6,12 +6,12 @@ import {
createInvention, createInvention,
getFeaturedInventions, getFeaturedInventions,
getInventionById, getInventionById,
getInventionsByCreator,
getInventionsByIds, getInventionsByIds,
getInventionsByRoom, getInventionsByRoom,
getInventionTagFilters, getInventionTagFilters,
getInventionTags, getInventionTags,
getInventionVersion, getInventionVersion,
getMyInventions,
getTopInventions, getTopInventions,
parsePermissionLevel, parsePermissionLevel,
publishInvention, publishInvention,
@@ -648,16 +648,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
} }
) )
// The signed-in player's saved inventions ("my inventions"), newest first. // The signed-in player's invention shelf ("my inventions"), newest first — the ones
// Auth-gated; returns a bare array (empty when the player has saved none). // 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( .get(
'/api/inventions/v2/mine', '/api/inventions/v2/mine',
describeRoute({ describeRoute({
tags: ['Inventions'], tags: ['Inventions'],
summary: 'The callers own inventions', summary: 'The callers own inventions',
description: description:
'“My inventions”, newest first — including unpublished ones, which nobody else can ' + '“My inventions”, newest first — the ones the caller created plus the ones they ' +
'see. Not paginated.', '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, security: AUTHED,
responses: { responses: {
200: json(InventionDto.array(), 'The callers inventions'), 200: json(InventionDto.array(), 'The callers inventions'),
@@ -667,7 +671,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(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))
} }
) )
+48
View File
@@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
import { import {
GAME_VERSION, GAME_VERSION,
grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
@@ -91,6 +93,9 @@ beforeAll(async () => {
// Inventions table (owned by the api worker) — invention save/mine use it. // 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() 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. // 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() 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 }) expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
}) })
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers 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 () => { test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST', method: 'POST',
+32 -2
View File
@@ -58,6 +58,17 @@ import type { PlatformLink } from './platform-db'
const TOKEN_SCOPE = 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' '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 * 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 * 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 = { const FAKE_OCULUS_CACHED_LOGIN = {
platform: PlatformType.Oculus, platform: PlatformType.Oculus,
platformId: '1', platformId: SIDELOAD_PLATFORM_ID,
accountId: 1, accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z', lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true, requirePassword: true,
@@ -306,6 +317,20 @@ async function verifyPlatformProof(
platformAuth: string, platformAuth: string,
postedPlatformId: string postedPlatformId: string
): Promise<PlatformProof> { ): Promise<PlatformProof> {
// 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) { if (platform === PlatformType.Steam) {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' } if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
@@ -418,7 +443,7 @@ const app = new Hono<App>()
// Scoped to that ONE identity rather than to all of platform 1 — store builds do // 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 // real Meta logins, and shadowing the whole platform would hide genuine links from
// their pickers. // their pickers.
if (platformInt === PlatformType.Oculus && id === '1') { if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
return c.json([FAKE_OCULUS_CACHED_LOGIN]) return c.json([FAKE_OCULUS_CACHED_LOGIN])
} }
// Listed straight from the link table, which is also what the `cached_login` // Listed straight from the link table, which is also what the `cached_login`
@@ -506,6 +531,11 @@ const app = new Hono<App>()
'when it is unset. The first identity linked also becomes the accounts primary', 'when it is unset. The first identity linked also becomes the accounts primary',
'(what the account DTO and a refreshed token report); later ones only link.', '(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', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.', 'powers refresh on every login and every refresh grant.',
].join('\n'), ].join('\n'),
@@ -893,6 +893,26 @@ describe('auth worker routes', () => {
expect(await getLinksForAccount(env.DB, 7103)).toEqual([]) 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 () => { 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 // Otherwise the signup cap would be trivially bypassable: create accounts with a
// password, then link the capped identity into all of them. // password, then link the capped identity into all of them.
+8 -2
View File
@@ -2,7 +2,14 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' 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 { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
@@ -35,7 +42,6 @@ import {
} from './consumables-db' } from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem } from './inventory-db' import { getInventory, grantItem } from './inventory-db'
import { grantInvention, ownsInvention } from './inventory-invention-db'
import { import {
AUTHED, AUTHED,
AvatarV2Dto, AvatarV2Dto,
+5 -2
View File
@@ -4,7 +4,11 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../econ.app' 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 // 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). // 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 { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db' import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-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 { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
import type { Env } from '../../context' import type { Env } from '../../context'
+1
View File
@@ -7,4 +7,5 @@ export * from './rooms-db'
export * from './room-instance-db' export * from './room-instance-db'
export * from './presence-db' export * from './presence-db'
export * from './gifts-db' export * from './gifts-db'
export * from './inventory-invention-db'
export * from './relationships-db' export * from './relationships-db'
@@ -1,7 +1,7 @@
/** /**
* Owned inventions on the shared `recflare` D1 database the inventions a player has * Owned inventions on the shared `recflare` D1 database the inventions a player has
* bought. One row per (account, invention), written at purchase time by * bought. One row per (account, invention), written at purchase time by the `econ`
* `GET /api/storefronts/v2/buyInvention`. * worker's `GET /api/storefronts/v2/buyInvention`.
* *
* Only the invention id is stored: the invention record itself lives in the `invention` * 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 * 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 * 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. * `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/ * The `econ` worker owns the schema/migration (apps/econ/migrations/
* 0008_inventory_invention.sql. * 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. */ /** Schema DDL (mirror of migrations 0008_inventory_invention.sql) — also builds the table in tests. */