add invention blob hash which does not seem to help

This commit is contained in:
Devin Zuczek
2026-08-01 12:15:50 -04:00
parent 55cb769de9
commit b3f1d04823
7 changed files with 146 additions and 17 deletions
+3
View File
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
// Image bucket (shared with the `img` worker, which serves objects back by // Image bucket (shared with the `img` worker, which serves objects back by
// key). Uploaded saved images are written here. // key). Uploaded saved images are written here.
IMAGES: R2Bucket 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 // SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push RelationshipChanged notifications when a player's relationship changes. // push RelationshipChanged notifications when a player's relationship changes.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub> RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
+56 -5
View File
@@ -130,6 +130,37 @@ function inventionBlobName(filename: string): string {
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv` 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<string | null> {
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. * Fields the client supplies on save (camelCase); everything else is defaulted here.
* `inventionDataFilename` is the one the caller must supply — an invention with no * `inventionDataFilename` is the one the caller must supply — an invention with no
@@ -163,6 +194,7 @@ export interface NewInvention {
*/ */
export async function createInvention( export async function createInvention(
db: D1Database, db: D1Database,
bucket: R2Bucket,
input: NewInvention input: NewInvention
): Promise<SavedInvention> { ): Promise<SavedInvention> {
// Sequential id: one past the current max (the table starts empty). // Sequential id: one past the current max (the table starts empty).
@@ -171,6 +203,7 @@ export async function createInvention(
.first<{ next: number }>() .first<{ next: number }>()
const inventionId = row?.next ?? 1 const inventionId = row?.next ?? 1
const now = new Date().toISOString() const now = new Date().toISOString()
const blobName = inventionBlobName(input.inventionDataFilename)
const invention: SavedInvention = { const invention: SavedInvention = {
InventionId: inventionId, InventionId: inventionId,
ReplicationId: crypto.randomUUID(), ReplicationId: crypto.randomUUID(),
@@ -183,8 +216,8 @@ export async function createInvention(
InventionId: inventionId, InventionId: inventionId,
ReplicationId: crypto.randomUUID(), ReplicationId: crypto.randomUUID(),
VersionNumber: 1, VersionNumber: 1,
BlobName: inventionBlobName(input.inventionDataFilename), BlobName: blobName,
BlobHash: null, BlobHash: await inventionBlobHash(bucket, blobName),
InstantiationCost: input.instantiationCost ?? 0, InstantiationCost: input.instantiationCost ?? 0,
LightsCost: input.lightsCost ?? 0, LightsCost: input.lightsCost ?? 0,
ChipsCost: input.chipsCost ?? 0, ChipsCost: input.chipsCost ?? 0,
@@ -568,20 +601,38 @@ export async function getInventionsByRoom(
*/ */
export async function getInventionVersion( export async function getInventionVersion(
db: D1Database, db: D1Database,
bucket: R2Bucket,
inventionId: number, inventionId: number,
versionNumber: number versionNumber: number
): Promise<InventionVersion | null> { ): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId) const invention = await getInventionById(db, inventionId)
if (invention === null) return null 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. */ /** Persist an edited invention record, bumping ModifiedAt. */
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> { async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
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<void> {
await db await db
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2') .prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
.bind(JSON.stringify(updated), invention.InventionId) .bind(JSON.stringify(invention), invention.InventionId)
.run() .run()
} }
+4 -1
View File
@@ -206,7 +206,10 @@ export const InventionVersionDto = z.object({
ReplicationId: z.string(), ReplicationId: z.string(),
VersionNumber: z.int(), VersionNumber: z.int(),
BlobName: z.string().describe('The `.inv` key in the storage workers bucket'), BlobName: z.string().describe('The `.inv` key in the storage workers 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(), InstantiationCost: z.int(),
LightsCost: z.int(), LightsCost: z.int(),
ChipsCost: z.int(), ChipsCost: z.int(),
+16 -8
View File
@@ -330,18 +330,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
) )
// A single version of an invention (`?inventionId=…&version=…`) — the bare // A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads. Public. // RRInventionVersion, which carries the blob name the client downloads and the
// Only the current version exists (nothing writes version history yet), so any // SHA-256 of that blob. Public. Only the current version exists (nothing writes
// other version number 404s rather than naming a blob that isn't there. // version history yet), so any other version number 404s rather than naming a
// blob that isn't there.
.get( .get(
'/api/inventions/v1/version', '/api/inventions/v1/version',
describeRoute({ describeRoute({
tags: ['Inventions'], tags: ['Inventions'],
summary: 'One version of an invention', summary: 'One version of an invention',
description: description:
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' + 'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
'Only the current version exists — nothing writes version history yet — so any ' + 'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
'other version number 404s rather than naming a blob that is not there.', '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: [ parameters: [
intQuery('inventionId', 'Invention id; required'), intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required'), intQuery('version', 'Version number; required'),
@@ -358,7 +361,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10) const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400) 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) return version === null ? c.notFound() : c.json(version)
} }
) )
@@ -704,7 +712,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ error: 'inventionDataFilename is required' }, 400) 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, creatorPlayerId: id,
inventionDataFilename, inventionDataFilename,
name: str(body.name), name: str(body.name),
+53 -1
View File
@@ -110,6 +110,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
} }
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
async function base64Sha256(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
}
describe('public endpoints', () => { describe('public endpoints', () => {
test('GET /api/config/v1/amplitude', async () => { test('GET /api/config/v1/amplitude', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`) 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 () => { 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`, { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST', method: 'POST',
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' }, headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
@@ -777,7 +789,8 @@ describe('public endpoints', () => {
}) })
const { Invention } = (await save.json()) as InventionSaveResult 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( const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1` `${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
) )
@@ -786,6 +799,7 @@ describe('public endpoints', () => {
InventionId: Invention.InventionId, InventionId: Invention.InventionId,
VersionNumber: 1, VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv', BlobName: '2026-07-12/lamp.inv',
BlobHash: await base64Sha256(data),
InstantiationCost: 42, InstantiationCost: 42,
}) })
@@ -808,6 +822,44 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400) 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<Record<string, unknown>> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
)
return (await res.json()) as Record<string, unknown>
}
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 () => { test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST', method: 'POST',
+7 -1
View File
@@ -19,11 +19,17 @@
} }
], ],
// Image bucket shared with the `img` worker (which serves objects back by key). // 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": [ "r2_buckets": [
{ {
"binding": "IMAGES", "binding": "IMAGES",
"bucket_name": "recflare-img" "bucket_name": "recflare-img"
},
{
"binding": "CDN_ASSETS",
"bucket_name": "recflare-cdn"
} }
], ],
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by // Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
+7 -1
View File
@@ -150,8 +150,14 @@ const app = new Hono<App>()
// does the extension, which is why it goes on the key, not just the name. // does the extension, which is why it goes on the key, not just the name.
const datePrefix = new Date().toISOString().slice(0, 10) const datePrefix = new Date().toISOString().slice(0, 10)
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}` 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' }, 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 }) return c.json({ filename })
} }