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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 caller’s own inventions',
|
summary: 'The caller’s 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 caller’s inventions'),
|
200: json(InventionDto.array(), 'The caller’s 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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 () => {
|
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',
|
||||||
|
|||||||
@@ -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 account’s primary',
|
'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.',
|
'(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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
+6
-4
@@ -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. */
|
||||||
Reference in New Issue
Block a user