diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index 48fd0ff..c18a69d 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -23,8 +23,8 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai /** * Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql + * 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured - * feed's query and `is_published`/`hide_from_player` the "may anyone see this" filter - * every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and + * feed's query and `is_published`/`hide_from_player` most of the "may anyone see this" + * filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and * NULL when the key is missing, which fails a `= 1` or `= 0` test either way. */ export const SCHEMA_DDL: string[] = [ @@ -53,6 +53,16 @@ export interface InventionVersion { ChipsCost: number CloudVariablesCost: number AICost: number + /** + * Whether the blob uses content still in beta, sent from `v9/save` on. It sits on the + * VERSION, where the client's own `RRInventionVersion` carries it and for the same + * reason the costs do: it describes the one revision saved, not the invention across + * all of them. `UgcVersion`, which reads like its twin, is an INVENTION field — see + * {@link SavedInvention}. Absent on a record saved through `v6/save`, which sends + * neither; the client's decoder reads a missing member as its default, so an old + * record is not retroactively wrong. + */ + HasBetaContent?: boolean } /** @@ -103,9 +113,32 @@ export interface SavedInvention { HideFromPlayer: boolean ReferencedInventions: number[] /** - * Tags served by `v1/details` and written by `v1/settags`. Optional and unset on - * save: the real `RRInvention` carries no Tags field and the client sends no tags - * when saving, so an untagged invention's DTO stays identical to the real one. + * The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field + * they most resemble, and the one this record has always carried on the invention. + * Note the v9 client's own `RRInvention` has no `Referenced*` at all (its VERSION + * carries them) and no `LongDescription`/`ConvertedFromInventionId` (it sends both and + * never reads them back); they are stored anyway, because what the client sent is + * worth keeping, and `toSaveResultV9` puts each where that client expects it. + * + * `UgcVersion` is the exception that has to be got right rather than tolerated: it is + * an invention field there, not a version one, next to `CurrentVersionNumber`. + * + * `DisplayMetadataJson` is stored as the opaque string the client sent: it is the + * client's own display state (`{"0":0,"99":0}`), and re-encoding it would be + * inventing a schema for something only the client reads. + * + * Each is absent on a `v6/save` record, which sends none of them. + */ + ReferencedUnityAssetIds?: string[] + UgcVersion?: number + LongDescription?: string + DisplayMetadataJson?: string + ConvertedFromInventionId?: number + /** + * Tags served by `v1/details` and written by `v1/settags`. Optional: the real + * `RRInvention` carries no Tags field, so an untagged invention's DTO stays + * identical to the real one. `v6/save` never sets it (that client tags in a second + * call); `v9/save` sets it when its `tagsRequest` names at least one tag. */ Tags?: InventionTag[] } @@ -130,6 +163,208 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult { return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion } } +/** + * `TagsResponse.Result` on a v9 save — the client's own tag-result enum, whose members + * run Success 0 … ReservedWordViolation 13. Only Success is named: the members between + * were not recovered from the client, and it never reads this field anyway, so a refused + * tag needs only to be something other than Success. + */ +export const INVENTION_TAG_RESULT = { + success: 0, + rejected: 1, +} as const + +/** + * The `Invention` inside a v9 save response — the client's 28-key `RRInvention`, which is + * NOT the record this server stores (that one mirrors the older shape the read endpoints + * still serve). The differences that matter: no nested `CurrentVersion` (the version rides + * beside it), no `Referenced*` (they moved onto the version), no `IsPublished` (the client + * infers it from `FirstPublishedAt`), and `UgcVersion`/`LatestVersionNumber`/ + * `ForceCannotPublish`/`IsRecRoomApproved` that the stored record has no equivalent for. + * + * The client reads exactly one of these keys — `InventionId` — and its decoder null-checks + * every member and drops the ones it doesn't know, so this projection is about being right + * rather than about being parseable. + */ +export interface InventionV9Dto { + InventionId: number + ReplicationId: string + CreatorPlayerId: number + Name: string + Description: string + ImageName: string + UgcVersion: number + CurrentVersionNumber: number + LatestVersionNumber: number + Accessibility: number + ForceCannotPublish: boolean + ModifiedAt: string + CreatedAt: string + FirstPublishedAt: string | null + CreationRoomId: number | null + NumPlayersHaveUsedInRoom: number + NumDownloads: number + CheerCount: number + CreatorPermission: number + GeneralPermission: number + IsAGInvention: boolean + IsCertifiedInvention: boolean + IsRecRoomApproved: boolean + AllowTrial: boolean + Price: number | null + HideFromPlayer: boolean + DisplayMetadataJson: string | null +} + +/** + * The `InventionVersion` inside a v9 save response — the client's 13-key + * `RRInventionVersion`. It carries `HasBetaContent`, a `CreatedAt` of its own and a + * nullable `UgcAccessibility` the stored version has no field for, and notably NO + * `AICost`, which the request body still sends and this server still stores. + * + * Both `Referenced*` lists are emitted here even though the client's DTO has room for one: + * which of the two it is wasn't recovered, and an unknown member is dropped silently while + * a missing one would be the list the client asked for going astray. + */ +export interface InventionVersionV9Dto { + InventionId: number + ReplicationId: string + VersionNumber: number + HasBetaContent: boolean + InstantiationCost: number + LightsCost: number + ChipsCost: number + CloudVariablesCost: number + BlobName: string + BlobHash: string | null + CreatedAt: string + UgcAccessibility: number | null + ReferencedInventions: number[] + ReferencedUnityAssetIds: string[] +} + +/** The tag half of a v9 save — `v1/settags`' answer, folded into the save response. */ +export interface InventionTagsV9Dto { + Result: number + Tags: string[] +} + +/** The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success. */ +export interface InventionSaveV9Value { + Status: number + Invention: InventionV9Dto + InventionVersion: InventionVersionV9Dto + TagsResponse: InventionTagsV9Dto +} + +/** + * What `v9/save` answers, and the whole reason it isn't just v6 with a bigger body: the + * result is ENVELOPED, where v6 serves the bare `{ Status, Invention, InventionVersion }`. + * + * The client's contract is two fields deep. It checks `Success`, then reads + * `Value.Invention.InventionId` and tags the invention with it; `Error` is the only text + * that ever reaches a human (it is logged as "Invention datablob upload failed"). `Status` + * is deserialized and never read on this route — the failure channel is the envelope, not + * the 55-member status enum — and so are `InventionVersion` and `TagsResponse`. + * + * The one shape that CRASHES the client is `Success: true` with `Value` null or absent: it + * dereferences `Value.Invention` unguarded. `Success: false` with a null `Value` is safe — + * that branch reads only `Error` — which is why every refusal goes through + * {@link inventionSaveV9Failure} rather than answering a bare `{ error }` like v6 does. A + * body that doesn't deserialize into this envelope at all is the same crash, so even the + * 401 answers it. + */ +export interface InventionSaveV9Result { + Value: InventionSaveV9Value | null + Success: boolean + Error: string | null + error_id: string | null +} + +/** + * Project a stored invention into the v9 save envelope. `tags` are the ones stored with + * it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they + * were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags, + * never the save, because the save is the thing the player would have to redo. + * + * Fields the stored record has no equivalent for are served as what they are here rather + * than guessed: nothing forces an invention not to publish, and nothing in this server + * approves one. + */ +export function toSaveResultV9( + invention: SavedInvention, + tags: InventionTag[], + tagResult: number = INVENTION_TAG_RESULT.success +): InventionSaveV9Result { + const version = invention.CurrentVersion + return { + Value: { + Status: 0, + Invention: { + InventionId: invention.InventionId, + ReplicationId: invention.ReplicationId, + CreatorPlayerId: invention.CreatorPlayerId, + Name: invention.Name, + Description: invention.Description, + ImageName: invention.ImageName, + UgcVersion: invention.UgcVersion ?? 0, + CurrentVersionNumber: invention.CurrentVersionNumber, + // One save, one version: the newest is the current one. + LatestVersionNumber: invention.CurrentVersionNumber, + Accessibility: invention.Accessibility, + ForceCannotPublish: false, + ModifiedAt: invention.ModifiedAt, + CreatedAt: invention.CreatedAt, + FirstPublishedAt: invention.FirstPublishedAt, + CreationRoomId: invention.CreationRoomId, + NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom, + NumDownloads: invention.NumDownloads, + CheerCount: invention.CheerCount, + CreatorPermission: invention.CreatorPermission, + GeneralPermission: invention.GeneralPermission, + IsAGInvention: invention.IsAGInvention, + IsCertifiedInvention: invention.IsCertifiedInvention, + IsRecRoomApproved: false, + AllowTrial: invention.AllowTrial, + Price: invention.Price, + HideFromPlayer: invention.HideFromPlayer, + DisplayMetadataJson: invention.DisplayMetadataJson ?? null, + }, + InventionVersion: { + InventionId: version.InventionId, + ReplicationId: version.ReplicationId, + VersionNumber: version.VersionNumber, + HasBetaContent: version.HasBetaContent ?? false, + InstantiationCost: version.InstantiationCost, + LightsCost: version.LightsCost, + ChipsCost: version.ChipsCost, + CloudVariablesCost: version.CloudVariablesCost, + BlobName: version.BlobName, + BlobHash: version.BlobHash, + // The version is minted with the invention, so they share a timestamp. + CreatedAt: invention.CreatedAt, + UgcAccessibility: null, + ReferencedInventions: invention.ReferencedInventions, + ReferencedUnityAssetIds: invention.ReferencedUnityAssetIds ?? [], + }, + TagsResponse: { Result: tagResult, Tags: tags.map((t) => t.Tag) }, + }, + Success: true, + Error: null, + error_id: null, + } +} + +/** + * A refused v9 save. `Value` is null, which is safe precisely because `Success` is false: + * the client reads `Error` on that branch and nothing else. See + * {@link InventionSaveV9Result} for why the alternative — a bare `{ error }` body — would + * take the client down instead. + */ +export function inventionSaveV9Failure(message: string): InventionSaveV9Result { + return { Value: null, Success: false, Error: message, error_id: null } +} + /** * Invention data blobs are named `.inv`, and the client expects the extension * on the `BlobName` it reads back. Uploads through the `storage` worker already land @@ -189,6 +424,19 @@ export interface NewInvention { aiCost?: number creationRoomId?: number | null referencedInventions?: number[] + /** + * The rest of what `v9/save` sends. Every one is optional and is written onto the + * record only when the caller actually supplied it, so a `v6/save` — which sends + * none of them — stores and answers exactly the record it always did. + */ + ugcVersion?: number + hasBetaContent?: boolean + referencedUnityAssetIds?: string[] + longDescription?: string | null + displayMetadataJson?: string | null + convertedFromInventionId?: number | null + /** Tags to store with the record, already normalized by {@link normalizeInventionTags}. */ + tags?: InventionTag[] } /** @@ -201,6 +449,11 @@ export interface NewInvention { * what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed. * The client's `creatorAccountRole` is ignored: it's the player's role in the room * they built it in, not a permission over the invention. + * + * The fields `v9/save` added over `v6/save` are written only when the caller supplies + * them, so the record a v6 client stores is byte-for-byte the one it always stored — + * the new keys appear on new records rather than being back-filled with defaults onto + * every old one. */ export async function createInvention( db: D1Database, @@ -233,6 +486,7 @@ export async function createInvention( ChipsCost: input.chipsCost ?? 0, CloudVariablesCost: input.cloudVariablesCost ?? 0, AICost: input.aiCost ?? 0, + ...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }), }, Accessibility: 0, IsPublished: false, @@ -252,6 +506,16 @@ export async function createInvention( AllowTrial: true, HideFromPlayer: false, ReferencedInventions: input.referencedInventions ?? [], + ...(input.referencedUnityAssetIds === undefined + ? {} + : { ReferencedUnityAssetIds: input.referencedUnityAssetIds }), + ...(input.ugcVersion === undefined ? {} : { UgcVersion: input.ugcVersion }), + ...(input.longDescription ? { LongDescription: input.longDescription } : {}), + ...(input.displayMetadataJson ? { DisplayMetadataJson: input.displayMetadataJson } : {}), + ...(typeof input.convertedFromInventionId === 'number' + ? { ConvertedFromInventionId: input.convertedFromInventionId } + : {}), + ...(input.tags?.length ? { Tags: input.tags } : {}), } await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run() return invention @@ -379,7 +643,7 @@ export async function searchInventions( const offset = Math.max(skip, 0) if (limit === 0) return [] - const where = ['is_published = 1', 'hide_from_player = 0'] + const where = [...VISIBLE_IN_FEEDS] const binds: Array = [] /** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */ const bind = (v: string | number): string => `?${binds.push(v)}` @@ -421,8 +685,7 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise() @@ -499,10 +762,9 @@ export async function getFeaturedInventions( /** * Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the * client derives from the invention itself (Type 2); custom tags are the creator's - * own (Type 0). Both lists are replaced wholesale — auto first, then custom, the - * order the tags come back in — and are lowercased/trimmed and de-duplicated so - * `details` doesn't echo back near-duplicates. Returns the stored tag list, or null - * when there's no such invention. + * own (Type 0). Both lists are replaced wholesale, normalized as + * {@link normalizeInventionTags} describes. Returns the stored tag list, or null when + * there's no such invention. */ export async function setInventionTags( db: D1Database, @@ -513,6 +775,21 @@ export async function setInventionTags( const invention = await getInventionById(db, inventionId) if (invention === null) return null + const tags = normalizeInventionTags(autoTags, customTags) + await writeInvention(db, { ...invention, Tags: tags }) + return tags +} + +/** + * The two tag lists as they are stored: auto first (Type 2), then custom (Type 0) — + * the order they come back in — each trimmed, lowercased and de-duplicated across both + * lists so `details` doesn't echo back near-duplicates. Blanks are dropped: the client + * pads its lists with empties. + * + * Shared by `v1/settags` and by `v9/save`, which carries the same two lists in its + * `tagsRequest` — a tag has to mean the same thing however it arrived. + */ +export function normalizeInventionTags(autoTags: string[], customTags: string[]): InventionTag[] { const tags: InventionTag[] = [] const seen = new Set() for (const [list, type] of [ @@ -526,8 +803,6 @@ export async function setInventionTags( tags.push({ Tag: tag, Type: type }) } } - - await writeInvention(db, { ...invention, Tags: tags }) return tags } @@ -540,6 +815,9 @@ export async function setInventionTags( export const INVENTION_PERMISSION = { unassigned: 0, limitedoneuseonly: 10, + // Recovered from the client's own ladder; nothing here sends it, and no name for it + // appears in `v1/update`'s picker. + disallowkeylock: 15, useonly: 20, editandsave: 40, publish: 60, @@ -547,6 +825,36 @@ export const INVENTION_PERMISSION = { unlimited: 100, } as const +/** + * Where a published invention may be FOUND, which `v4/publish` sets and nothing before it + * did — every record written before that endpoint carries 0, the value a save mints. + * + * Only `unlisted` is recovered from the client for certain; the other two mirror the room + * accessibility enum, which they match member-for-member, and the publish sheet sends 1 for + * an ordinary publish. + * + * Note what that leaves ambiguous: a stored 0 is either "private" or "written before this + * enum meant anything", and the two are indistinguishable without a backfill. So the browse + * filter excludes `unlisted` by name rather than requiring `public` — the latter reads + * every invention published through `v3/publish` as private and empties the feeds. + */ +export const INVENTION_ACCESSIBILITY = { + private: 0, + public: 1, + unlisted: 2, +} as const + +/** + * The "anyone may come across this" test the browse feeds and search share: published, not + * hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what + * unlisted means — so the by-id reads deliberately don't apply it. + */ +const VISIBLE_IN_FEEDS = [ + 'is_published = 1', + 'hide_from_player = 0', + `COALESCE(json_extract(data, '$.Accessibility'), 0) <> ${INVENTION_ACCESSIBILITY.unlisted}`, +] + /** * Parse a permission level the way the client sends it: a name (`useonly`, * `edit_and_save`) or the raw number. Undefined when it's neither. @@ -567,6 +875,13 @@ export interface InventionPatch { imageName?: string allowTrial?: boolean generalPermission?: number + /** + * The rest of what `v2/metadata` can edit. Undefined leaves the stored value alone, + * which is how both editors say "not this field" — `v1/update` by omitting the query + * param, `v2/metadata` by sending the key as null. + */ + longDescription?: string + tags?: InventionTag[] } /** @@ -591,11 +906,27 @@ export async function updateInvention( ImageName: patch.imageName ?? invention.ImageName, AllowTrial: patch.allowTrial ?? invention.AllowTrial, GeneralPermission: patch.generalPermission ?? invention.GeneralPermission, + // Both of these are optional ON the record, so an untouched one resolves to + // undefined and JSON.stringify drops the key — an invention that never had a long + // description doesn't acquire an empty one by being edited. + LongDescription: patch.longDescription ?? invention.LongDescription, + Tags: patch.tags ?? invention.Tags, } await writeInvention(db, updated) return updated } +/** + * What a publish decides. Each is optional and an omitted one keeps what the invention + * has — except the permission, which falls back to UseOnly, the level the older + * `v3/publish` has always defaulted to when its query string named none. + */ +export interface InventionPublish { + permissionLevel?: number + accessibility?: number + price?: number +} + /** * Publish an invention (`v3/publish`) — what puts it into search and the feeds. * Publishing sets the permission other players get (UseOnly unless the creator asks @@ -605,8 +936,7 @@ export async function updateInvention( export async function publishInvention( db: D1Database, inventionId: number, - permissionLevel: number | undefined, - price: number | undefined + publish: InventionPublish = {} ): Promise { const invention = await getInventionById(db, inventionId) if (invention === null) return null @@ -614,8 +944,13 @@ export async function publishInvention( const updated: SavedInvention = { ...invention, IsPublished: true, - GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly, - Price: price ?? 0, + GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly, + Accessibility: publish.accessibility ?? invention.Accessibility, + // An unmentioned price is the price it already has, not zero: a republish that says + // nothing about money must not quietly give away something that was for sale. A + // first publish is unaffected — a fresh invention's price is 0 either way. + Price: publish.price ?? invention.Price, + // The FIRST publish is the one that gets dated; re-publishing doesn't reset it. FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(), } await writeInvention(db, updated) @@ -714,8 +1049,7 @@ export async function getInventionsByRoom( .prepare( `SELECT data FROM invention WHERE json_extract(data, '$.CreationRoomId') = ?1 - AND is_published = 1 - AND hide_from_player = 0` + AND ${VISIBLE_IN_FEEDS.join(' AND ')}` ) .bind(roomId) .all() diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 9ac1fae..0bdb7ac 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -420,6 +420,10 @@ export const InventionVersionDto = z.object({ ChipsCost: z.int(), CloudVariablesCost: z.int(), AICost: z.int(), + HasBetaContent: z + .boolean() + .optional() + .describe('Set from `v9/save` on — absent on a version saved through `v6/save`'), }) /** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */ @@ -456,10 +460,30 @@ export const InventionDto = z.object({ AllowTrial: z.boolean(), HideFromPlayer: z.boolean(), ReferencedInventions: z.array(z.int()), + ReferencedUnityAssetIds: z + .array(z.string()) + .optional() + .describe('Set from `v9/save` on — absent on an invention saved through `v6/save`'), + UgcVersion: z + .int() + .optional() + .describe('An invention field, not a version one — set from `v9/save` on'), + LongDescription: z.string().optional().describe('Set from `v9/save` on, when non-empty'), + DisplayMetadataJson: z + .string() + .optional() + .describe('The client’s own display state, stored as the opaque string it sent'), + ConvertedFromInventionId: z + .int() + .optional() + .describe('The invention this one was converted from, when `v9/save` named one'), Tags: z .array(InventionTagDto) .optional() - .describe('Unset on save — the real RRInvention carries no Tags field'), + .describe( + 'The real RRInvention carries no Tags field. Unset by `v6/save`; set by `v9/save` ' + + 'when its `tagsRequest` names at least one tag' + ), }) /** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */ @@ -469,6 +493,88 @@ export const InventionSaveResult = z.object({ InventionVersion: InventionVersionDto, }) +/** + * The `Invention` a v9 save answers with — the newer client's own `RRInvention`, which is + * not the record this server stores or the read endpoints serve: no nested + * `CurrentVersion` (the version rides beside it), no `Referenced*` (those moved onto the + * version), no `IsPublished`. + */ +export const InventionV9Dto = z.object({ + InventionId: z.int(), + ReplicationId: z.string(), + CreatorPlayerId: z.int(), + Name: z.string(), + Description: z.string(), + ImageName: z.string(), + UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'), + CurrentVersionNumber: z.int(), + LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'), + Accessibility: z.int(), + ForceCannotPublish: z.boolean().describe('Always false — nothing here forbids publishing'), + ModifiedAt: z.string(), + CreatedAt: z.string(), + FirstPublishedAt: z.string().nullable(), + CreationRoomId: z.int().nullable(), + NumPlayersHaveUsedInRoom: z.int(), + NumDownloads: z.int(), + CheerCount: z.int(), + CreatorPermission: z.int(), + GeneralPermission: z.int(), + IsAGInvention: z.boolean(), + IsCertifiedInvention: z.boolean(), + IsRecRoomApproved: z.boolean().describe('Always false — nothing here approves an invention'), + AllowTrial: z.boolean(), + Price: z.int().nullable(), + HideFromPlayer: z.boolean(), + DisplayMetadataJson: z.string().nullable(), +}) + +/** + * The `InventionVersion` a v9 save answers with. It carries `HasBetaContent`, a `CreatedAt` + * of its own and a nullable `UgcAccessibility`, and notably no `AICost` — which the request + * still sends and this server still stores. + */ +export const InventionVersionV9Dto = z.object({ + InventionId: z.int(), + ReplicationId: z.string(), + VersionNumber: z.int(), + HasBetaContent: z.boolean(), + InstantiationCost: z.int(), + LightsCost: z.int(), + ChipsCost: z.int(), + CloudVariablesCost: z.int(), + BlobName: z.string(), + BlobHash: z.string().nullable(), + CreatedAt: z.string(), + UgcAccessibility: z.int().nullable().describe('Always null — versions carry no accessibility'), + ReferencedInventions: z.array(z.int()), + ReferencedUnityAssetIds: z.array(z.string()), +}) + +/** + * What `v9/save` answers — the enveloped result. The client checks `Success` and then reads + * `Value.Invention.InventionId`; `Error` is the only text it shows a human, and `Status`, + * `InventionVersion` and `TagsResponse` are deserialized and never read. `Success: true` + * with a null `Value` crashes it, so a refusal is `Success: false` with `Value: null`. + */ +export const InventionSaveV9Result = z.object({ + Value: z + .object({ + Status: z.int().describe('0 = success; the client never reads it on this route'), + Invention: InventionV9Dto, + InventionVersion: InventionVersionV9Dto, + TagsResponse: z.object({ + Result: z.int().describe('0 = success; non-zero when a tag broke the tag rule'), + Tags: z.array(z.string()).describe('The stored tag NAMES, auto first, then custom'), + }), + }) + .nullable() + .describe('Null when Success is false — and only then'), + Success: z.boolean(), + Error: z.string().nullable().describe('The refusal message; the only text the client shows'), + error_id: z.string().nullable().describe('Always null'), +}) + /** The tag filter chips on a browse screen, derived from the tags actually in use. */ export const TagFilters = z.object({ PinnedFilters: z.array(z.string()), @@ -506,6 +612,58 @@ export const SetTagsResponse = z.object({ Tags: z.array(z.string()).describe('Auto tags first, then custom'), }) +/** + * `PUT /api/inventions/v2/metadata` JSON body — PascalCase, and every field but the id is + * NULLABLE: the newer client sends the whole shape on every edit and marks the fields it + * isn't touching as null. An empty string is not a null — it clears the field. + */ +export const UpdateInventionMetadataRequest = z.object({ + InventionId: z.int(), + Name: z + .string() + .nullable() + .optional() + .describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'), + Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'), + LongDescription: z.string().nullable().optional().describe('Empty clears it'), + ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'), + TagsRequest: z + .object({ + AutoTags: z.array(z.string()).nullable().optional(), + CustomTags: z.array(z.string()).nullable().optional(), + }) + .nullable() + .optional() + .describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'), +}) + +/** + * `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way + * `v2/metadata`'s is: a null field keeps what the invention already has. + */ +export const PublishInventionRequest = z.object({ + InventionId: z.int(), + Permission: z + .int() + .nullable() + .optional() + .describe( + 'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' + + 'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' + + 'Charge 80, Unlimited 100. Null publishes as UseOnly' + ), + Accessibility: z + .int() + .nullable() + .optional() + .describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'), + Price: z + .int() + .nullable() + .optional() + .describe('Price in tokens; null leaves it as it is, and a negative one is ignored'), +}) + /** `POST /api/inventions/v1/updateprice` JSON body. */ export const UpdatePriceRequest = z.object({ InventionId: z.int(), @@ -533,6 +691,34 @@ export const SaveInventionRequest = z.object({ aiCost: z.int().optional(), creationRoomId: z.int().optional(), referencedInventions: z.array(z.int()).optional(), + creatorAccountRole: z + .int() + .optional() + .describe('Accepted and ignored — a room role, not a permission over the invention'), +}) + +/** + * `POST /api/inventions/v9/save` JSON body — `v6`’s fields plus what the invention + * points at, what it says about itself, and the tags that used to need a second + * `v1/settags` call. + */ +export const SaveInventionV9Request = SaveInventionRequest.extend({ + ugcVersion: z.int().optional().describe('The UGC format the blob was written in'), + hasBetaContent: z.boolean().optional(), + referencedUnityAssetIds: z.array(z.string()).optional(), + longDescription: z.string().optional().describe('Stored when non-empty'), + displayMetadataJson: z + .string() + .optional() + .describe('Opaque client display state, e.g. `{"0":0,"99":0}`; stored verbatim'), + convertedFromInventionId: z.int().nullable().optional(), + tagsRequest: z + .object({ + AutoTags: z.array(z.string()).nullable().optional(), + CustomTags: z.array(z.string()).nullable().optional(), + }) + .optional() + .describe('The same two lists `v1/settags` takes, folded into the save'), }) // ---- Avatar / custom avatar items ------------------------------------------ diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index d2d1372..cf5016c 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -6,6 +6,7 @@ import { getOutfit, getOutfitsByAccounts, inventionDescriptionRejection, + inventionLongDescriptionRejection, inventionNameRejection, inventionTagRejection, MAX_BULK_OUTFIT_ACCOUNTS, @@ -35,6 +36,9 @@ import { getInventionVersion, getMyInventions, getTopInventions, + INVENTION_TAG_RESULT, + inventionSaveV9Failure, + normalizeInventionTags, ownsAllInventions, parsePermissionLevel, publishInvention, @@ -42,6 +46,7 @@ import { setInventionPrice, setInventionTags, toSaveResult, + toSaveResultV9, updateInvention, } from '../inventions-db' import { @@ -65,6 +70,7 @@ import { InventionPersonalDetails, InventionReportRequest, InventionSaveResult, + InventionSaveV9Result, InventionVersionDto, json, JsonArray, @@ -77,7 +83,9 @@ import { OutfitsMeRequest, OutfitsMeResponse, pageParams, + PublishInventionRequest, SaveInventionRequest, + SaveInventionV9Request, SetTagsRequest, SetTagsResponse, stringParam, @@ -87,13 +95,14 @@ import { TagFilters, UNAUTHORIZED_RESPONSE, UpdateCustomAvatarItemRequest, + UpdateInventionMetadataRequest, UpdatePriceRequest, } from '../openapi' import { createReport } from '../reports-db' import type { Context } from 'hono' import type { App } from '../context' -import type { SavedInvention } from '../inventions-db' +import type { InventionTag, SavedInvention } from '../inventions-db' /** * The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers @@ -133,24 +142,150 @@ async function bulkCustomAvatarItemIds(c: Context): Promise { /** * The gate every invention write runs through: the caller must be signed in, the - * invention must exist, and it must be theirs. Yields the loaded invention, or the - * error response to return as-is (401 / 404 / 403). + * invention must exist, and it must be theirs. Yields the loaded invention, or why not — + * as a reason and the status it maps to, so that a caller answering an envelope can put + * the reason where its client will read it instead of in a body that client can't parse. + * {@link creatorsInvention} is the rendering the older routes want. + */ +async function creatorsInventionResult( + c: Context, + inventionId: number +): Promise< + { invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 } +> { + const playerId = await authedId(c) + if (playerId === null) return { rejection: 'Unauthorized', status: 401 } + if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 } + + const invention = await getInventionById(c.env.DB, inventionId) + if (invention === null) return { rejection: 'No such invention', status: 404 } + if (invention.CreatorPlayerId !== playerId) { + return { rejection: 'Not your invention', status: 403 } + } + return { invention } +} + +/** + * {@link creatorsInventionResult} as the older invention writes answer it: the loaded + * invention, or the response to return as-is (400 / 401 / 403 / 404). */ async function creatorsInvention( c: Context, inventionId: number ): Promise<{ invention: SavedInvention } | { response: Response | Promise }> { - const playerId = await authedId(c) - if (playerId === null) return { response: unauthorized(c) } - if (Number.isNaN(inventionId)) { - return { response: c.json({ error: 'inventionId is required' }, 400) } + const gate = await creatorsInventionResult(c, inventionId) + if ('invention' in gate) return gate + if (gate.status === 401) return { response: unauthorized(c) } + if (gate.status === 404) return { response: c.notFound() } + return { response: c.json({ error: gate.rejection }, gate.status) } +} + +/** + * The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken — + * the block the v9 save sends as `tagsRequest` and `v2/metadata` sends as `TagsRequest`. + * Null when the client named no block at all, which each caller reads its own way: a save + * stores no tags, an edit leaves the stored ones alone. + * + * Tags are held to the same rule `v1/settags` applies, but a tag that breaks it costs the + * TAGS and not the write: both replies carry a tag result of their own precisely because + * the two outcomes are separate, and refusing a save would make the player redo a build + * over a hyphen. All the tags go rather than the offending one alone, so nothing is + * silently half-applied — the creator re-submits the list and sees what took. Blanks are + * skipped rather than counted against it; the client pads its lists with empties. + */ +function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: number } | null { + if (typeof request !== 'object' || request === null) return null + + const lists = request as Record + const strings = (v: unknown): string[] => + Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : [] + const autoTags = strings(lists.AutoTags) + const customTags = strings(lists.CustomTags) + + const rejected = [...autoTags, ...customTags].some((raw) => { + const tag = raw.trim().toLowerCase() + return tag !== '' && inventionTagRejection(tag) !== null + }) + return rejected + ? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected } + : { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success } +} + +/** + * What an invention save produced: the stored record and how its tags fared, or the one + * message that refuses it. Both save routes go through {@link createInventionFromBody} to + * get one of these and then render it their own way — v6 bare, v9 enveloped — because the + * two versions disagree about the shape of a reply, not about what a save is. + */ +type InventionSaveOutcome = + | { rejection: string } + | { invention: SavedInvention; tags: InventionTag[]; tagResult: number } + +/** + * The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does + * plus what the invention points at (`referencedUnityAssetIds`), what it says about itself + * (`longDescription`, `displayMetadataJson`, `convertedFromInventionId`), `ugcVersion` and + * `hasBetaContent`, and the tags that until now needed a second `v1/settags` call. One + * reader takes them all: a v6 client sends none of them, and each is optional, so parsing + * them here changes nothing about the record a v6 save stores. + */ +async function createInventionFromBody( + c: Context, + creatorPlayerId: number, + body: Record +): Promise { + const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) + const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined) + const list = (v: unknown, is: (x: unknown) => x is T): T[] | undefined => + Array.isArray(v) ? v.filter(is) : undefined + const isString = (v: unknown): v is string => typeof v === 'string' + const isNumber = (v: unknown): v is number => typeof v === 'number' + + const inventionDataFilename = str(body.inventionDataFilename)?.trim() + if (!inventionDataFilename) return { rejection: 'inventionDataFilename is required' } + + // 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 { rejection: nameRejection } + + const description = str(body.description) + const descriptionRejection = + description === undefined ? null : inventionDescriptionRejection(description) + if (descriptionRejection !== null) return { rejection: descriptionRejection } + + // v9 folds `v1/settags` into the save; a client that names no tags gets none. + const requested = requestedTags(body.tagsRequest) ?? { + tags: [], + tagResult: INVENTION_TAG_RESULT.success, } - const invention = await getInventionById(c.env.DB, inventionId) - if (invention === null) return { response: c.notFound() } - if (invention.CreatorPlayerId !== playerId) { - return { response: c.json({ error: 'Not your invention' }, 403) } - } - return { invention } + + const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, { + creatorPlayerId, + inventionDataFilename, + name, + description, + imageName: str(body.imageName), + instantiationCost: num(body.instantiationCost), + lightsCost: num(body.lightsCost), + chipsCost: num(body.chipsCost), + cloudVariablesCost: num(body.cloudVariablesCost), + aiCost: num(body.aiCost), + creationRoomId: num(body.creationRoomId), + referencedInventions: list(body.referencedInventions, isNumber), + ugcVersion: num(body.ugcVersion), + hasBetaContent: bool(body.hasBetaContent), + referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString), + longDescription: str(body.longDescription), + displayMetadataJson: str(body.displayMetadataJson), + convertedFromInventionId: num(body.convertedFromInventionId), + tags: requested.tags, + }) + return { invention, ...requested } } /** @@ -1216,12 +1351,11 @@ export const avatarRoutes = new Hono({ strict: false }) const permissionLevel = c.req.query('permissionLevel') const price = Number.parseInt(c.req.query('price') ?? '', 10) - const published = await publishInvention( - c.env.DB, - gate.invention.InventionId, - permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel), - Number.isNaN(price) || price < 0 ? undefined : price - ) + const published = await publishInvention(c.env.DB, gate.invention.InventionId, { + permissionLevel: + permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel), + price: Number.isNaN(price) || price < 0 ? undefined : price, + }) return published === null ? c.notFound() : c.json(toSaveResult(published)) } ) @@ -1670,43 +1804,229 @@ export const avatarRoutes = new Hono({ strict: false }) const body = (await c.req.json().catch(() => null)) as Record | null if (body === null) return c.json({ error: 'Invalid request body' }, 400) - const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) - const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) + const outcome = await createInventionFromBody(c, id, body) + if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400) + return c.json(toSaveResult(outcome.invention)) + } + ) - const inventionDataFilename = str(body.inventionDataFilename)?.trim() - if (!inventionDataFilename) { - return c.json({ error: 'inventionDataFilename is required' }, 400) + // The same save as the newer client sends it: v6's body plus the invention's + // references, its long description and display metadata, what the saved blob is, and + // the tags — which v6 clients set afterwards through `v1/settags`. It stores the same + // record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`: + // the client reads `Success` and then `Value.Invention.InventionId`, and a body that + // isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer — + // takes it down rather than failing it, which is why every branch below answers one. + .post( + '/api/inventions/v9/save', + describeRoute({ + tags: ['Inventions'], + summary: 'Save a new invention (v9)', + description: + '`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' + + '`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' + + '`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' + + '`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' + + 'stored only when sent, so a body v6 would accept produces the same record here.' + + '\n\n' + + 'The reply is where the two versions part: v9 is ENVELOPED as ' + + '`{ Value, Success, Error, error_id }`, with v6’s ' + + '`{ Status, Invention, InventionVersion }` inside `Value` alongside a ' + + '`TagsResponse`. The client reads `Success` and then ' + + '`Value.Invention.InventionId`; `Error` is the only text it ever shows a human.' + + '\n\n' + + 'So a refusal is *also* a 200 carrying `{ Success: false, Error, Value: null }` — ' + + 'the client dereferences `Value` unguarded when `Success` is true, and treats ' + + 'anything that isn’t this envelope as a null one. Tags are held to the ' + + '`v1/settags` rule (at most 15 letters each), but one that breaks it costs the ' + + 'tags and not the save: `TagsResponse.Result` comes back non-zero and the creator ' + + 're-submits them through `v1/settags`.\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(SaveInventionV9Request, 'The invention metadata (camelCase)'), + responses: { + 200: json( + InventionSaveV9Result, + 'The envelope — the stored invention under `Value`, or `Success: false` with ' + + '`Error` when the save was refused' + ), + 401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'), + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.json(inventionSaveV9Failure('Unauthorized'), 401) + + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json(inventionSaveV9Failure('Invalid request body')) + + const outcome = await createInventionFromBody(c, id, body) + return c.json( + 'rejection' in outcome + ? inventionSaveV9Failure(outcome.rejection) + : toSaveResultV9(outcome.invention, outcome.tags, outcome.tagResult) + ) + } + ) + + // Edit an invention's metadata, as the newer client sends it: one PUT with a PascalCase + // body where every field but the id is nullable, and NULL means "leave this alone" — + // the client sends the whole shape every time and marks the fields it isn't touching. + // The tags ride along the way they do on `v9/save`, and the reply is that same + // envelope: `v1/update` is the older client's version of this endpoint, query params + // and a bare body and all. + .put( + '/api/inventions/v2/metadata', + describeRoute({ + tags: ['Inventions'], + summary: 'Edit an invention’s metadata (v2)', + description: + 'Creator only. Every field but `InventionId` is nullable and a null one is left ' + + 'as it is — the client sends the whole shape on every edit — so this is a patch, ' + + 'not a replace. An empty string is not a null: it is how a creator CLEARS a ' + + 'description, long description or image. `Name` is the exception, since a nameless ' + + 'invention isn’t a thing the client can draw: it is held to the same 3–24 ' + + 'character rule `v6/save` enforces, which an empty name fails.\n\n' + + '`TagsRequest` replaces both tag lists wholesale, exactly as `v1/settags` does; a ' + + 'null one leaves the stored tags alone. A tag that breaks the tag rule costs the ' + + 'tags and not the edit — `TagsResponse.Result` comes back non-zero.\n\n' + + 'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' + + 'the client re-renders the detail page from `Value.Invention`. Refusals — an ' + + 'unknown invention and someone else’s alike — are `Success: false` with a null ' + + '`Value` rather than a bare error body, which that client cannot parse.', + security: AUTHED, + requestBody: jsonBody(UpdateInventionMetadataRequest, 'The fields to change'), + responses: { + 200: json( + InventionSaveV9Result, + 'The envelope — the updated invention under `Value`, or `Success: false` with ' + + '`Error` when the edit was refused' + ), + 401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'), + }, + }), + async (c) => { + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json(inventionSaveV9Failure('Invalid request body')) + + // The id rides in the body here, not the query string. + const gate = await creatorsInventionResult( + c, + typeof body.InventionId === 'number' ? body.InventionId : Number.NaN + ) + // Only a missing token is answered as a transport failure. An unknown invention + // or someone else's is a domain answer the client is meant to read — its own + // status enum has DoesNotExist and NotCreator members — so it goes in the + // envelope, where the message reaches a human. + if ('rejection' in gate) { + return gate.status === 401 + ? c.json(inventionSaveV9Failure(gate.rejection), 401) + : c.json(inventionSaveV9Failure(gate.rejection)) + } + + // Null is "leave it"; a string, empty or not, is an edit. + const edited = (key: string): string | undefined => + typeof body[key] === 'string' ? body[key] : undefined + const name = edited('Name')?.trim() + const description = edited('Description') + const longDescription = edited('LongDescription') + + for (const rejection of [ + name === undefined ? null : inventionNameRejection(name), + description === undefined ? null : inventionDescriptionRejection(description), + longDescription === undefined + ? null + : inventionLongDescriptionRejection(longDescription), + ]) { + if (rejection !== null) return c.json(inventionSaveV9Failure(rejection)) } - // 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, + // A null TagsRequest leaves the stored tags alone, and the reply still reports + // them: the client reads the list back as the tags the invention now has, not as + // the ones this call changed. + const requested = requestedTags(body.TagsRequest) + const updated = await updateInvention(c.env.DB, gate.invention.InventionId, { name, description, - imageName: str(body.imageName), - instantiationCost: num(body.instantiationCost), - lightsCost: num(body.lightsCost), - chipsCost: num(body.chipsCost), - cloudVariablesCost: num(body.cloudVariablesCost), - aiCost: num(body.aiCost), - creationRoomId: num(body.creationRoomId), - referencedInventions: Array.isArray(body.referencedInventions) - ? body.referencedInventions.filter((v): v is number => typeof v === 'number') - : undefined, + longDescription, + imageName: edited('ImageName'), + tags: requested?.tags, + }) + if (updated === null) return c.json(inventionSaveV9Failure('No such invention')) + return c.json( + toSaveResultV9( + updated, + updated.Tags ?? [], + requested?.tagResult ?? INVENTION_TAG_RESULT.success + ) + ) + } + ) + + // Publish an invention, as the newer client sends it: a PascalCase body instead of a + // query string, and an Accessibility of its own — where `v3/publish` only ever flipped + // the published flag, this decides whether the result can be FOUND. Same enveloped + // reply as `v9/save`, carrying the published invention. + .post( + '/api/inventions/v4/publish', + describeRoute({ + tags: ['Inventions'], + summary: 'Publish an invention (v4)', + description: + 'What puts an invention into search and the feeds. Creator only.\n\n' + + '`Permission` is the `GeneralPermission` other players get, as a raw ladder ' + + 'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' + + 'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' + + 'keeps it out of browse and search. A null `Price` leaves the price alone rather ' + + 'than zeroing it, so re-publishing something that was for sale doesn’t give it ' + + 'away; every field but `InventionId` is nullable and an omitted one keeps what ' + + 'the invention has.\n\n' + + 'Publishing is not undone here, and re-publishing doesn’t re-date the first ' + + 'publish. Refusals answer `Success: false` with a null `Value`, the way ' + + '`v9/save` does.', + security: AUTHED, + requestBody: jsonBody(PublishInventionRequest, 'What the publish decides'), + responses: { + 200: json( + InventionSaveV9Result, + 'The envelope — the published invention under `Value`, or `Success: false` ' + + 'with `Error` when the publish was refused' + ), + 401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'), + }, + }), + async (c) => { + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json(inventionSaveV9Failure('Invalid request body')) + + const gate = await creatorsInventionResult( + c, + typeof body.InventionId === 'number' ? body.InventionId : Number.NaN + ) + // As on `v2/metadata`: only a missing token is a transport failure. The rest are + // answers the client is meant to read out of the envelope. + if ('rejection' in gate) { + return gate.status === 401 + ? c.json(inventionSaveV9Failure(gate.rejection), 401) + : c.json(inventionSaveV9Failure(gate.rejection)) + } + + // Null is "leave it". The permission and accessibility are taken as sent rather + // than checked against the ladder, the way `parsePermissionLevel` already accepts + // a raw number: the ladders are the client's, and a level this server hasn't heard + // of is better stored than swapped for one the creator didn't pick. + const int = (key: string): number | undefined => + typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined + const price = int('Price') + + const published = await publishInvention(c.env.DB, gate.invention.InventionId, { + permissionLevel: int('Permission'), + accessibility: int('Accessibility'), + // A negative price is dropped rather than stored, as it is on `v3/publish`. + price: price !== undefined && price < 0 ? undefined : price, }) - return c.json(toSaveResult(invention)) + if (published === null) return c.json(inventionSaveV9Failure('No such invention')) + return c.json(toSaveResultV9(published, published.Tags ?? [])) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index fcc0f18..92a4cdc 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -63,7 +63,11 @@ import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../war import type { SavedImage } from '@repo/domain' import type { Env } from '../../context' import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db' -import type { InventionSaveResult, SavedInvention } from '../../inventions-db' +import type { + InventionSaveResult, + InventionSaveV9Result, + SavedInvention, +} from '../../inventions-db' declare module 'cloudflare:test' { interface ProvidedEnv extends Env {} @@ -1808,6 +1812,557 @@ describe('public endpoints', () => { expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId }) }) + test('POST /api/inventions/v9/save answers the enveloped result the client reads', async () => { + const body = { + name: '082926 13:42:46', + description: 'No description yet', + imageName: 'invention/2026-08-29/52c1e282-76f5-4974-975f-d85060884085.jpg', + hasBetaContent: false, + instantiationCost: 101, + lightsCost: 0, + chipsCost: 0, + cloudVariablesCost: 0, + aiCost: 0, + ugcVersion: 1, + creationRoomId: 398, + inventionDataFilename: '2026-08-29/cb608051-f38b-4ef2-aa8a-a26eb0195b2b.inv', + referencedInventions: [], + referencedUnityAssetIds: [], + creatorAccountRole: 255, + convertedFromInventionId: null, + displayMetadataJson: '{"0":0,"99":0}', + longDescription: '', + tagsRequest: { AutoTags: ['small'], CustomTags: null }, + } + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5151')), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + expect(res.status).toBe(200) + + // v9 is ENVELOPED where v6 is bare. The client checks Success and then reads + // Value.Invention.InventionId — unguarded, so a true Success with a null Value is + // the one shape that takes it down. + const result = (await res.json()) as InventionSaveV9Result + expect(result.Success).toBe(true) + expect(result.Error).toBeNull() + expect(result.error_id).toBeNull() + const value = result.Value + if (value === null) throw new Error('Value must not be null on a successful save') + expect(value.Status).toBe(0) + expect(Object.keys(value).sort()).toEqual([ + 'Invention', + 'InventionVersion', + 'Status', + 'TagsResponse', + ]) + + const saved = value.Invention + expect(saved.InventionId).toBeGreaterThan(0) + expect(saved.CreatorPlayerId).toBe(5151) + expect(saved.Name).toBe(body.name) + expect(saved.CreationRoomId).toBe(398) + expect(saved.DisplayMetadataJson).toBe('{"0":0,"99":0}') + // UgcVersion is an INVENTION field here, next to the version numbers — not a + // version one, where its twin HasBetaContent lives. + expect(saved.UgcVersion).toBe(1) + expect(saved.CurrentVersionNumber).toBe(1) + expect(saved.LatestVersionNumber).toBe(1) + // The v9 RRInvention has no nested version, no Referenced* and no IsPublished — + // the client reads publication from FirstPublishedAt. + expect(saved).not.toHaveProperty('CurrentVersion') + expect(saved).not.toHaveProperty('ReferencedInventions') + expect(saved).not.toHaveProperty('IsPublished') + expect(saved.FirstPublishedAt).toBeNull() + + // Costs, the blob and the beta flag ride on the version beside it. No AICost: the + // request sends one and this DTO has nowhere to put it. + expect(value.InventionVersion).toMatchObject({ + InventionId: saved.InventionId, + VersionNumber: 1, + InstantiationCost: 101, + HasBetaContent: false, + BlobName: body.inventionDataFilename, + UgcAccessibility: null, + ReferencedInventions: [], + ReferencedUnityAssetIds: [], + }) + expect(value.InventionVersion).not.toHaveProperty('AICost') + + // The tagsRequest is applied as `v1/settags` would have applied it, and answered + // the way settags answers: a result code and the bare tag NAMES. + expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] }) + const details = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/details?inventionId=${saved.InventionId}` + ) + expect(await details.json()).toEqual({ Tags: [{ Tag: 'small', Type: 2 }] }) + + // Stored once, read by every version: the older lookup still serves the record it + // always did, nested CurrentVersion and all. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}` + ) + expect((await one.json()) as SavedInvention).toMatchObject({ + InventionId: saved.InventionId, + IsPublished: false, + CurrentVersion: { BlobName: body.inventionDataFilename }, + }) + }) + + test('POST /api/inventions/v9/save leaves the v9-only keys off the stored record', async () => { + // The same body a v6 client sends, posted at v9: nothing is back-filled, so the + // record is the one v6 has always stored. The response still carries the full v9 + // projection — those fields have defaults there, not absences. + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5152')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Bare Save', inventionDataFilename: 'bare.inv' }), + }) + expect(res.status).toBe(200) + const value = ((await res.json()) as InventionSaveV9Result).Value + if (value === null) throw new Error('Value must not be null on a successful save') + expect(value.Invention.UgcVersion).toBe(0) + expect(value.Invention.DisplayMetadataJson).toBeNull() + expect(value.InventionVersion.HasBetaContent).toBe(false) + expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] }) + + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}` + ) + const stored = (await one.json()) as SavedInvention + expect(stored).not.toHaveProperty('Tags') + expect(stored).not.toHaveProperty('UgcVersion') + expect(stored).not.toHaveProperty('ReferencedUnityAssetIds') + expect(stored.CurrentVersion).not.toHaveProperty('HasBetaContent') + }) + + test('POST /api/inventions/v9/save refuses through the envelope, never a bare error', async () => { + // A refusal the client can show is Success:false with a null Value — the branch + // that reads Error and nothing else. A bare `{ error }` body would deserialize to + // a null envelope and take the client down instead of failing the save. + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5153')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'No Blob' }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + Value: null, + Success: false, + Error: 'inventionDataFilename is required', + error_id: null, + }) + + // Even the 401 answers the envelope: an empty body is a null envelope to the + // client, which is the crash, not a refusal. + const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Anon', inventionDataFilename: 'anon.inv' }), + }) + expect(anon.status).toBe(401) + expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ + Value: null, + Success: false, + }) + }) + + test('POST /api/inventions/v9/save keeps the save when a tag breaks the tag rule', async () => { + // The reply carries a tag result of its own, so the two outcomes are separate: a + // hyphen in a tag must not cost the player the build they just saved. + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5154')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Tagged Badly', + inventionDataFilename: 'tagged-badly.inv', + tagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] }, + }), + }) + expect(res.status).toBe(200) + const result = (await res.json()) as InventionSaveV9Result + expect(result.Success).toBe(true) + const value = result.Value + if (value === null) throw new Error('a refused tag must not refuse the save') + expect(value.Invention.InventionId).toBeGreaterThan(0) + + // Non-zero result, and the whole list dropped rather than the offending tag alone — + // the creator re-submits it through `v1/settags` and sees what took. + expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] }) + const details = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/details?inventionId=${value.Invention.InventionId}` + ) + expect(await details.json()).toEqual({ Tags: [] }) + + // The invention is on the creator's shelf regardless. + const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, { + headers: await bearer('5154'), + }) + expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([ + value.Invention.InventionId, + ]) + }) + + test('PUT /api/inventions/v2/metadata edits only the fields that aren’t null', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Before Edit', + description: 'the original description', + imageName: 'invention/before.jpg', + inventionDataFilename: 'before-edit.inv', + longDescription: 'the original blurb', + tagsRequest: { AutoTags: ['small'], CustomTags: null }, + }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + expect(inventionId).toBeGreaterThan(0) + + // The client sends the whole shape every time and marks what it isn't touching as + // null — so a null Name must not blank the name. + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, { + method: 'PUT', + headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + InventionId: inventionId, + Name: null, + Description: 'devin test No description yet', + LongDescription: null, + ImageName: null, + TagsRequest: null, + }), + }) + expect(res.status).toBe(200) + const result = (await res.json()) as InventionSaveV9Result + expect(result.Success).toBe(true) + const value = result.Value + if (value === null) throw new Error('Value must not be null on a successful edit') + + // The edit answers the UPDATED invention — the client re-renders the detail page + // from it — in the same envelope the save answers. + expect(value.Invention.Description).toBe('devin test No description yet') + expect(value.Invention.Name).toBe('Before Edit') + expect(value.Invention.ImageName).toBe('invention/before.jpg') + expect(value.Invention.InventionId).toBe(inventionId) + // A null TagsRequest leaves the stored tags alone, and they are still reported: the + // list is what the invention HAS, not what this call changed. + expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] }) + + // And it stuck — including the long description, which the v9 Invention DTO has no + // key for but the record keeps. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${inventionId}` + ) + expect((await one.json()) as SavedInvention).toMatchObject({ + Name: 'Before Edit', + Description: 'devin test No description yet', + LongDescription: 'the original blurb', + Tags: [{ Tag: 'small', Type: 2 }], + }) + }) + + test('PUT /api/inventions/v2/metadata treats an empty string as a clear, not a null', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Clear Me', + description: 'to be cleared', + imageName: 'invention/clear-me.jpg', + inventionDataFilename: 'clear-me.inv', + longDescription: 'blurb to be cleared', + }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, { + method: 'PUT', + headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + InventionId: inventionId, + Name: null, + Description: '', + LongDescription: '', + ImageName: '', + TagsRequest: { AutoTags: ['large'], CustomTags: ['puzzle'] }, + }), + }) + const value = ((await res.json()) as InventionSaveV9Result).Value + if (value === null) throw new Error('Value must not be null on a successful edit') + expect(value.Invention.Description).toBe('') + expect(value.Invention.ImageName).toBe('') + // TagsRequest replaces both lists wholesale, auto first, then custom. + expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['large', 'puzzle'] }) + + // An empty name is not how a name is cleared — nothing can draw a nameless + // invention, so it fails the same rule a save holds it to and nothing is written. + const named = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, { + method: 'PUT', + headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Name: '' }), + }) + expect(named.status).toBe(200) + expect((await named.json()) as InventionSaveV9Result).toMatchObject({ + Value: null, + Success: false, + }) + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${inventionId}` + ) + expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me') + }) + + test('PUT /api/inventions/v2/metadata refuses another creator’s invention in-band', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Not Yours', inventionDataFilename: 'not-yours.inv' }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + // Someone else's invention and an unknown one are domain answers, not transport + // ones — the client's own status enum has NotCreator and DoesNotExist members — so + // they come back 200 in the envelope, where the message reaches a human. + const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, { + method: 'PUT', + headers: { ...(await bearer('5163')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Description: 'hijacked' }), + }) + expect(theirs.status).toBe(200) + expect(await theirs.json()).toEqual({ + Value: null, + Success: false, + Error: 'Not your invention', + error_id: null, + }) + + const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, { + method: 'PUT', + headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: 987654, Description: 'nobody' }), + }) + expect(missing.status).toBe(200) + expect((await missing.json()) as InventionSaveV9Result).toMatchObject({ + Value: null, + Error: 'No such invention', + }) + + // A missing token is the one refusal that stays a transport failure — but it still + // answers the envelope, because an unparseable body crashes the client. + const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Description: 'anon' }), + }) + expect(anon.status).toBe(401) + expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ + Value: null, + Success: false, + }) + + // Untouched throughout. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${inventionId}` + ) + expect(((await one.json()) as SavedInvention).Description).toBe('No description yet') + }) + + test('PUT /api/inventions/v2/metadata keeps the edit when a tag breaks the tag rule', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Tag Trouble', + inventionDataFilename: 'tag-trouble.inv', + tagsRequest: { AutoTags: ['small'], CustomTags: null }, + }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, { + method: 'PUT', + headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + InventionId: inventionId, + Description: 'edited anyway', + TagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] }, + }), + }) + const value = ((await res.json()) as InventionSaveV9Result).Value + if (value === null) throw new Error('a refused tag must not refuse the edit') + // The metadata edit lands; the tags are what didn't. + expect(value.Invention.Description).toBe('edited anyway') + expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] }) + const details = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/details?inventionId=${inventionId}` + ) + expect(await details.json()).toEqual({ Tags: [] }) + }) + + test('POST /api/inventions/v4/publish publishes with the permission and accessibility sent', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Publish Me', inventionDataFilename: 'publish-me.inv' }), + }) + const saved = ((await save.json()) as InventionSaveV9Result).Value?.Invention + expect(saved?.FirstPublishedAt).toBeNull() + + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + InventionId: saved?.InventionId, + Permission: 20, + Accessibility: 1, + Price: null, + }), + }) + expect(res.status).toBe(200) + const result = (await res.json()) as InventionSaveV9Result + expect(result.Success).toBe(true) + const value = result.Value + if (value === null) throw new Error('Value must not be null on a successful publish') + + // Publishing narrows what everyone else gets down to what the sheet sent, and dates + // the invention — the client reads publication from FirstPublishedAt, not a flag. + expect(value.Invention.GeneralPermission).toBe(20) + expect(value.Invention.Accessibility).toBe(1) + expect(typeof value.Invention.FirstPublishedAt).toBe('string') + expect(value.Invention.Price).toBe(0) + + // And it's findable now: the record the older reads serve says published, and it + // turns up in search. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}` + ) + expect((await one.json()) as SavedInvention).toMatchObject({ IsPublished: true }) + const found = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=Publish Me`) + expect(((await found.json()) as SavedInvention[]).map((i) => i.InventionId)).toContain( + value.Invention.InventionId + ) + + // Taken back out: the browse tests below assert the exact published catalogue, and + // a test that publishes something publicly is a test that changes it. + await env.DB.prepare('DELETE FROM invention WHERE id = ?1') + .bind(value.Invention.InventionId) + .run() + }) + + test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Quietly Published', + inventionDataFilename: 'quietly-published.inv', + creationRoomId: 4171, + }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 2 }), + }) + const value = ((await res.json()) as InventionSaveV9Result).Value + expect(value?.Invention.Accessibility).toBe(2) + + // Unlisted is published — it is reachable by id, which is the whole point of it — + // but it is not something anyone comes across. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${inventionId}` + ) + expect((await one.json()) as SavedInvention).toMatchObject({ + InventionId: inventionId, + IsPublished: true, + }) + const found = await exports.default.fetch( + `${ORIGIN}/api/inventions/v2/search?value=Quietly Published` + ) + expect(await found.json()).toEqual([]) + const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`) + expect(await room.json()).toEqual([]) + }) + + test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'For Sale', inventionDataFilename: 'for-sale.inv' }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + const publish = async (body: Record) => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, ...body }), + }) + const value = ((await res.json()) as InventionSaveV9Result).Value + if (value === null) throw new Error('Value must not be null on a successful publish') + return value.Invention + } + + const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 }) + expect(first.Price).toBe(250) + + // A republish that says nothing about money must not give away something that was + // for sale, and must not re-date the first publish. + const again = await publish({ Permission: 20, Accessibility: 1, Price: null }) + expect(again.Price).toBe(250) + expect(again.GeneralPermission).toBe(20) + expect(again.FirstPublishedAt).toBe(first.FirstPublishedAt) + + // A negative price is dropped rather than stored. + expect((await publish({ Price: -5 })).Price).toBe(250) + + // Out of the published catalogue again — see the note in the publish test above. + await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run() + }) + + test('POST /api/inventions/v4/publish refuses another creator’s invention in-band', async () => { + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, { + method: 'POST', + headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Theirs Alone', inventionDataFilename: 'theirs-alone.inv' }), + }) + const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId + + const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }), + }) + expect(theirs.status).toBe(200) + expect(await theirs.json()).toEqual({ + Value: null, + Success: false, + Error: 'Not your invention', + error_id: null, + }) + + const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }), + }) + expect(anon.status).toBe(401) + expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ Value: null }) + + // Still unpublished throughout. + const one = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${inventionId}` + ) + expect((await one.json()) as SavedInvention).toMatchObject({ + IsPublished: false, + FirstPublishedAt: null, + }) + }) + 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. @@ -6317,7 +6872,9 @@ describe('openapi', () => { 'POST /api/inventions/v1/settags', 'POST /api/inventions/v1/update', 'POST /api/inventions/v1/updateprice', + 'POST /api/inventions/v4/publish', 'POST /api/inventions/v6/save', + 'POST /api/inventions/v9/save', 'POST /api/messages/v1/friendOnlineStatus', 'POST /api/messages/v1/sendMultiple', 'POST /api/messages/v2/send', @@ -6350,6 +6907,7 @@ describe('openapi', () => { 'POST /outfits/bulk', 'POST /statsigUserProperties', 'PUT /api/customAvatarItems/v1/{id}', + 'PUT /api/inventions/v2/metadata', 'PUT /api/playerevents/v2/{eventId}/accessibility', 'PUT /api/playerevents/v2/{eventId}/description', 'PUT /api/playerevents/v2/{eventId}/name', diff --git a/packages/domain/src/validation.ts b/packages/domain/src/validation.ts index bce705a..ae62476 100644 --- a/packages/domain/src/validation.ts +++ b/packages/domain/src/validation.ts @@ -61,6 +61,15 @@ export const MIN_INVENTION_NAME_LENGTH = 3 export const MAX_INVENTION_NAME_LENGTH = 24 export const MAX_INVENTION_DESCRIPTION_LENGTH = 512 +/** + * The long description — the blurb on an invention's detail page, as opposed to the one + * line that fits under a browse tile. The client sends it on `v9/save` and edits it + * through `v2/metadata`, and how long it lets one get has not been pinned down, so the cap + * here is this server's own: generous enough that no real blurb hits it, small enough that + * a record stays a record. It is checked only when a caller actually sends one. + */ +export const MAX_INVENTION_LONG_DESCRIPTION_LENGTH = 4096 + /** * 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, @@ -180,6 +189,14 @@ export function inventionDescriptionRejection(value: string): string | null { return null } +/** Why a long description can't be stored, or null when it can. */ +export function inventionLongDescriptionRejection(value: string): string | null { + if (glyphLength(value) > MAX_INVENTION_LONG_DESCRIPTION_LENGTH) { + return `Invention long descriptions can be at most ${MAX_INVENTION_LONG_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