diff --git a/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts b/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts index 11d79507..e5379c98 100644 --- a/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts +++ b/layers/artifact-delivery/server/api/v1/resolutions/index.post.ts @@ -19,6 +19,7 @@ import { readsLinkedFiles, requestResolution, } from '../../../utils/request-resolution' +import { serveReadyResolution } from '../../../utils/served-resolution' import { setSkillPageUrlHeader } from '../../../utils/skill-page' import { getResolution } from '../../../utils/state' @@ -42,6 +43,7 @@ export default withArtifactProblems(defineApiHandler({ body.source.repository, ) : { _tag: 'not-found' as const } + const build = createArtifactBuildDependencies(platform.env) const result = await requestResolution({ db: platform.db, lookupAdmitted: fetchAdmittedSkillIdentity(event.context), @@ -68,6 +70,15 @@ export default withArtifactProblems(defineApiHandler({ }, }), now: () => Math.floor(Date.now() / 1000), + // A warm run takes the ready Resolution of its commit and skips the + // queue and the build. + serveReady: serveReadyResolution({ + db: platform.db, + bucket: build.bucket, + trustedRoot: build.trustedRoot, + now: build.now, + resolveOnGithub: source => build.github.resolve(source), + }), }, { source: body.source, idempotencyKey, diff --git a/layers/artifact-delivery/server/utils/build.ts b/layers/artifact-delivery/server/utils/build.ts index a7c1848e..5156b7ad 100644 --- a/layers/artifact-delivery/server/utils/build.ts +++ b/layers/artifact-delivery/server/utils/build.ts @@ -114,7 +114,7 @@ export const LEADING_BUILD_WAIT_SECONDS = 2 * root and still stored in R2. Its files and check results come from its signed * attestation, never from unsigned columns. */ -interface ReusableBuild { +export interface ReusableBuild { resolutionId: string source: ResolvedSource artifactId: string @@ -125,7 +125,7 @@ interface ReusableBuild { linkedFiles: LinkedArtifactFile[] } -type ReuseDecision +export type ReuseDecision = { _tag: 'hit', build: ReusableBuild } | { _tag: 'miss', reason: ReuseMissReason } @@ -675,8 +675,8 @@ async function decideReuse( * - the statement matches the stored row; * - R2 still holds the exact bytes. */ -async function findReusableBuild( - dependencies: ArtifactBuildDependencies, +export async function findReusableBuild( + dependencies: Pick, lookup: ReadyBuildLookup, linkedFiles: boolean, ): Promise { diff --git a/layers/artifact-delivery/server/utils/request-resolution.ts b/layers/artifact-delivery/server/utils/request-resolution.ts index 32b7cae7..0bb6ada3 100644 --- a/layers/artifact-delivery/server/utils/request-resolution.ts +++ b/layers/artifact-delivery/server/utils/request-resolution.ts @@ -2,6 +2,7 @@ import type { H3EventContext } from 'h3' import type { SourceRequest } from '../schemas/contracts' import type { FetchAdmittedSkillIdentity } from './admitted-identity' import type { ArtifactBuildOutcome } from './build' +import type { ServedResolution } from './served-resolution' import type { CreateResolutionResult } from './state' import { admittedSourceRequest } from './admitted-identity' import { recordResolutionRequester } from './requester-github' @@ -13,6 +14,12 @@ export interface ResolutionRequestDependencies { lookupAdmitted: FetchAdmittedSkillIdentity enqueue: (resolutionId: string) => Promise now: () => number + /** + * Answer a public request from a ready build, or name the source to build. + * `POST /api/v1/resolutions` passes it. The run sweep does not, so every + * sweep check still runs the build. + */ + serveReady?: (source: SourceRequest, linkedFiles: boolean) => Promise } export type ResolutionAccess @@ -67,10 +74,16 @@ export async function requestResolution( // A private build reads blobs and never links a file. const linkedFiles = input.access.visibility === 'public' && input.linkedFiles === true const identity = await resolutionRequestIdentity(input.source, input.idempotencyKey, accountId, linkedFiles) - const source = input.access.visibility === 'private' + const admitted = input.access.visibility === 'private' ? input.source : await admittedSourceRequest(input.source, dependencies.lookupAdmitted) - const result = await createResolution(dependencies.db, source, identity, dependencies.now(), input.access, linkedFiles) + // A ready build serves only a CLI that reads what it holds (see `findReusableBuild`). + const served: ServedResolution = input.access.visibility === 'public' && dependencies.serveReady + ? await dependencies.serveReady(admitted, linkedFiles) + : { _tag: 'build', source: admitted } + if (served._tag === 'served') + return served + const result = await createResolution(dependencies.db, served.source, identity, dependencies.now(), input.access, linkedFiles) if (result._tag === 'created' && input.access.visibility === 'public' && input.requesterAccountId) { await recordResolutionRequester(dependencies.db, { resolutionId: result.row.id, diff --git a/layers/artifact-delivery/server/utils/served-resolution.ts b/layers/artifact-delivery/server/utils/served-resolution.ts new file mode 100644 index 00000000..e50f1536 --- /dev/null +++ b/layers/artifact-delivery/server/utils/served-resolution.ts @@ -0,0 +1,139 @@ +import type { SourceRequest } from '../schemas/contracts' +import type { ReuseMissReason } from './build' +import type { ResolveSourceResult } from './github-source' +import type { ResolutionRow } from './state' +import type { TrustedRoot } from './trusted-root' +import { createWideEvent } from '@harlan-zw/nuxt-wide-events/standalone' +import { emitOperationalEvent } from '#server/utils/operational-event' +import { findReusableBuild } from './build' +import { getResolution } from './state' + +/** + * How long a Resolution request waits for GitHub to name the commit of a + * branch. A slower answer builds the request as sent, and the build resolves + * the branch itself. + */ +export const SERVE_RESOLVE_TIMEOUT_MS = 2_500 + +export interface ServedResolutionDependencies { + db: D1Database + bucket: R2Bucket + trustedRoot: TrustedRoot + /** Unix seconds. */ + now: () => number + /** Resolve a public request that names no commit, as the build would. */ + resolveOnGithub: (source: SourceRequest) => Promise + resolveTimeoutMs?: number + /** Receives each decision. The default emits the `artifact-resolution-served` wide event. */ + report?: (report: ServedResolutionReport) => void +} + +export type ServedResolution + = | { _tag: 'served', row: ResolutionRow } + /** No ready build fits. Build `source`, pinned to its commit when GitHub named one. */ + | { _tag: 'build', source: SourceRequest } + +export type ServedResolutionReport + = | { _tag: 'hit', reusedFrom: string } + | { _tag: 'miss', reason: ReuseMissReason | 'github-unresolved' | 'github-timeout' | 'not-ready' } + +/** + * Answer a public request from the ready build of the same Repository, + * commit, Skill folder and policy, with no queue and no build. + * + * A warm run used to request a new Resolution, wait for the queue, and wait + * again while the build re-signed the same bytes: 5.6 to 9.2 s in production + * on 2026-10-07. The ready Resolution answers it now, exactly as `GET + * /api/v1/resolutions/:id` would. It passes every check a reused build passes: + * the trusted root verifies its attestation, it names the current policy, its + * check results pass, and R2 still holds its bytes. + * + * A request that names no commit resolves on GitHub first. When no ready + * build fits, the build gets the request pinned to that commit and folder, as + * a registry name already is, so it reads no branch again. + */ +export function serveReadyResolution( + dependencies: ServedResolutionDependencies, +): (source: SourceRequest, linkedFiles: boolean) => Promise { + const report = dependencies.report ?? emitServedEvent + return async (source, linkedFiles) => { + const pinned = await pinToCommit(dependencies, source) + if (pinned._tag !== 'pinned') { + report({ _tag: 'miss', reason: pinned._tag }) + return { _tag: 'build', source } + } + const decision = await findReusableBuild(dependencies, { + _tag: 'pinned', + owner: pinned.source.owner, + repository: pinned.source.repository, + commitSha: pinned.commitSha, + selector: pinned.source.selector, + }, linkedFiles) + if (decision._tag === 'miss') { + report({ _tag: 'miss', reason: decision.reason }) + return { _tag: 'build', source: pinned.source } + } + const row = await getResolution(dependencies.db, decision.build.resolutionId) + if (row?.state !== 'ready') { + report({ _tag: 'miss', reason: 'not-ready' }) + return { _tag: 'build', source: pinned.source } + } + report({ _tag: 'hit', reusedFrom: row.id }) + return { _tag: 'served', row } + } +} + +type PinnedRequest + = | { _tag: 'pinned', source: SourceRequest, commitSha: string } + | { _tag: 'github-unresolved' | 'github-timeout' } + +async function pinToCommit( + dependencies: ServedResolutionDependencies, + source: SourceRequest, +): Promise { + if (source.ref?.type === 'commit') + return { _tag: 'pinned', source, commitSha: source.ref.value } + let timer: ReturnType | undefined + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(resolve, dependencies.resolveTimeoutMs ?? SERVE_RESOLVE_TIMEOUT_MS, 'timeout') + }) + const answer = await Promise.race([ + dependencies.resolveOnGithub(source).catch(() => { + // Safe to drop: the build reads GitHub again with its own retries, + // and reports the failure it finds. + return null + }), + timeout, + ]).finally(() => clearTimeout(timer)) + if (answer === 'timeout') + return { _tag: 'github-timeout' } + if (answer?._tag !== 'resolved' || answer.source.visibility !== 'public') + return { _tag: 'github-unresolved' } + return { + _tag: 'pinned', + commitSha: answer.source.commitSha, + source: { + provider: source.provider, + owner: source.owner, + repository: source.repository, + selector: { type: 'path', path: answer.source.skillPath }, + ref: { type: 'commit', value: answer.source.commitSha }, + }, + } +} + +function emitServedEvent(report: ServedResolutionReport): void { + if (report._tag === 'hit') { + emitOperationalEvent(createWideEvent({ + 'operation': 'artifact-resolution-served', + 'outcome': 'hit', + 'artifact.reusedFrom': report.reusedFrom, + }), 'info') + return + } + emitOperationalEvent(createWideEvent({ + operation: 'artifact-resolution-served', + outcome: 'miss', + reason: report.reason, + }), 'info') +} diff --git a/layers/artifact-delivery/server/utils/state.ts b/layers/artifact-delivery/server/utils/state.ts index b73c4aed..9f4bca9d 100644 --- a/layers/artifact-delivery/server/utils/state.ts +++ b/layers/artifact-delivery/server/utils/state.ts @@ -161,8 +161,12 @@ export async function resolutionRequestIdentity(source: SourceRequest, idempoten } } +/** + * `served` is a ready Resolution that answers a new request for the same + * Repository, commit, Skill folder and policy. See `serveReadyResolution`. + */ export type CreateResolutionResult - = { _tag: 'created' | 'existing', row: ResolutionRow } + = { _tag: 'created' | 'existing' | 'served', row: ResolutionRow } | { _tag: 'idempotency-conflict' } export async function createResolution( diff --git a/test/unit/artifact-served-resolution.test.ts b/test/unit/artifact-served-resolution.test.ts new file mode 100644 index 00000000..df731333 --- /dev/null +++ b/test/unit/artifact-served-resolution.test.ts @@ -0,0 +1,368 @@ +import type { ArtifactAttestationStatement, ResolvedSource, SourceRequest } from '../../layers/artifact-delivery/server/schemas/contracts' +import type { ArtifactBuildDependencies } from '../../layers/artifact-delivery/server/utils/build' +import type { ArtifactSourceFile, PublicGithubSourceClient, ResolveSourceResult } from '../../layers/artifact-delivery/server/utils/github-source' +import type { ResolutionAccess } from '../../layers/artifact-delivery/server/utils/request-resolution' +import type { ServedResolutionReport } from '../../layers/artifact-delivery/server/utils/served-resolution' +import type { TrustedRoot } from '../../layers/artifact-delivery/server/utils/trusted-root' +import type { ArtifactSignerBindings } from '../../workers/artifact-signer/src/handler' +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { artifactAttestationSchema, createResolutionResponseSchema } from '../../layers/artifact-delivery/server/schemas/contracts' +import { + completeAttestation, + createArtifactSigner, + createAttestationSignaturePayload, + encodeAttestationStatement, +} from '../../layers/artifact-delivery/server/utils/attestation' +import { processArtifactBuild } from '../../layers/artifact-delivery/server/utils/build' +import { bytesToBase64Url } from '../../layers/artifact-delivery/server/utils/encoding' +import { requestResolution } from '../../layers/artifact-delivery/server/utils/request-resolution' +import { serveReadyResolution } from '../../layers/artifact-delivery/server/utils/served-resolution' +import { getResolution, presentResolution } from '../../layers/artifact-delivery/server/utils/state' +import { handleArtifactSignerRequest } from '../../workers/artifact-signer/src/handler' +import { loadedFromFiles } from '../fixtures/loaded-source' +import { createSqliteD1 } from './helpers/d1-sqlite' + +const NOW = 1_787_227_200 +const MIGRATIONS = [ + 'migrations/0017_users.sql', + 'migrations/0110_artifact_delivery.sql', + 'migrations/0111_github_app_delivery.sql', + 'migrations/0112_private_artifact_keys.sql', + 'migrations/0122_artifact_resolution_retry_after.sql', + 'migrations/0131_artifact_build_reuse.sql', + 'migrations/0144_artifact_resolution_requesters.sql', + 'migrations/0145_artifact_resolution_linked_files.sql', +] +const COMMIT = '0123456789abcdef0123456789abcdef01234567' +const resolved: ResolvedSource = { + provider: 'github', + repositoryId: 123456789, + owner: 'skilld-dev', + repository: 'skills', + visibility: 'public', + commitSha: COMMIT, + treeSha: '89abcdef0123456789abcdef0123456789abcdef', + skillPath: 'skills/demo', +} +const pinned: SourceRequest = { + provider: 'github', + owner: 'skilld-dev', + repository: 'skills', + selector: { type: 'path', path: 'skills/demo' }, + ref: { type: 'commit', value: COMMIT }, +} +const onBranch: SourceRequest = { ...pinned, ref: { type: 'branch', value: 'main' } } +const byName: SourceRequest = { + provider: 'github', + owner: 'skilld-dev', + repository: 'skills', + selector: { type: 'named-skill', name: 'demo' }, +} +const files: ArtifactSourceFile[] = [{ + path: 'SKILL.md', + mode: 420, + bytes: new TextEncoder().encode('---\nname: demo\ndescription: Use this Skill for demo work.\n---\n\n# Demo\n'), + gitBlobSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', +}] +const PUBLIC: ResolutionAccess = { visibility: 'public' } + +describe('serving a warm run from its ready build', () => { + it('answers a request pinned to a built commit with the ready Resolution, and queues nothing', async () => { + const harness = await createHarness() + const first = await harness.buildReady(pinned) + const enqueue = vi.fn(async () => {}) + + const result = await harness.request(pinned, { enqueue }) + + expect(result._tag).toBe('served') + if (result._tag !== 'served') + throw new Error('not served') + expect(result.row.id).toBe(first.id) + expect(enqueue).not.toHaveBeenCalled() + const answer = createResolutionResponseSchema.parse(presentResolution(result.row)) + expect(answer.state).toBe('ready') + expect(harness.unbuilt()).toBe(0) + harness.close() + }) + + it('serves a registry name from its admitted folder and commit, with no GitHub read', async () => { + const harness = await createHarness() + await harness.buildReady(pinned) + const github = vi.fn(async (): Promise => ({ _tag: 'resolved', source: resolved })) + + const result = await harness.request(byName, { + admitted: { skillPath: 'skills/demo', commitSha: COMMIT }, + resolveOnGithub: github, + }) + + expect(result._tag).toBe('served') + expect(github).not.toHaveBeenCalled() + harness.close() + }) + + it('resolves a branch to its commit on GitHub, then serves the ready build of that commit', async () => { + const harness = await createHarness() + const first = await harness.buildReady(pinned) + const github = vi.fn(async (): Promise => ({ _tag: 'resolved', source: resolved })) + + const result = await harness.request(onBranch, { resolveOnGithub: github }) + + expect(github).toHaveBeenCalledWith(onBranch) + expect(result._tag === 'served' && result.row.id).toBe(first.id) + harness.close() + }) + + it('builds a resolved branch pinned to its commit when no ready build exists', async () => { + const harness = await createHarness() + const enqueue = vi.fn(async () => {}) + + const result = await harness.request(onBranch, { + enqueue, + resolveOnGithub: async () => ({ _tag: 'resolved', source: resolved }), + }) + + expect(result._tag).toBe('created') + if (result._tag !== 'created') + throw new Error('not created') + expect(result.row).toMatchObject({ ref_type: 'commit', ref_value: COMMIT, selector_type: 'path', selector_value: 'skills/demo' }) + expect(enqueue).toHaveBeenCalledWith(result.row.id) + expect(harness.reports).toContainEqual({ _tag: 'miss', reason: 'no-ready-build' }) + harness.close() + }) + + it('builds the request as sent when GitHub cannot resolve it in time', async () => { + const harness = await createHarness() + await harness.buildReady(pinned) + + const result = await harness.request(onBranch, { + resolveOnGithub: () => new Promise(() => {}), + resolveTimeoutMs: 5, + }) + + expect(result._tag).toBe('created') + if (result._tag !== 'created') + throw new Error('not created') + expect(result.row).toMatchObject({ ref_type: 'branch', ref_value: 'main' }) + expect(harness.reports).toContainEqual({ _tag: 'miss', reason: 'github-timeout' }) + harness.close() + }) + + it('does not serve a build signed under another policy', async () => { + const harness = await createHarness() + const first = await harness.buildReady(pinned) + await harness.resign(first.id, statement => ({ ...statement, policyVersion: '2026-01-01.1' })) + + const result = await harness.request(pinned) + + expect(result._tag).toBe('created') + expect(harness.reports).toContainEqual({ _tag: 'miss', reason: 'policy-changed' }) + harness.close() + }) + + it('does not serve a ready build whose bytes left R2', async () => { + const harness = await createHarness() + const first = await harness.buildReady(pinned) + harness.storage.remove(first.r2_key!) + + const result = await harness.request(pinned) + + expect(result._tag).toBe('created') + expect(harness.reports).toContainEqual({ _tag: 'miss', reason: 'bytes-missing' }) + harness.close() + }) + + it('never serves a private request', async () => { + const harness = await createHarness() + await harness.buildReady(pinned) + harness.raw.prepare('INSERT INTO users (id, github_id, login, created_at, last_login_at) VALUES (1, 1, ?, ?, ?)').run('private-owner', NOW, NOW) + const serve = vi.fn() + + const result = await requestResolution({ + db: harness.db, + lookupAdmitted: async () => null, + enqueue: async () => {}, + now: () => NOW, + serveReady: serve, + }, { + source: pinned, + idempotencyKey: 'served-private-request-key', + access: { visibility: 'private', accountId: 1, installationId: 2, repositoryId: 3 }, + }) + + expect(result._tag).toBe('created') + expect(serve).not.toHaveBeenCalled() + harness.close() + }) +}) + +async function createHarness() { + const sqlite = createSqliteD1(MIGRATIONS) + const storage = memoryBucket() + const signing = await signingKey('skilld-production-2026-08') + const reports: ServedResolutionReport[] = [] + let requests = 0 + const signerBindings: ArtifactSignerBindings = { + DB: sqlite.db, + PUBLIC_ARTIFACTS: storage.bucket, + PRIVATE_ARTIFACTS: storage.bucket, + ARTIFACT_SIGNING_MAX_AGE_SECONDS: '300', + ...signing.bindings, + } + const buildDependencies: ArtifactBuildDependencies = { + db: sqlite.db, + github: { + resolve: async () => ({ _tag: 'resolved', source: resolved }), + load: async () => ({ _tag: 'loaded', value: loadedFromFiles(resolved, files) }), + } satisfies PublicGithubSourceClient, + bucket: storage.bucket, + signer: createArtifactSigner({ + fetch: async (request: Request) => await handleArtifactSignerRequest(request, signerBindings, () => NOW), + } as unknown as Fetcher), + trustedRoot: signing.trustedRoot, + now: () => NOW, + reportReuse: () => {}, + } + + async function request( + source: SourceRequest, + options: { + enqueue?: (resolutionId: string) => Promise + admitted?: { skillPath: string, commitSha: string | null } + resolveOnGithub?: (source: SourceRequest) => Promise + resolveTimeoutMs?: number + } = {}, + ) { + requests += 1 + return await requestResolution({ + db: sqlite.db, + lookupAdmitted: async () => options.admitted ?? null, + enqueue: options.enqueue ?? (async () => {}), + now: () => NOW, + serveReady: serveReadyResolution({ + db: sqlite.db, + bucket: storage.bucket, + trustedRoot: signing.trustedRoot, + now: () => NOW, + resolveOnGithub: options.resolveOnGithub ?? (async () => ({ _tag: 'resolved', source: resolved })), + resolveTimeoutMs: options.resolveTimeoutMs, + report: report => reports.push(report), + }), + }, { source, idempotencyKey: `served-request-key-${requests}`, access: PUBLIC }) + } + + async function buildReady(source: SourceRequest) { + const created = await requestResolution({ + db: sqlite.db, + lookupAdmitted: async () => null, + enqueue: async () => {}, + now: () => NOW, + }, { source, idempotencyKey: `served-build-key-${requests++}`, access: PUBLIC }) + if (created._tag === 'idempotency-conflict') + throw new Error('Test Resolution conflicted') + await processArtifactBuild(buildDependencies, created.row.id) + const row = await getResolution(sqlite.db, created.row.id) + if (row?.state !== 'ready') + throw new Error(`Test build ended ${row?.state}`) + return row + } + + /** Replace a ready build's attestation with a changed statement the same key signs. */ + async function resign(resolutionId: string, edit: (statement: ArtifactAttestationStatement) => ArtifactAttestationStatement) { + const row = await getResolution(sqlite.db, resolutionId) + const { statement: _statement, signature: _signature, ...statement } = artifactAttestationSchema.parse(JSON.parse(row!.attestation_json!)) + const rawStatement = encodeAttestationStatement(edit(statement)) + const signature = await crypto.subtle.sign( + 'Ed25519', + signing.privateKey, + await createAttestationSignaturePayload(new TextEncoder().encode(rawStatement)), + ) + const attestation = completeAttestation(rawStatement, { + algorithm: 'Ed25519', + keyId: signing.keyId, + value: bytesToBase64Url(new Uint8Array(signature)), + }) + sqlite.raw.prepare('UPDATE artifact_resolutions SET attestation_json = ? WHERE id = ?').run(JSON.stringify(attestation), resolutionId) + } + + return { + db: sqlite.db, + raw: sqlite.raw, + storage, + reports, + request, + buildReady, + resign, + /** Resolutions still waiting for a build. */ + unbuilt: () => (sqlite.raw.prepare(`SELECT count(*) AS n FROM artifact_resolutions WHERE state = 'requested'`).get() as { n: number }).n, + close: sqlite.close, + } +} + +async function signingKey(keyId: string) { + const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) + const publicKey = bytesToBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey))) + const privateKey = bytesToBase64Url(new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey))) + const notBefore = new Date((NOW - 60) * 1000).toISOString() + const notAfter = new Date((NOW + 3600) * 1000).toISOString() + const trustedRoot: TrustedRoot = { + version: 1, + rootKeyId: 'skilld-root-2026', + rootPublicKey: publicKey, + keys: [{ + keyId, + algorithm: 'Ed25519', + publicKey, + notBefore, + notAfter, + status: 'active', + statement: bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ keyId }))), + rootSignature: 'C'.repeat(86), + }], + fetchedAt: new Date(NOW * 1000).toISOString(), + } + return { + keyId, + privateKey: keyPair.privateKey, + trustedRoot, + bindings: { + ARTIFACT_SIGNING_KEY_ID: keyId, + ARTIFACT_SIGNING_KEY_NOT_BEFORE: notBefore, + ARTIFACT_SIGNING_KEY_NOT_AFTER: notAfter, + ARTIFACT_SIGNING_PRIVATE_KEY_PKCS8: privateKey, + }, + } +} + +function memoryBucket() { + const objects = new Map }>() + const describeObject = (key: string) => { + const object = objects.get(key) + if (!object) + return null + const digest = createHash('sha256').update(object.bytes).digest() + return { + key, + size: object.bytes.byteLength, + checksums: { sha256: Uint8Array.from(digest).buffer }, + customMetadata: object.customMetadata, + } + } + const bucket = { + put: async (key: string, value: Uint8Array, options?: R2PutOptions) => { + if (objects.has(key)) + return null + objects.set(key, { bytes: Uint8Array.from(value), customMetadata: options?.customMetadata ?? {} }) + return describeObject(key) + }, + head: async (key: string) => describeObject(key), + get: async (key: string) => { + const object = describeObject(key) + const stored = objects.get(key) + return object && stored + ? { ...object, arrayBuffer: async () => Uint8Array.from(stored.bytes).buffer, body: new Blob([Uint8Array.from(stored.bytes)]).stream() } + : null + }, + } as unknown as R2Bucket + return { bucket, remove: (key: string) => objects.delete(key) } +}