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
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
- `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 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<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 to spawn. Only published, non-hidden inventions are visible here (a
+10 -6
View File
@@ -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<App>({ 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 callers 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 callers inventions'),
@@ -667,7 +671,7 @@ export const avatarRoutes = new Hono<App>({ 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))
}
)
+48
View File
@@ -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 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 () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
+32 -2
View File
@@ -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<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) {
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<App>()
// 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<App>()
'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.',
'',
'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'),
@@ -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.
+8 -2
View File
@@ -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,
+5 -2
View File
@@ -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'
+1
View File
@@ -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'
@@ -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. */