From 1299ae3c6122edda4714da418aa1d32a41553214 Mon Sep 17 00:00:00 2001 From: Diango Date: Fri, 7 Aug 2026 09:24:22 -0400 Subject: [PATCH] feat(assets): harden signed manifest lifecycle --- package.json | 1 + .../1781300000000-add-cosmetic-asset-files.ts | 14 ++++ src/modules/assets/domain/AssetUrlSigner.ts | 7 +- .../assets/infrastructure/R2AssetUrlSigner.ts | 30 ++++---- .../catalog/application/GetCosmeticAssets.ts | 38 ++++++++++ .../application/GetCosmeticsCatalog.ts | 24 ++++--- src/modules/catalog/domain/Cosmetic.ts | 9 ++- .../catalog/infrastructure/CosmeticEntity.ts | 3 + .../CosmeticPostgresRepository.ts | 3 + .../loadout/application/GetMyLoadout.ts | 7 +- src/scripts/index-cosmetic-assets.ts | 47 +++++++++++++ src/server/routes/cosmetics-router.ts | 57 +++++++++++---- src/server/routes/loadout-router.ts | 7 +- src/server/routes/me-cosmetics-router.ts | 66 +++++++++++++----- src/server/routes/public-loadout-router.ts | 6 +- src/server/routes/signed-asset-response.ts | 7 ++ .../infrastructure/R2AssetUrlSigner.test.ts | 42 +++++++++++ .../application/GetCosmeticAssets.test.ts | 69 +++++++++++++++++++ .../application/GetCosmeticsCatalog.test.ts | 6 +- .../modules/catalog/domain/Cosmetic.test.ts | 17 +++++ .../CosmeticPostgresRepository.test.ts | 3 + .../loadout/application/GetMyLoadout.test.ts | 6 +- .../application/GetPublicLoadout.test.ts | 6 +- .../routes/signed-asset-response.test.ts | 18 +++++ 24 files changed, 428 insertions(+), 65 deletions(-) create mode 100644 src/migrations/1781300000000-add-cosmetic-asset-files.ts create mode 100644 src/modules/catalog/application/GetCosmeticAssets.ts create mode 100644 src/scripts/index-cosmetic-assets.ts create mode 100644 src/server/routes/signed-asset-response.ts create mode 100644 tests/unit/modules/catalog/application/GetCosmeticAssets.test.ts create mode 100644 tests/unit/server/routes/signed-asset-response.test.ts diff --git a/package.json b/package.json index ea38ff0..a71ac6f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "migration:cosmetics:run": "bun run src/scripts/run-cosmetics-migrations.ts", "migration:cosmetics:revert": "bun run src/scripts/run-cosmetics-migrations.ts --revert", "seed:cosmetics": "bun run src/scripts/seed-cosmetics.ts", + "index:cosmetic-assets": "bun run src/scripts/index-cosmetic-assets.ts", "assign:cosmetic": "bun run src/scripts/assign-cosmetic.ts", "lint": "biome check .", "lint:fix": "biome check --write .", diff --git a/src/migrations/1781300000000-add-cosmetic-asset-files.ts b/src/migrations/1781300000000-add-cosmetic-asset-files.ts new file mode 100644 index 0000000..f84c445 --- /dev/null +++ b/src/migrations/1781300000000-add-cosmetic-asset-files.ts @@ -0,0 +1,14 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +/** Stores relative object keys so request-time signing does not need R2 ListObjects. */ +export class AddCosmeticAssetFiles1781300000000 implements MigrationInterface { + name = "AddCosmeticAssetFiles1781300000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cosmetics" ADD COLUMN "asset_files" text array`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cosmetics" DROP COLUMN "asset_files"`); + } +} diff --git a/src/modules/assets/domain/AssetUrlSigner.ts b/src/modules/assets/domain/AssetUrlSigner.ts index 88af933..e760632 100644 --- a/src/modules/assets/domain/AssetUrlSigner.ts +++ b/src/modules/assets/domain/AssetUrlSigner.ts @@ -1,3 +1,8 @@ +export interface SignedAssetManifest { + assets: Record; + expiresAt: string; +} + export interface AssetUrlSigner { /** Signs a short-lived read (GET) URL for a single asset reference. */ sign(assetRef: string): string; @@ -11,5 +16,5 @@ export interface AssetUrlSigner { * Lets clients fetch a multi-file asset (a gltf plus its .bin/texture, or a * sleeve's render + preview) whose parts each need their own signed URL. */ - signManifest(prefix: string): Promise>; + signManifest(prefix: string, assetFiles?: readonly string[]): Promise; } diff --git a/src/modules/assets/infrastructure/R2AssetUrlSigner.ts b/src/modules/assets/infrastructure/R2AssetUrlSigner.ts index e265c77..c72f21e 100644 --- a/src/modules/assets/infrastructure/R2AssetUrlSigner.ts +++ b/src/modules/assets/infrastructure/R2AssetUrlSigner.ts @@ -1,6 +1,6 @@ import { S3Client } from "bun"; -import { AssetUrlSigner } from "../domain/AssetUrlSigner"; +import { AssetUrlSigner, SignedAssetManifest } from "../domain/AssetUrlSigner"; /** * Signs read URLs for assets stored in a private R2 (S3-compatible) bucket. @@ -13,6 +13,7 @@ export class R2AssetUrlSigner implements AssetUrlSigner { constructor( private readonly client: S3Client, private readonly ttlSeconds: number, + private readonly clock: () => number = Date.now, ) {} sign(assetRef: string): string { @@ -23,19 +24,20 @@ export class R2AssetUrlSigner implements AssetUrlSigner { return Object.fromEntries(assetRefs.map((ref) => [ref, this.sign(ref)])); } - async signManifest(prefix: string): Promise> { - const listed = await this.client.list({ prefix }); + async signManifest(prefix: string, assetFiles?: readonly string[]): Promise { + const files = assetFiles ?? (await this.listRelativeFiles(prefix)); + const expiresAt = new Date(this.clock() + this.ttlSeconds * 1_000).toISOString(); + + const assets = Object.fromEntries(files.map((file) => [file, this.sign(`${prefix}${file}`)])); - const manifest: Record = {}; - for (const object of listed.contents ?? []) { - const key = object.key; - // Skip the folder placeholder some tools create for an empty prefix. - if (!key || key.endsWith("/")) { - continue; - } - manifest[key.slice(prefix.length)] = this.sign(key); - } - - return manifest; + return { assets, expiresAt }; + } + + private async listRelativeFiles(prefix: string): Promise { + const listed = await this.client.list({ prefix }); + return (listed.contents ?? []) + .map((object) => object.key) + .filter((key): key is string => Boolean(key) && !key?.endsWith("/")) + .map((key) => key.slice(prefix.length)); } } diff --git a/src/modules/catalog/application/GetCosmeticAssets.ts b/src/modules/catalog/application/GetCosmeticAssets.ts new file mode 100644 index 0000000..d29ab5e --- /dev/null +++ b/src/modules/catalog/application/GetCosmeticAssets.ts @@ -0,0 +1,38 @@ +import { AssetUrlSigner } from "../../assets/domain/AssetUrlSigner"; +import { EntitlementsGatekeeper } from "../../entitlements/application/EntitlementsGatekeeper"; +import { NotFoundError } from "../../../shared/errors/NotFoundError"; +import { CosmeticRepository } from "../domain/CosmeticRepository"; + +export interface CosmeticAssets { + assets: Record; + assetsExpiresAt: string; +} + +/** Refreshes one cosmetic manifest without re-signing the whole catalog. */ +export class GetCosmeticAssets { + constructor( + private readonly repository: CosmeticRepository, + private readonly signer: AssetUrlSigner, + private readonly gatekeeper: EntitlementsGatekeeper, + ) {} + + async run(cosmeticId: string, userId: string | null): Promise { + const cosmetic = await this.repository.findById(cosmeticId); + const access = await this.gatekeeper.accessFor(userId); + + // Use the same not-found response for unknown, inactive, and inaccessible + // cosmetics so the endpoint does not disclose gated catalog entries. + if (!cosmetic || !cosmetic.active || !access.canUse(cosmetic)) { + throw new NotFoundError(`Cosmetic "${cosmeticId}" not found`); + } + + const signedManifest = await this.signer.signManifest( + cosmetic.assetRef, + cosmetic.assetFiles ?? undefined, + ); + return { + assets: signedManifest.assets, + assetsExpiresAt: signedManifest.expiresAt, + }; + } +} diff --git a/src/modules/catalog/application/GetCosmeticsCatalog.ts b/src/modules/catalog/application/GetCosmeticsCatalog.ts index 8ef54aa..9948931 100644 --- a/src/modules/catalog/application/GetCosmeticsCatalog.ts +++ b/src/modules/catalog/application/GetCosmeticsCatalog.ts @@ -11,6 +11,7 @@ export interface CatalogCosmetic { tier: CosmeticTier; displayName: string; assets: Record; + assetsExpiresAt: string; animation?: CompanionAnimationDescriptor; } @@ -41,14 +42,21 @@ export class GetCosmeticsCatalog { ); return Promise.all( - visible.map(async (cosmetic) => ({ - id: cosmetic.id, - type: cosmetic.type, - tier: cosmetic.tier, - displayName: cosmetic.displayName, - assets: await this.signer.signManifest(cosmetic.assetRef), - animation: cosmetic.animation, - })), + visible.map(async (cosmetic) => { + const signedManifest = await this.signer.signManifest( + cosmetic.assetRef, + cosmetic.assetFiles ?? undefined, + ); + return { + id: cosmetic.id, + type: cosmetic.type, + tier: cosmetic.tier, + displayName: cosmetic.displayName, + assets: signedManifest.assets, + assetsExpiresAt: signedManifest.expiresAt, + animation: cosmetic.animation, + }; + }), ); } } diff --git a/src/modules/catalog/domain/Cosmetic.ts b/src/modules/catalog/domain/Cosmetic.ts index 60b8880..22fd720 100644 --- a/src/modules/catalog/domain/Cosmetic.ts +++ b/src/modules/catalog/domain/Cosmetic.ts @@ -12,6 +12,7 @@ export class Cosmetic { public readonly displayName: string, public readonly active: boolean, public readonly animation?: CompanionAnimationDescriptor, + public readonly assetFiles: readonly string[] | null = null, ) {} static create({ @@ -21,6 +22,7 @@ export class Cosmetic { assetRef, displayName, animation, + assetFiles, }: { id: string; type: CosmeticType; @@ -28,6 +30,7 @@ export class Cosmetic { assetRef: string; displayName: string; animation?: CompanionAnimationDescriptor; + assetFiles?: readonly string[]; }): Cosmetic { if (!assetRef.trim()) { throw new InvalidArgumentError("assetRef cannot be empty"); @@ -46,7 +49,7 @@ export class Cosmetic { throw new InvalidArgumentError("animation is only allowed for COMPANION cosmetics"); } - return new Cosmetic(id, type, tier, assetRef, displayName, true, animation); + return new Cosmetic(id, type, tier, assetRef, displayName, true, animation, assetFiles ?? null); } static from(data: { @@ -57,6 +60,7 @@ export class Cosmetic { displayName: string; active: boolean; animation?: CompanionAnimationDescriptor; + assetFiles?: readonly string[] | null; }): Cosmetic { return new Cosmetic( data.id, @@ -66,6 +70,7 @@ export class Cosmetic { data.displayName, data.active, data.animation, + data.assetFiles ?? null, ); } @@ -77,6 +82,7 @@ export class Cosmetic { displayName: string; active: boolean; animation?: CompanionAnimationDescriptor; + assetFiles: readonly string[] | null; } { return { id: this.id, @@ -86,6 +92,7 @@ export class Cosmetic { displayName: this.displayName, active: this.active, animation: this.animation, + assetFiles: this.assetFiles, }; } } diff --git a/src/modules/catalog/infrastructure/CosmeticEntity.ts b/src/modules/catalog/infrastructure/CosmeticEntity.ts index 1849598..dc172ec 100644 --- a/src/modules/catalog/infrastructure/CosmeticEntity.ts +++ b/src/modules/catalog/infrastructure/CosmeticEntity.ts @@ -33,6 +33,9 @@ export class CosmeticEntity { @Column({ name: "animation", type: "jsonb", nullable: true }) animation?: CompanionAnimationDescriptor | null; + @Column({ name: "asset_files", type: "text", array: true, nullable: true }) + assetFiles: string[] | null; + @CreateDateColumn({ name: "created_at" }) createdAt: Date; diff --git a/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts b/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts index 2720f05..b7b5be2 100644 --- a/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts +++ b/src/modules/catalog/infrastructure/CosmeticPostgresRepository.ts @@ -22,6 +22,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository { displayName: entity.displayName, active: entity.active, animation: entity.animation ?? undefined, + assetFiles: entity.assetFiles, }), ); } @@ -46,6 +47,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository { displayName: entity.displayName, active: entity.active, animation: entity.animation ?? undefined, + assetFiles: entity.assetFiles, }); } @@ -61,6 +63,7 @@ export class CosmeticPostgresRepository implements CosmeticRepository { displayName: data.displayName, active: data.active, animation: data.animation ?? null, + assetFiles: data.assetFiles ? [...data.assetFiles] : null, }); await repository.save(entity); diff --git a/src/modules/loadout/application/GetMyLoadout.ts b/src/modules/loadout/application/GetMyLoadout.ts index b37838f..d193b00 100644 --- a/src/modules/loadout/application/GetMyLoadout.ts +++ b/src/modules/loadout/application/GetMyLoadout.ts @@ -8,6 +8,7 @@ export interface MyLoadoutSlot { cosmeticType: CosmeticType; cosmeticId: string; assets: Record; + assetsExpiresAt?: string; animation?: CompanionAnimationDescriptor; } @@ -24,10 +25,14 @@ export class GetMyLoadout { return Promise.all( loadout.items().map(async (item) => { const cosmetic = await this.cosmetics.findById(item.cosmeticId); + const signedManifest = cosmetic + ? await this.signer.signManifest(cosmetic.assetRef, cosmetic.assetFiles ?? undefined) + : undefined; return { cosmeticType: item.cosmeticType, cosmeticId: item.cosmeticId, - assets: cosmetic ? await this.signer.signManifest(cosmetic.assetRef) : {}, + assets: signedManifest?.assets ?? {}, + assetsExpiresAt: signedManifest?.expiresAt, animation: cosmetic?.animation, }; }), diff --git a/src/scripts/index-cosmetic-assets.ts b/src/scripts/index-cosmetic-assets.ts new file mode 100644 index 0000000..385dc52 --- /dev/null +++ b/src/scripts/index-cosmetic-assets.ts @@ -0,0 +1,47 @@ +import { cosmeticsDataSource } from "../cosmetics-data-source"; +import { createR2AssetUrlSigner } from "../modules/assets/infrastructure/createR2AssetUrlSigner"; +import { Cosmetic } from "../modules/catalog/domain/Cosmetic"; +import { CosmeticPostgresRepository } from "../modules/catalog/infrastructure/CosmeticPostgresRepository"; + +/** One-time/backfill indexer. Lists each unindexed R2 prefix once and persists + * relative keys so normal catalog/loadout requests only perform local signing. */ +async function main(): Promise { + await cosmeticsDataSource.initialize(); + + try { + const repository = new CosmeticPostgresRepository(); + const signer = createR2AssetUrlSigner(); + const cosmetics = await repository.findAll(); + let indexed = 0; + let skipped = 0; + + for (const cosmetic of cosmetics) { + if (cosmetic.assetFiles !== null) { + skipped++; + continue; + } + + const signed = await signer.signManifest(cosmetic.assetRef); + const assetFiles = Object.keys(signed.assets); + await repository.save( + Cosmetic.from({ + ...cosmetic.toPrimitives(), + assetFiles, + }), + ); + indexed++; + console.log(`Indexed ${cosmetic.assetRef}: ${assetFiles.length} files`); + } + + console.log(`Cosmetic asset index complete: ${indexed} indexed, ${skipped} skipped`); + } finally { + await cosmeticsDataSource.destroy(); + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/src/server/routes/cosmetics-router.ts b/src/server/routes/cosmetics-router.ts index f055d05..69258f4 100644 --- a/src/server/routes/cosmetics-router.ts +++ b/src/server/routes/cosmetics-router.ts @@ -1,12 +1,14 @@ import { Elysia, t } from "elysia"; import { createR2AssetUrlSigner } from "../../modules/assets/infrastructure/createR2AssetUrlSigner"; +import { GetCosmeticAssets } from "../../modules/catalog/application/GetCosmeticAssets"; import { GetCosmeticsCatalog } from "../../modules/catalog/application/GetCosmeticsCatalog"; import { CosmeticTier } from "../../modules/catalog/domain/CosmeticTier"; import { CosmeticType } from "../../modules/catalog/domain/CosmeticType"; import { CosmeticPostgresRepository } from "../../modules/catalog/infrastructure/CosmeticPostgresRepository"; import { EntitlementsGatekeeper } from "../../modules/entitlements/application/EntitlementsGatekeeper"; import { EntitlementPostgresRepository } from "../../modules/entitlements/infrastructure/EntitlementPostgresRepository"; +import { preventSignedAssetResponseCaching } from "./signed-asset-response"; const gatekeeper = new EntitlementsGatekeeper(new EntitlementPostgresRepository()); @@ -15,20 +17,45 @@ const getCosmeticsCatalog = new GetCosmeticsCatalog( createR2AssetUrlSigner(), gatekeeper, ); +const getCosmeticAssets = new GetCosmeticAssets( + new CosmeticPostgresRepository(), + createR2AssetUrlSigner(), + gatekeeper, +); -export const cosmeticsRouter = new Elysia({ prefix: "/cosmetics" }).get( - "/", - ({ query }) => getCosmeticsCatalog.run({ type: query.type, tier: query.tier }, null), - { - query: t.Object({ - type: t.Optional(t.Enum(CosmeticType)), - tier: t.Optional(t.Enum(CosmeticTier)), - }), - detail: { - tags: ["Cosmetics"], - summary: "List the cosmetics catalog", - description: - "Public catalog of cosmetics (STANDARD tier only). Filterable by type and tier. Each item includes a manifest of short-lived signed URLs, one per asset file under the cosmetic's storage prefix.", +export const cosmeticsRouter = new Elysia({ prefix: "/cosmetics" }) + .get( + "/", + ({ query, set }) => { + preventSignedAssetResponseCaching(set); + return getCosmeticsCatalog.run({ type: query.type, tier: query.tier }, null); }, - }, -); + { + query: t.Object({ + type: t.Optional(t.Enum(CosmeticType)), + tier: t.Optional(t.Enum(CosmeticTier)), + }), + detail: { + tags: ["Cosmetics"], + summary: "List the cosmetics catalog", + description: + "Public catalog of cosmetics (STANDARD tier only). Filterable by type and tier. Each item includes a manifest of short-lived signed URLs, one per asset file under the cosmetic's storage prefix.", + }, + }, + ) + .get( + "/:id/assets", + ({ params, set }) => { + preventSignedAssetResponseCaching(set); + return getCosmeticAssets.run(params.id, null); + }, + { + params: t.Object({ id: t.String() }), + detail: { + tags: ["Cosmetics"], + summary: "Refresh one public cosmetic asset manifest", + description: + "Returns fresh signed URLs only for the requested STANDARD cosmetic, without reloading the catalog.", + }, + }, + ); diff --git a/src/server/routes/loadout-router.ts b/src/server/routes/loadout-router.ts index 88af8e3..62bb5ef 100644 --- a/src/server/routes/loadout-router.ts +++ b/src/server/routes/loadout-router.ts @@ -11,6 +11,7 @@ import { EquipCosmetic } from "../../modules/loadout/application/EquipCosmetic"; import { GetMyLoadout } from "../../modules/loadout/application/GetMyLoadout"; import { LoadoutPostgresRepository } from "../../modules/loadout/infrastructure/LoadoutPostgresRepository"; import { JWT } from "../../shared/JWT"; +import { preventSignedAssetResponseCaching } from "./signed-asset-response"; const jwt = new JWT(config.jwt); const cosmetics = new CosmeticPostgresRepository(); @@ -24,7 +25,8 @@ export const loadoutRouter = new Elysia({ prefix: "/me/loadout" }) .use(bearer()) .get( "/", - ({ bearer }) => { + ({ bearer, set }) => { + preventSignedAssetResponseCaching(set); const { id } = jwt.decode(bearer as string) as { id: string }; return getMyLoadout.run(id); }, @@ -39,7 +41,8 @@ export const loadoutRouter = new Elysia({ prefix: "/me/loadout" }) ) .put( "/", - async ({ bearer, body }) => { + async ({ bearer, body, set }) => { + preventSignedAssetResponseCaching(set); const { id } = jwt.decode(bearer as string) as { id: string }; await equipCosmetic.run({ userId: id, diff --git a/src/server/routes/me-cosmetics-router.ts b/src/server/routes/me-cosmetics-router.ts index 46ce284..cae77e5 100644 --- a/src/server/routes/me-cosmetics-router.ts +++ b/src/server/routes/me-cosmetics-router.ts @@ -3,6 +3,7 @@ import { Elysia, t } from "elysia"; import { config } from "../../config"; import { createR2AssetUrlSigner } from "../../modules/assets/infrastructure/createR2AssetUrlSigner"; +import { GetCosmeticAssets } from "../../modules/catalog/application/GetCosmeticAssets"; import { GetCosmeticsCatalog } from "../../modules/catalog/application/GetCosmeticsCatalog"; import { CosmeticTier } from "../../modules/catalog/domain/CosmeticTier"; import { CosmeticType } from "../../modules/catalog/domain/CosmeticType"; @@ -10,6 +11,7 @@ import { CosmeticPostgresRepository } from "../../modules/catalog/infrastructure import { EntitlementsGatekeeper } from "../../modules/entitlements/application/EntitlementsGatekeeper"; import { EntitlementPostgresRepository } from "../../modules/entitlements/infrastructure/EntitlementPostgresRepository"; import { JWT } from "../../shared/JWT"; +import { preventSignedAssetResponseCaching } from "./signed-asset-response"; const jwt = new JWT(config.jwt); const gatekeeper = new EntitlementsGatekeeper(new EntitlementPostgresRepository()); @@ -19,24 +21,50 @@ const getCosmeticsCatalog = new GetCosmeticsCatalog( createR2AssetUrlSigner(), gatekeeper, ); +const getCosmeticAssets = new GetCosmeticAssets( + new CosmeticPostgresRepository(), + createR2AssetUrlSigner(), + gatekeeper, +); -export const meCosmeticsRouter = new Elysia({ prefix: "/me/cosmetics" }).use(bearer()).get( - "/", - ({ bearer, query }) => { - const { id } = jwt.decode(bearer as string) as { id: string }; - return getCosmeticsCatalog.run({ type: query.type, tier: query.tier }, id); - }, - { - query: t.Object({ - type: t.Optional(t.Enum(CosmeticType)), - tier: t.Optional(t.Enum(CosmeticTier)), - }), - detail: { - tags: ["Cosmetics"], - summary: "List my cosmetics catalog", - description: - "Personalized catalog of cosmetics visible to the authenticated user. Includes cosmetics covered by the user's tier or individual COSMETIC grants. Each item includes a manifest of short-lived signed URLs.", - security: [{ bearerAuth: [] }], +export const meCosmeticsRouter = new Elysia({ prefix: "/me/cosmetics" }) + .use(bearer()) + .get( + "/", + ({ bearer, query, set }) => { + preventSignedAssetResponseCaching(set); + const { id } = jwt.decode(bearer as string) as { id: string }; + return getCosmeticsCatalog.run({ type: query.type, tier: query.tier }, id); }, - }, -); + { + query: t.Object({ + type: t.Optional(t.Enum(CosmeticType)), + tier: t.Optional(t.Enum(CosmeticTier)), + }), + detail: { + tags: ["Cosmetics"], + summary: "List my cosmetics catalog", + description: + "Personalized catalog of cosmetics visible to the authenticated user. Includes cosmetics covered by the user's tier or individual COSMETIC grants. Each item includes a manifest of short-lived signed URLs.", + security: [{ bearerAuth: [] }], + }, + }, + ) + .get( + "/:id/assets", + ({ bearer, params, set }) => { + preventSignedAssetResponseCaching(set); + const { id: userId } = jwt.decode(bearer as string) as { id: string }; + return getCosmeticAssets.run(params.id, userId); + }, + { + params: t.Object({ id: t.String() }), + detail: { + tags: ["Cosmetics"], + summary: "Refresh one entitled cosmetic asset manifest", + description: + "Returns fresh signed URLs only for the requested cosmetic after checking the user's current access.", + security: [{ bearerAuth: [] }], + }, + }, + ); diff --git a/src/server/routes/public-loadout-router.ts b/src/server/routes/public-loadout-router.ts index df69147..be0667e 100644 --- a/src/server/routes/public-loadout-router.ts +++ b/src/server/routes/public-loadout-router.ts @@ -6,6 +6,7 @@ import { GetMyLoadout } from "../../modules/loadout/application/GetMyLoadout"; import { GetPublicLoadout } from "../../modules/loadout/application/GetPublicLoadout"; import { LoadoutPostgresRepository } from "../../modules/loadout/infrastructure/LoadoutPostgresRepository"; import { UserDirectoryPostgresRepository } from "../../modules/loadout/infrastructure/UserDirectoryPostgresRepository"; +import { preventSignedAssetResponseCaching } from "./signed-asset-response"; const loadouts = new LoadoutPostgresRepository(); const cosmetics = new CosmeticPostgresRepository(); @@ -14,7 +15,10 @@ const getPublicLoadout = new GetPublicLoadout(new UserDirectoryPostgresRepositor export const publicLoadoutRouter = new Elysia().get( "/users/by-username/:username/loadout", - ({ params }) => getPublicLoadout.run(params.username), + ({ params, set }) => { + preventSignedAssetResponseCaching(set); + return getPublicLoadout.run(params.username); + }, { detail: { tags: ["Cosmetics"], diff --git a/src/server/routes/signed-asset-response.ts b/src/server/routes/signed-asset-response.ts new file mode 100644 index 0000000..60bfaaf --- /dev/null +++ b/src/server/routes/signed-asset-response.ts @@ -0,0 +1,7 @@ +import type { Context } from "elysia"; + +export const SIGNED_ASSET_CACHE_CONTROL = "private, no-store"; + +export function preventSignedAssetResponseCaching(set: Context["set"]): void { + set.headers["Cache-Control"] = SIGNED_ASSET_CACHE_CONTROL; +} diff --git a/tests/unit/modules/assets/infrastructure/R2AssetUrlSigner.test.ts b/tests/unit/modules/assets/infrastructure/R2AssetUrlSigner.test.ts index bc3be25..68472a0 100644 --- a/tests/unit/modules/assets/infrastructure/R2AssetUrlSigner.test.ts +++ b/tests/unit/modules/assets/infrastructure/R2AssetUrlSigner.test.ts @@ -39,4 +39,46 @@ describe("R2AssetUrlSigner", () => { expect(urls[ref]).toContain("X-Amz-Expires=600"); } }); + + it("signs a manifest and reports its absolute expiration", async () => { + const now = Date.parse("2029-12-31T23:50:00.000Z"); + const fakeClient = { + list: async () => ({ + contents: [ + { key: "playmats/arena/" }, + { key: "playmats/arena/model.gltf" }, + { key: "playmats/arena/model.bin" }, + ], + }), + presign: (key: string) => `signed:${key}`, + } as unknown as S3Client; + const signer = new R2AssetUrlSigner(fakeClient, ttlSeconds, () => now); + + const result = await signer.signManifest("playmats/arena/"); + + expect(result).toEqual({ + assets: { + "model.gltf": "signed:playmats/arena/model.gltf", + "model.bin": "signed:playmats/arena/model.bin", + }, + expiresAt: "2030-01-01T00:00:00.000Z", + }); + }); + + it("does not call ListObjects when relative asset files are already indexed", async () => { + const fakeClient = { + list: () => { + throw new Error("ListObjects must not run on the indexed hot path"); + }, + presign: (key: string) => `signed:${key}`, + } as unknown as S3Client; + const signer = new R2AssetUrlSigner(fakeClient, ttlSeconds, () => 0); + + const result = await signer.signManifest("playmats/arena/", ["model.gltf", "model.bin"]); + + expect(result.assets).toEqual({ + "model.gltf": "signed:playmats/arena/model.gltf", + "model.bin": "signed:playmats/arena/model.bin", + }); + }); }); diff --git a/tests/unit/modules/catalog/application/GetCosmeticAssets.test.ts b/tests/unit/modules/catalog/application/GetCosmeticAssets.test.ts new file mode 100644 index 0000000..0abf2ba --- /dev/null +++ b/tests/unit/modules/catalog/application/GetCosmeticAssets.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test"; + +import type { AssetUrlSigner } from "../../../../../src/modules/assets/domain/AssetUrlSigner"; +import { GetCosmeticAssets } from "../../../../../src/modules/catalog/application/GetCosmeticAssets"; +import { Cosmetic } from "../../../../../src/modules/catalog/domain/Cosmetic"; +import type { CosmeticRepository } from "../../../../../src/modules/catalog/domain/CosmeticRepository"; +import { CosmeticTier } from "../../../../../src/modules/catalog/domain/CosmeticTier"; +import { CosmeticType } from "../../../../../src/modules/catalog/domain/CosmeticType"; +import { EntitlementsGatekeeper } from "../../../../../src/modules/entitlements/application/EntitlementsGatekeeper"; +import type { EntitlementRepository } from "../../../../../src/modules/entitlements/domain/EntitlementRepository"; +import { NotFoundError } from "../../../../../src/shared/errors/NotFoundError"; + +const standard = Cosmetic.from({ + id: "standard-playmat", + type: CosmeticType.PLAYMAT, + tier: CosmeticTier.STANDARD, + assetRef: "playmats/standard/", + displayName: "Standard", + active: true, +}); + +function build(cosmetic: Cosmetic | null): GetCosmeticAssets { + const repository: CosmeticRepository = { + findAll: async () => (cosmetic ? [cosmetic] : []), + findById: async () => cosmetic, + save: async () => undefined, + }; + const signer: AssetUrlSigner = { + sign: () => "", + signMany: () => ({}), + signManifest: async (prefix) => ({ + assets: { "model.gltf": `signed:${prefix}model.gltf` }, + expiresAt: "2030-01-01T00:00:00.000Z", + }), + }; + const entitlements: EntitlementRepository = { + findByUserId: async () => [], + save: async () => undefined, + }; + return new GetCosmeticAssets(repository, signer, new EntitlementsGatekeeper(entitlements)); +} + +describe("GetCosmeticAssets", () => { + it("returns one freshly signed manifest with its absolute expiration", async () => { + const result = await build(standard).run(standard.id, null); + + expect(result).toEqual({ + assets: { "model.gltf": "signed:playmats/standard/model.gltf" }, + assetsExpiresAt: "2030-01-01T00:00:00.000Z", + }); + }); + + it("hides an unknown cosmetic behind not-found", async () => { + await expect(build(null).run("missing", null)).rejects.toBeInstanceOf(NotFoundError); + }); + + it("hides a cosmetic the caller cannot access", async () => { + const donor = Cosmetic.from({ + id: "donor-playmat", + type: CosmeticType.PLAYMAT, + tier: CosmeticTier.DONOR, + assetRef: "playmats/donor/", + displayName: "Donor", + active: true, + }); + + await expect(build(donor).run(donor.id, null)).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts index c2ae994..969003b 100644 --- a/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts +++ b/tests/unit/modules/catalog/application/GetCosmeticsCatalog.test.ts @@ -78,7 +78,10 @@ const companion = Cosmetic.from({ const signer: AssetUrlSigner = { sign: () => "", signMany: () => ({}), - signManifest: async (prefix) => ({ "render.jpg": `signed:${prefix}render.jpg` }), + signManifest: async (prefix) => ({ + assets: { "render.jpg": `signed:${prefix}render.jpg` }, + expiresAt: "2030-01-01T00:00:00.000Z", + }), }; function fakeRepo(cosmetics: Cosmetic[]): CosmeticRepository { @@ -132,6 +135,7 @@ describe("GetCosmeticsCatalog", () => { expect(result).toHaveLength(2); expect(result[0].assets).toEqual({ "render.jpg": "signed:sleeves/a/render.jpg" }); + expect(result[0].assetsExpiresAt).toBe("2030-01-01T00:00:00.000Z"); }); it("excludes inactive cosmetics", async () => { diff --git a/tests/unit/modules/catalog/domain/Cosmetic.test.ts b/tests/unit/modules/catalog/domain/Cosmetic.test.ts index e50f40b..d376899 100644 --- a/tests/unit/modules/catalog/domain/Cosmetic.test.ts +++ b/tests/unit/modules/catalog/domain/Cosmetic.test.ts @@ -46,6 +46,23 @@ describe("Cosmetic", () => { expect(cosmetic.toPrimitives().animation).toEqual(animation); }); + it("round-trips the persisted relative asset file index", () => { + const assetFiles = ["character.glb", "Rig_Medium_General.glb", "preview.jpg"]; + const cosmetic = Cosmetic.from({ + id: "companion-1", + type: CosmeticType.COMPANION, + tier: CosmeticTier.STANDARD, + assetRef: "companions/kaykit-warrior/", + displayName: "Warrior", + active: true, + animation, + assetFiles, + }); + + expect(cosmetic.assetFiles).toEqual(assetFiles); + expect(cosmetic.toPrimitives().assetFiles).toEqual(assetFiles); + }); + it("leaves animation undefined for non-COMPANION cosmetics", () => { const cosmetic = Cosmetic.create({ id: "sleeve-1", diff --git a/tests/unit/modules/catalog/infrastructure/CosmeticPostgresRepository.test.ts b/tests/unit/modules/catalog/infrastructure/CosmeticPostgresRepository.test.ts index 574ac8e..53a6904 100644 --- a/tests/unit/modules/catalog/infrastructure/CosmeticPostgresRepository.test.ts +++ b/tests/unit/modules/catalog/infrastructure/CosmeticPostgresRepository.test.ts @@ -30,6 +30,7 @@ describe("CosmeticPostgresRepository", () => { it("maps the animation descriptor when loading a COMPANION by uuid", async () => { const animation = { rigFile: "Rig_Medium_General.glb", clips: { idle: "Idle_A" } }; + const assetFiles = ["Warrior.glb", "Rig_Medium_General.glb", "preview.jpg"]; const id = "11111111-1111-4111-8111-111111111111"; const findOne = mock(async () => ({ id, @@ -39,6 +40,7 @@ describe("CosmeticPostgresRepository", () => { displayName: "Warrior", active: true, animation, + assetFiles, })); const spy = stubRepository({ findOne }); @@ -46,6 +48,7 @@ describe("CosmeticPostgresRepository", () => { expect(result).toBeInstanceOf(Cosmetic); expect(result?.animation).toEqual(animation); + expect(result?.assetFiles).toEqual(assetFiles); spy.mockRestore(); }); diff --git a/tests/unit/modules/loadout/application/GetMyLoadout.test.ts b/tests/unit/modules/loadout/application/GetMyLoadout.test.ts index 417335a..f8f9f4b 100644 --- a/tests/unit/modules/loadout/application/GetMyLoadout.test.ts +++ b/tests/unit/modules/loadout/application/GetMyLoadout.test.ts @@ -49,7 +49,10 @@ function build(loadout: Loadout) { const signer: AssetUrlSigner = { sign: () => "", signMany: () => ({}), - signManifest: async (prefix) => ({ "render.jpg": `signed:${prefix}render.jpg` }), + signManifest: async (prefix) => ({ + assets: { "render.jpg": `signed:${prefix}render.jpg` }, + expiresAt: "2030-01-01T00:00:00.000Z", + }), }; return new GetMyLoadout(loadouts, cosmetics, signer); @@ -67,6 +70,7 @@ describe("GetMyLoadout", () => { expect(result[0].cosmeticType).toBe(CosmeticType.SLEEVE); expect(result[0].cosmeticId).toBe("cosmetic-1"); expect(result[0].assets).toEqual({ "render.jpg": "signed:sleeves/a/render.jpg" }); + expect(result[0].assetsExpiresAt).toBe("2030-01-01T00:00:00.000Z"); }); it("returns an empty loadout for a user with nothing equipped", async () => { diff --git a/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts b/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts index 2c2a5f9..bda8bc5 100644 --- a/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts +++ b/tests/unit/modules/loadout/application/GetPublicLoadout.test.ts @@ -53,7 +53,10 @@ function build(directory: UserDirectory, equipped: Cosmetic = sleeve): GetPublic const signer: AssetUrlSigner = { sign: () => "", signMany: () => ({}), - signManifest: async (prefix) => ({ "render.jpg": `signed:${prefix}render.jpg` }), + signManifest: async (prefix) => ({ + assets: { "render.jpg": `signed:${prefix}render.jpg` }, + expiresAt: "2030-01-01T00:00:00.000Z", + }), }; return new GetPublicLoadout(directory, new GetMyLoadout(loadouts, cosmetics, signer)); @@ -69,6 +72,7 @@ describe("GetPublicLoadout", () => { expect(result).toHaveLength(1); expect(result[0].assets).toEqual({ "render.jpg": "signed:sleeves/a/render.jpg" }); + expect(result[0].assetsExpiresAt).toBe("2030-01-01T00:00:00.000Z"); }); it("carries the COMPANION animation descriptor through the public gate (opponent/spectator render)", async () => { diff --git a/tests/unit/server/routes/signed-asset-response.test.ts b/tests/unit/server/routes/signed-asset-response.test.ts new file mode 100644 index 0000000..99eb3f9 --- /dev/null +++ b/tests/unit/server/routes/signed-asset-response.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; +import type { Context } from "elysia"; + +import { + preventSignedAssetResponseCaching, + SIGNED_ASSET_CACHE_CONTROL, +} from "../../../../src/server/routes/signed-asset-response"; + +describe("signed asset responses", () => { + it("prevents browsers and intermediaries from caching bearer URLs", () => { + const set = { headers: {} } as Context["set"]; + + preventSignedAssetResponseCaching(set); + + expect(set.headers["Cache-Control"]).toBe(SIGNED_ASSET_CACHE_CONTROL); + expect(SIGNED_ASSET_CACHE_CONTROL).toBe("private, no-store"); + }); +});