Merge remote-tracking branch 'origin/main'

This commit is contained in:
Devin Zuczek
2026-08-30 00:49:13 -04:00
5 changed files with 1488 additions and 73 deletions
+354 -20
View File
@@ -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 `<name>.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<string | number> = []
/** 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<S
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE is_published = 1
AND hide_from_player = 0
WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
@@ -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<string>()
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<SavedInvention | null> {
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<InventionRow>()
+187 -1
View File
@@ -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 clients 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('324 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 ------------------------------------------
+371 -51
View File
@@ -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<App>): Promise<string[]> {
/**
* 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<App>,
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<App>,
inventionId: number
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
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<string, unknown>
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<App>,
creatorPlayerId: number,
body: Record<string, unknown>
): Promise<InventionSaveOutcome> {
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 = <T>(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<App>({ 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<App>({ strict: false })
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | 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 v6s ' +
'`{ 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 isnt 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 creators 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<string, unknown> | 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 inventions 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 isnt a thing the client can draw: it is held to the same 324 ' +
'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 elses 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<string, unknown> | 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 doesnt 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 doesnt 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<string, unknown> | 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 ?? []))
}
)
+559 -1
View File
@@ -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 arent 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 creators 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<string, unknown>) => {
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 creators 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 callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
@@ -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',
+17
View File
@@ -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