From b3f1d0482384d9419867b1e0f1962f522d20be9d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 1 Aug 2026 12:15:50 -0400 Subject: [PATCH] add invention blob hash which does not seem to help --- apps/api/src/context.ts | 3 ++ apps/api/src/inventions-db.ts | 61 +++++++++++++++++++++-- apps/api/src/openapi.ts | 5 +- apps/api/src/routes/avatar.ts | 24 ++++++--- apps/api/src/test/integration/api.test.ts | 54 +++++++++++++++++++- apps/api/wrangler.jsonc | 8 ++- apps/storage/src/storage.app.ts | 8 ++- 7 files changed, 146 insertions(+), 17 deletions(-) diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 7b4a3ab..24c392b 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & { // Image bucket (shared with the `img` worker, which serves objects back by // key). Uploaded saved images are written here. IMAGES: R2Bucket + // Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read + // here only to hash an invention's uploaded data blob under `invention/`. + CDN_ASSETS: R2Bucket // SignalR notifications hub (DO owned by the `notify` worker). Bound here to // push RelationshipChanged notifications when a player's relationship changes. RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index c0e2964..2f7b564 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -130,6 +130,37 @@ function inventionBlobName(filename: string): string { return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv` } +/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */ +function toBase64(bytes: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(bytes))) +} + +/** + * The hash of an invention's data blob: its SHA-256, base64-encoded, matching the + * real API's `BlobHash`. Read from the checksum the `storage` worker records at + * upload time, so this is normally a HEAD with no body transfer; a blob stored + * before that (or by anything else) is downloaded and digested instead. + * + * Null when the blob isn't in the bucket — a metadata-only save names a file that + * was never uploaded, and a hash of nothing would be worse than the absent hash the + * field already allows for. + */ +export async function inventionBlobHash( + bucket: R2Bucket, + blobName: string +): Promise { + const key = `invention/${inventionBlobName(blobName)}` + const head = await bucket.head(key) + if (head === null) return null + const recorded = head.checksums.sha256 + if (recorded !== undefined) return toBase64(recorded) + + const object = await bucket.get(key) + return object === null + ? null + : toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer())) +} + /** * Fields the client supplies on save (camelCase); everything else is defaulted here. * `inventionDataFilename` is the one the caller must supply — an invention with no @@ -163,6 +194,7 @@ export interface NewInvention { */ export async function createInvention( db: D1Database, + bucket: R2Bucket, input: NewInvention ): Promise { // Sequential id: one past the current max (the table starts empty). @@ -171,6 +203,7 @@ export async function createInvention( .first<{ next: number }>() const inventionId = row?.next ?? 1 const now = new Date().toISOString() + const blobName = inventionBlobName(input.inventionDataFilename) const invention: SavedInvention = { InventionId: inventionId, ReplicationId: crypto.randomUUID(), @@ -183,8 +216,8 @@ export async function createInvention( InventionId: inventionId, ReplicationId: crypto.randomUUID(), VersionNumber: 1, - BlobName: inventionBlobName(input.inventionDataFilename), - BlobHash: null, + BlobName: blobName, + BlobHash: await inventionBlobHash(bucket, blobName), InstantiationCost: input.instantiationCost ?? 0, LightsCost: input.lightsCost ?? 0, ChipsCost: input.chipsCost ?? 0, @@ -568,20 +601,38 @@ export async function getInventionsByRoom( */ export async function getInventionVersion( db: D1Database, + bucket: R2Bucket, inventionId: number, versionNumber: number ): Promise { const invention = await getInventionById(db, inventionId) if (invention === null) return null - return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null + if (invention.CurrentVersionNumber !== versionNumber) return null + + // A version saved before its blob finished uploading (or before we hashed on + // save at all) carries no hash. Hash it now and keep the result, so the other + // invention endpoints serve it too and this stays a one-time cost per blob. + // ModifiedAt is deliberately left alone: reading a version is not an edit. + if (invention.CurrentVersion.BlobHash === null) { + const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName) + if (hash !== null) { + invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash } + await storeInvention(db, invention) + } + } + return invention.CurrentVersion } /** Persist an edited invention record, bumping ModifiedAt. */ async function writeInvention(db: D1Database, invention: SavedInvention): Promise { - const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() } + await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() }) +} + +/** Write a record back as it stands — for changes that aren't edits (see above). */ +async function storeInvention(db: D1Database, invention: SavedInvention): Promise { await db .prepare('UPDATE invention SET data = ?1 WHERE id = ?2') - .bind(JSON.stringify(updated), invention.InventionId) + .bind(JSON.stringify(invention), invention.InventionId) .run() } diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 8f1a275..436219a 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -206,7 +206,10 @@ export const InventionVersionDto = z.object({ ReplicationId: z.string(), VersionNumber: z.int(), BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'), - BlobHash: z.string().nullable(), + BlobHash: z + .string() + .nullable() + .describe('Base64 SHA-256 of the blob; null when it was never uploaded'), InstantiationCost: z.int(), LightsCost: z.int(), ChipsCost: z.int(), diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index a3f610d..dfac9a5 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -330,18 +330,21 @@ export const avatarRoutes = new Hono({ strict: false }) ) // A single version of an invention (`?inventionId=…&version=…`) — the bare - // RRInventionVersion, which carries the blob name the client downloads. Public. - // Only the current version exists (nothing writes version history yet), so any - // other version number 404s rather than naming a blob that isn't there. + // RRInventionVersion, which carries the blob name the client downloads and the + // SHA-256 of that blob. Public. Only the current version exists (nothing writes + // version history yet), so any other version number 404s rather than naming a + // blob that isn't there. .get( '/api/inventions/v1/version', describeRoute({ tags: ['Inventions'], summary: 'One version of an invention', description: - 'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' + - 'Only the current version exists — nothing writes version history yet — so any ' + - 'other version number 404s rather than naming a blob that is not there.', + 'The bare `RRInventionVersion`, which carries the blob name the client downloads ' + + 'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' + + 'never uploaded). Only the current version exists — nothing writes version ' + + 'history yet — so any other version number 404s rather than naming a blob that ' + + 'is not there.', parameters: [ intQuery('inventionId', 'Invention id; required'), intQuery('version', 'Version number; required'), @@ -358,7 +361,12 @@ export const avatarRoutes = new Hono({ strict: false }) const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10) if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400) - const version = await getInventionVersion(c.env.DB, inventionId, versionNumber) + const version = await getInventionVersion( + c.env.DB, + c.env.CDN_ASSETS, + inventionId, + versionNumber + ) return version === null ? c.notFound() : c.json(version) } ) @@ -704,7 +712,7 @@ export const avatarRoutes = new Hono({ strict: false }) return c.json({ error: 'inventionDataFilename is required' }, 400) } - const invention = await createInvention(c.env.DB, { + const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, { creatorPlayerId: id, inventionDataFilename, name: str(body.name), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 28ecb07..f20342c 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -110,6 +110,12 @@ async function bearer(sub = '42'): Promise> { return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } } +/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */ +async function base64Sha256(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes) + return btoa(String.fromCharCode(...new Uint8Array(digest))) +} + describe('public endpoints', () => { test('GET /api/config/v1/amplitude', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`) @@ -766,6 +772,12 @@ describe('public endpoints', () => { }) test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => { + // The data file is uploaded (via the storage worker) before the metadata save, + // so the version carries its hash from the start. No sha256 recorded on this + // object — the api worker digests the blob itself in that case. + const data = new Uint8Array([1, 2, 3, 4]) + await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data) + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' }, @@ -777,7 +789,8 @@ describe('public endpoints', () => { }) const { Invention } = (await save.json()) as InventionSaveResult - // The bare RRInventionVersion — the blob name is what the client downloads. + // The bare RRInventionVersion — the blob name is what the client downloads, + // BlobHash the base64 SHA-256 of what it will download. const res = await exports.default.fetch( `${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1` ) @@ -786,6 +799,7 @@ describe('public endpoints', () => { InventionId: Invention.InventionId, VersionNumber: 1, BlobName: '2026-07-12/lamp.inv', + BlobHash: await base64Sha256(data), InstantiationCost: 42, }) @@ -808,6 +822,44 @@ describe('public endpoints', () => { expect(noId.status).toBe(400) }) + test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => { + // Saved before the upload landed: nothing to hash, so the field stays null + // rather than carrying a hash of something the client can't download. + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }), + }) + const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult + expect(InventionVersion.BlobHash).toBeNull() + + const version = async (): Promise> => { + const res = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1` + ) + return (await res.json()) as Record + } + expect((await version()).BlobHash).toBeNull() + + // Once the blob is there the hash resolves — here from the checksum recorded at + // upload time (what the storage worker puts), not by digesting the body. + const data = new Uint8Array([9, 8, 7]) + await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, { + sha256: await crypto.subtle.digest('SHA-256', data), + }) + const hash = await base64Sha256(data) + expect((await version()).BlobHash).toBe(hash) + + // And it's kept, so the other invention endpoints serve it too — without the + // read counting as an edit (ModifiedAt is untouched). + const details = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}` + ) + const stored = (await details.json()) as SavedInvention + expect(stored.CurrentVersion.BlobHash).toBe(hash) + expect(stored.ModifiedAt).toBe(Invention.ModifiedAt) + }) + test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 34a022d..20261e8 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -19,11 +19,17 @@ } ], // Image bucket shared with the `img` worker (which serves objects back by key). - // Saved-image uploads are written here. + // Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the + // `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an + // invention's uploaded data blob for its `BlobHash`. "r2_buckets": [ { "binding": "IMAGES", "bucket_name": "recflare-img" + }, + { + "binding": "CDN_ASSETS", + "bucket_name": "recflare-cdn" } ], // Cross-worker binding to the SignalR notifications hub DO (owned/migrated by diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 2d6756d..aba9534 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -150,8 +150,14 @@ const app = new Hono() // does the extension, which is why it goes on the key, not just the name. const datePrefix = new Date().toISOString().slice(0, 10) const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}` - await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), { + const bytes = await file.arrayBuffer() + await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, { httpMetadata: { contentType: file.type || 'application/octet-stream' }, + // Record the SHA-256 on the object. R2 stores an md5 on its own, but the + // hashes the client is served (an invention's `BlobHash`) are SHA-256, and + // only a checksum given at put time is readable later — this lets the `api` + // worker answer one from a HEAD instead of downloading the blob to digest it. + sha256: await crypto.subtle.digest('SHA-256', bytes), }) return c.json({ filename }) }