mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
track owned inventions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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<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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 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'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
+6
-4
@@ -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. */
|
||||
Reference in New Issue
Block a user