diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index b3f162f..1bd050f 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -320,8 +320,14 @@ export const InventionPersonalDetails = z.object({ /** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */ export const SetTagsRequest = z.object({ InventionId: z.int(), - AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'), - CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'), + AutoTags: z + .array(z.string()) + .optional() + .describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'), + CustomTags: z + .array(z.string()) + .optional() + .describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'), }) /** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */ @@ -341,8 +347,14 @@ export const SaveInventionRequest = z.object({ inventionDataFilename: z .string() .describe('The blob uploaded through the storage worker; the one required field'), - name: z.string().optional().describe('Defaults to “Untitled”'), - description: z.string().optional(), + name: z + .string() + .optional() + .describe('3–24 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'), + description: z + .string() + .optional() + .describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'), imageName: z.string().optional(), instantiationCost: z.int().optional(), lightsCost: z.int().optional(), diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 2b101f4..e43f24a 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1,6 +1,12 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' +import { + inventionDescriptionRejection, + inventionNameRejection, + inventionTagRejection, +} from '@repo/domain' + import { authedId, unauthorized } from '../http' import { createInvention, @@ -387,18 +393,20 @@ export const avatarRoutes = new Hono({ strict: false }) 'A GET that writes — that is what the client sends, with the fields to change as ' + 'query params. Absent params keep their stored value. An empty `description` ' + 'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' + - 'invention. Publishing and pricing are separate endpoints.', + 'invention. A supplied name/description must satisfy the same rules `v6/save` ' + + 'enforces. Publishing and pricing are separate endpoints.', security: AUTHED, parameters: [ intQuery('inventionId', 'Invention id; required'), - stringQuery('name', 'New name; empty is ignored'), - stringQuery('description', 'New description; present-but-empty clears it'), + stringQuery('name', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'), + stringQuery('description', 'Max 512 chars; present-but-empty clears it'), stringQuery('imageName', 'New thumbnail; empty is ignored'), stringQuery('allowTrial', '`true`/`1` to allow trials'), stringQuery('permission', 'A name like `useonly`, or the raw permission number'), ], responses: { 200: json(InventionSaveResult, 'The updated invention, in the save envelope'), + 400: json(ErrorResponse, 'A supplied name or description breaks its rule'), 401: UNAUTHORIZED_RESPONSE, 403: json(ErrorResponse, 'Not the caller’s invention'), 404: { description: 'No such invention' }, @@ -416,10 +424,23 @@ export const avatarRoutes = new Hono({ strict: false }) const allowTrial = c.req.query('allowTrial') const permission = c.req.query('permission') + // Only a name that's actually being changed is checked — an absent or empty one + // keeps the stored name, which was already validated when it was set. + const name = nonEmpty('name') + const nameRejection = name === undefined ? null : inventionNameRejection(name) + if (nameRejection !== null) return c.json({ error: nameRejection }, 400) + + // The description is checked on presence, not emptiness: empty is how a creator + // clears it, and the length rule accepts that. + const description = c.req.query('description') + const descriptionRejection = + description === undefined ? null : inventionDescriptionRejection(description) + if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400) + const updated = await updateInvention(c.env.DB, gate.invention.InventionId, { - name: nonEmpty('name'), + name, // Present-but-empty clears the description, so this checks presence. - description: c.req.query('description'), + description, imageName: nonEmpty('imageName'), allowTrial: allowTrial === undefined @@ -523,13 +544,15 @@ export const avatarRoutes = new Hono({ strict: false }) '`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' + 'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' + 'only.\n\n' + + 'Every tag in either list must be at most 15 letters (a–z once lowercased); one ' + + 'that isn’t fails the whole call, so no tag is ever silently dropped.\n\n' + 'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' + 'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.', security: AUTHED, requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'), responses: { 200: json(SetTagsResponse, 'The resulting tag names'), - 400: json(ErrorResponse, 'Unparseable body'), + 400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'), 401: UNAUTHORIZED_RESPONSE, 403: json(ErrorResponse, 'Not the caller’s invention'), 404: { description: 'No such invention' }, @@ -546,11 +569,29 @@ export const avatarRoutes = new Hono({ strict: false }) const strings = (v: unknown): string[] => Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : [] + const autoTags = strings(body.AutoTags) + const customTags = strings(body.CustomTags) + + // Both lists are held to the tag rule, and one bad tag fails the whole call rather + // than being dropped — a silently missing tag looks to the creator like a tag that + // saved. Checked against the normalized form `setInventionTags` will store, so the + // rejection quotes the tag as it would have been stored, not as it was typed. + // Blanks are skipped, not rejected: the store already drops them, and the client + // pads its list with empties. + for (const raw of [...autoTags, ...customTags]) { + const tag = raw.trim().toLowerCase() + if (tag === '') continue + const rejection = inventionTagRejection(tag) + if (rejection !== null) { + return c.json({ error: `${rejection} (“${tag}”)` }, 400) + } + } + const tags = await setInventionTags( c.env.DB, gate.invention.InventionId, - strings(body.AutoTags), - strings(body.CustomTags) + autoTags, + customTags ) return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) }) } @@ -690,14 +731,19 @@ export const avatarRoutes = new Hono({ strict: false }) 'Records an invention’s metadata. The data file itself is uploaded separately ' + 'through the `storage` worker and referenced here by `inventionDataFilename` — the ' + 'one required field, since an invention with no data blob is unusable. An omitted ' + - 'name/description is defaulted rather than rejected.\n\n' + + 'name/description is defaulted rather than rejected; a supplied one must be 3–24 ' + + 'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' + + 'characters (description).\n\n' + 'A freshly saved invention is private: it shows up only in the creator’s own list ' + 'until they call `v3/publish`.', security: AUTHED, requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'), responses: { 200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'), - 400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'), + 400: json( + ErrorResponse, + 'Unparseable body, no inventionDataFilename, or an invalid name/description' + ), 401: UNAUTHORIZED_RESPONSE, }, }), @@ -716,11 +762,24 @@ export const avatarRoutes = new Hono({ strict: false }) return c.json({ error: 'inventionDataFilename is required' }, 400) } + // An omitted or blank name/description is defaulted by `createInvention` ("Untitled", + // "No description yet"), so only a supplied one is held to the rules — otherwise + // saving an unnamed invention would fail the 3-character minimum on a name the + // player never typed. + const name = str(body.name)?.trim() + const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name) + if (nameRejection !== null) return c.json({ error: nameRejection }, 400) + + const description = str(body.description) + const descriptionRejection = + description === undefined ? null : inventionDescriptionRejection(description) + if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400) + const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, { creatorPlayerId: id, inventionDataFilename, - name: str(body.name), - description: str(body.description), + name, + description, imageName: str(body.imageName), instantiationCost: num(body.instantiationCost), lightsCost: num(body.lightsCost), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 964d14b..004354f 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -426,7 +426,7 @@ describe('public endpoints', () => { const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }), + body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }), }) expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe( '2026-07-12/x.inv' @@ -532,6 +532,51 @@ describe('public endpoints', () => { }) }) + test('POST /api/inventions/v6/save enforces the name and description rules', async () => { + const save = async (fields: Record): Promise => + exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }), + }) + + // A name is 3–24 characters of letters, digits, spaces, dashes and colons. + expect((await save({ name: 'ab' })).status).toBe(400) + expect((await save({ name: 'a'.repeat(25) })).status).toBe(400) + expect((await save({ name: 'Rocket!' })).status).toBe(400) + expect((await save({ name: 'Café Lamp' })).status).toBe(400) + const ok = await save({ name: 'Rocket Sofa-Bed 2' }) + expect(ok.status).toBe(200) + expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2') + + // The rejection carries the player-facing sentence, not a code. + const short = await save({ name: 'ab' }) + expect((await short.json()) as { error: string }).toEqual({ + error: 'Invention names must be at least 3 characters.', + }) + + // A description is prose: any characters, at most 512 of them. + expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400) + expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200) + expect((await save({ name: 'Punctuated', description: 'Yes! It’s 100% good.' })).status).toBe( + 200 + ) + }) + + test('POST /api/inventions/v6/save accepts the client’s auto-generated timestamp name', async () => { + // The real client names an unnamed invention after the moment it was saved + // (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name + // charset on purpose. Dropping it from the pattern would 400 every unnamed save the + // game makes — this test is what would catch that. + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }), + }) + expect(res.status).toBe(200) + expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50') + }) + test('GET /api/inventions/v1 404s for an unknown invention', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`) expect(res.status).toBe(404) @@ -611,6 +656,39 @@ describe('public endpoints', () => { }) expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] }) + // A tag is at most 15 letters once lowercased. One bad tag in either list fails the + // whole call — nothing is dropped silently — and leaves the stored tags alone. + const punctuated = await settags({ + InventionId: Invention.InventionId, + CustomTags: ['racing', 'Cool Stuff!'], + }) + expect(punctuated.status).toBe(400) + expect((await punctuated.json()) as { error: string }).toEqual({ + error: 'Invention tags can only contain letters. (“cool stuff!”)', + }) + expect( + (await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status + ).toBe(400) + expect( + (await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status + ).toBe(400) + const stillThere = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}` + ) + expect(await stillThere.json()).toEqual({ + Tags: [ + { Tag: 'modern', Type: 0 }, + { Tag: 'bed', Type: 0 }, + ], + }) + + // Blank entries are skipped rather than rejected: the store already drops them. + const padded = await settags({ + InventionId: Invention.InventionId, + CustomTags: ['modern', '', ' '], + }) + expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] }) + // Only the creator may retag; unknown inventions 404; no token → 401. const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999') expect(notMine.status).toBe(403) @@ -939,6 +1017,16 @@ describe('public endpoints', () => { const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' }) + // A supplied name/description is held to the same rules as the save path, and a + // rejected edit changes nothing. + expect((await update('name=xy')).status).toBe(400) + expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400) + expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400) + const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult + expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' }) + const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult + expect(renamed.Invention.Name).toBe('Draft-Lamp Two') + // allowTrial takes true/1. const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult expect(trial.Invention.AllowTrial).toBe(true) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index f31c417..3645311 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -28,8 +28,10 @@ import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' import { ALL_PLATFORMS, + creditCurrency, CurrencyType, DEFAULT_STARTING_TOKENS, + ensureStartingBalances, getBalance, isSpendable, spendCurrency, @@ -1181,11 +1183,13 @@ const app = new Hono({ strict: false }) // Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends // `?inventionId=…&requestedPrice=…` with no body, so that's what we answer. // - // Only FREE inventions can be bought for now: we look the invention up by id and - // confirm its stored `Price` — both against the price the client rendered (a - // mismatch is a stale or tampered client, 409) and against 0 (a priced invention is - // 402, since nothing here debits the buyer or pays the creator yet). That keeps the - // path from ever moving currency while the payout half is unimplemented. + // A priced invention is settled player-to-player: the buyer is debited its `Price` in + // RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the + // tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the + // money entirely: nothing is debited and nobody is paid. The stored price is confirmed + // against the price the client rendered first, so a stale or tampered client can't buy + // at a price the creator no longer offers (409), and an unaffordable one is a 400 — + // the same "Insufficient balance" buyItem answers with. // // Ownership is recorded in `inventory_invention`; the creator is not sold their own // invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather @@ -1199,9 +1203,11 @@ const app = new Hono({ strict: false }) summary: 'Buy an invention', description: [ 'Looks the invention up by id, confirms the client’s `requestedPrice` still matches', - 'its stored `Price`, records ownership in `inventory_invention`, and returns the', - 'invention alongside the (unchanged) balance. Only FREE inventions are sellable for', - 'now — a priced one is 402. A GET because that is how the client sends it.', + 'its stored `Price`, debits the buyer and pays the creator that price in', + 'RecCenterTokens (a free invention moves nothing), records ownership in', + '`inventory_invention`, and returns the invention alongside the buyer’s resulting', + 'balance. Both players get a StorefrontBalanceUpdate push when tokens moved.', + 'A GET because that is how the client sends it.', ].join(' '), security: AUTHED, parameters: [ @@ -1222,9 +1228,11 @@ const app = new Hono({ strict: false }) ], responses: { 200: json(BuyInventionResponse, 'The purchase result (invention + balance)'), - 400: json(ErrorResponse, 'Missing/non-numeric inventionId, or buying your own'), + 400: json( + ErrorResponse, + 'Missing/non-numeric inventionId, buying your own, or insufficient balance' + ), 401: UNAUTHORIZED_RESPONSE, - 402: json(ErrorResponse, 'The invention is not free (unsupported for now)'), 403: json(ErrorResponse, 'The invention is not published, so it is not for sale'), 404: json(ErrorResponse, 'No such invention'), 409: json(ErrorResponse, 'Already owned, or the price has changed'), @@ -1236,8 +1244,8 @@ const app = new Hono({ strict: false }) const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) - // Absent/non-numeric requestedPrice reads as 0 — the only price we sell at anyway, - // so the confirmation below still has something to compare against. + // Absent/non-numeric requestedPrice reads as 0, which only matches a free invention — + // a priced one then fails the confirmation below rather than selling for nothing. const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0 const invention = await getInventionById(c.env.DB, inventionId) @@ -1251,27 +1259,66 @@ const app = new Hono({ strict: false }) return c.json({ error: 'Already owned' }, 409) } - // Confirm the price twice: against what the client rendered, then against the only - // price we can actually settle (free). + // The price the client rendered must still be the stored one: a mismatch is a stale + // catalog or a tampered request, never a sale. if (invention.Price !== requestedPrice) { return c.json({ error: 'Price has changed' }, 409) } - if (invention.Price !== 0) { - return c.json({ error: 'Only free inventions can be bought right now' }, 402) + + const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + // Inventions are priced in RecCenterTokens only — the store shows no other currency + // for them, and `Price` carries no currency of its own to pick a different one from. + const price = invention.Price + if (price > 0) { + // Debit the buyer atomically; false means they couldn't afford it and nothing + // changed, so no ownership is recorded and the creator is not paid. + const paid = await spendCurrency( + c.env.DB, + id, + CurrencyType.RecCenterTokens, + price, + startingTokens + ) + if (!paid) return c.json({ error: 'Insufficient balance' }, 400) } + // Grant before paying out: these are three separate D1 writes with no transaction + // around them, so order them by what a failure costs. A buyer who paid and got the + // invention but left the creator unpaid is recoverable; a buyer charged for nothing + // is not. await grantInvention(c.env.DB, id, inventionId) - // Nothing was debited, so this is the buyer's balance as it stands (a first read - // seeds their starting grant, as everywhere else). Unlike buyItem — whose `Balance` - // is the change applied — the reference server answers this one with the RESULTING - // total, so no StorefrontBalanceUpdate push is needed either: nothing changed. - const balance = await getBalance( - c.env.DB, - id, - CurrencyType.RecCenterTokens, - intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) - ) + if (price > 0) { + // Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts + // the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a + // creator who had never touched their balance would otherwise have the row created + // here and lose their starting tokens forever. + await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens) + const creatorBalance = await creditCurrency( + c.env.DB, + invention.CreatorPlayerId, + CurrencyType.RecCenterTokens, + price, + startingTokens + ) + // The creator is a different, probably-online player: push their new total so a + // sale lands on their shown balance without a re-fetch. Best-effort, as everywhere. + await pushBalanceUpdate( + c, + invention.CreatorPlayerId, + CurrencyType.RecCenterTokens, + creatorBalance + ) + } + + // Unlike buyItem — whose `Balance` is the change applied — the reference server + // answers this one with the RESULTING total (a first read seeds the buyer's starting + // grant, as everywhere else). + const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens) + // A free invention moved nothing, so there is no balance to push for it. + if (price > 0) { + await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, balance) + } return c.json({ BalanceUpdateResponse: { Balance: balance, diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 045b40f..8fa7c1a 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -157,7 +157,7 @@ export const BuyInventionResponse = z.object({ .describe('The same envelope `POST /api/inventions/v6/save` returns'), }) -/** buyItem / buyInvention error body (`{ error }`), returned on 400/402/403/404/409. */ +/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */ export const ErrorResponse = z.object({ error: z.string() }) // ---- Request schemas ------------------------------------------------------- diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a8d0ad6..2d6e78d 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -111,7 +111,7 @@ function invention( const SEEDED_INVENTIONS = [ invention(8), // free, published, someone else's — the sellable one - invention(9, { Price: 250 }), // priced: not sellable while only free is supported + invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens invention(10, { IsPublished: false }), // a draft, not on sale even at 0 invention(11, { CreatorPlayerId: 60 }), // account 60's own invention ] @@ -1039,12 +1039,52 @@ describe('econ endpoints', () => { expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8]) }) - test('GET /api/storefronts/v2/buyInvention refuses anything but a free invention', async () => { - // Invention 9 costs 250. Sending the price the client rendered is a 402 (nothing - // here can settle a paid purchase yet); sending 0 for it is a stale/tampered price. - expect((await buyInvention('51', 9, 250)).status).toBe(402) - expect((await buyInvention('51', 9, 0)).status).toBe(409) - expect(await getOwnedInventionIds(env.DB, 51)).toEqual([]) + test('GET /api/storefronts/v2/buyInvention pays the creator the buyer’s tokens', async () => { + // Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from + // the buyer to that creator — no house cut, so the two sides are equal and opposite. + const res = await buyInvention('51', 9, 250) + expect(res.status).toBe(200) + const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } } + // `Balance` is the buyer's RESULTING total, so it already has the debit in it. + expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250) + expect( + await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).toBe(DEFAULT_STARTING_TOKENS - 250) + // The creator had never touched their balance: they keep their starting grant AND get + // paid, rather than the payout standing in for the grant. + expect( + await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).toBe(DEFAULT_STARTING_TOKENS + 250) + expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9]) + }) + + test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => { + // Sending 0 for the 250-token invention 9 is a stale (or tampered) price. + expect((await buyInvention('53', 9, 0)).status).toBe(409) + + // Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned. + await spendCurrency( + env.DB, + 54, + CurrencyType.RecCenterTokens, + DEFAULT_STARTING_TOKENS, + DEFAULT_STARTING_TOKENS + ) + const creatorBefore = await getBalance( + env.DB, + 999, + CurrencyType.RecCenterTokens, + DEFAULT_STARTING_TOKENS + ) + expect((await buyInvention('54', 9, 250)).status).toBe(400) + expect( + await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).toBe(0) + expect( + await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).toBe(creatorBefore) + expect(await getOwnedInventionIds(env.DB, 53)).toEqual([]) + expect(await getOwnedInventionIds(env.DB, 54)).toEqual([]) }) test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => { diff --git a/packages/domain/src/validation.ts b/packages/domain/src/validation.ts index ce8ed62..a9e68b1 100644 --- a/packages/domain/src/validation.ts +++ b/packages/domain/src/validation.ts @@ -37,6 +37,25 @@ export const MAX_CLUB_DESCRIPTION_LENGTH = 512 export const MAX_EVENT_NAME_LENGTH = 64 export const MAX_EVENT_DESCRIPTION_LENGTH = 512 +/** + * Invention limits. A name is a title a player types into the invention-save box and + * reads back in a browse tile, so it allows the punctuation a title needs — but + * nothing else, since it is also what invention search matches on. The minimum is real: + * one- and two-character names are unsearchable and unreadable in a tile, and the client + * offers `Untitled` rather than an empty box. + */ +export const MIN_INVENTION_NAME_LENGTH = 3 +export const MAX_INVENTION_NAME_LENGTH = 24 +export const MAX_INVENTION_DESCRIPTION_LENGTH = 512 + +/** + * One invention tag. Short and letters-only because tags are a controlled vocabulary the + * browse chips are derived from (see `getInventionTagFilters`) — a tag with digits, + * punctuation or spaces makes a chip nobody else will ever type again. Tags are stored + * lowercased, so the rule is checked against the normalized form, not what was typed. + */ +export const MAX_INVENTION_TAG_LENGTH = 15 + /** * Length in code points rather than UTF-16 units, so an emoji or other astral character * counts once instead of twice — the way a player counts what they typed. @@ -74,6 +93,69 @@ export function nameRejection(value: string, label: string, max: number): string return null } +/** + * Letters, digits, spaces, dashes and colons — the title charset. Wider than + * `NAME_PATTERN` because an invention is a thing with a name ("Grappling Hook v2", + * "Speed-Boost Pad"), not an identifier someone types into a sign-in box. Still no + * arbitrary Unicode, for the same homoglyph reasons. + * + * The colon is not decorative: an invention the player never named is called after the + * moment it was saved (`071126 13:10:50`), generated by the CLIENT, so a rule without it + * would refuse every unnamed save the game makes. The dash stays last in the class so it + * reads as a literal rather than a range. + */ +const INVENTION_NAME_PATTERN = /^[A-Za-z0-9 :-]+$/ + +/** Lowercase letters only — the normalized form a tag is stored in. */ +const INVENTION_TAG_PATTERN = /^[a-z]+$/ + +/** + * Why a player-supplied invention name is unacceptable, or `null` when it's fine. + * + * Callers pass the TRIMMED name: leading and trailing spaces are the player's typing, + * not part of what they named the thing, and counting them toward the minimum would let + * `" a "` through. + */ +export function inventionNameRejection(value: string): string | null { + if (glyphLength(value) < MIN_INVENTION_NAME_LENGTH) { + return `Invention names must be at least ${MIN_INVENTION_NAME_LENGTH} characters.` + } + if (glyphLength(value) > MAX_INVENTION_NAME_LENGTH) { + return `Invention names can be at most ${MAX_INVENTION_NAME_LENGTH} characters.` + } + if (!INVENTION_NAME_PATTERN.test(value)) { + return 'Invention names can only contain letters, numbers, spaces, dashes and colons.' + } + return null +} + +/** + * Why an invention description is unacceptable, or `null` when it's fine. Length only — + * a description is prose, so nothing is refused for the characters it's made of, and an + * empty one is fine (it's how a creator clears the field). + */ +export function inventionDescriptionRejection(value: string): string | null { + if (glyphLength(value) > MAX_INVENTION_DESCRIPTION_LENGTH) { + return `Invention descriptions can be at most ${MAX_INVENTION_DESCRIPTION_LENGTH} characters.` + } + return null +} + +/** + * Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED + * tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed + * instead would refuse `Racing` for a capital that never reaches the database. + */ +export function inventionTagRejection(value: string): string | null { + if (value.length > MAX_INVENTION_TAG_LENGTH) { + return `Invention tags can be at most ${MAX_INVENTION_TAG_LENGTH} characters.` + } + if (!INVENTION_TAG_PATTERN.test(value)) { + return 'Invention tags can only contain letters.' + } + return null +} + /** * Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`. *