How to add a new S3 API operation to the gateway
All S3 operations are implemented as Fastify route handlers in src/routes/index.ts. They share a common RouteContext containing metadataStore, synapseClient, localStore, and logger.
Check the AWS S3 API docs for:
If the operation involves new request/response types, add them to src/s3/types.ts:
// src/s3/types.ts
export interface NewOperationResponse {
// ...fields matching S3 XML output
}
If the operation returns XML, add a builder function in src/s3/xml.ts:
// src/s3/xml.ts
export function buildNewOperationXml(data: NewOperationResponse): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<NewOperationResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
...
</NewOperationResult>`
}
Remember to:
escapeXml() for all user-provided valuestoISO8601() for date fields from SQLiteIn src/routes/index.ts, inside registerRoutes():
// Pattern for bucket-level operations:
// app.get('/:bucket', handler) — matches GET /bucket (no key)
// app.put('/:bucket', handler) — matches PUT /bucket (no key)
// Pattern for object-level operations:
// app.get('/:bucket/*', handler) — matches GET /bucket/key/path
// app.put('/:bucket/*', handler) — matches PUT /bucket/key/path
// Extract params:
const { bucket } = request.params as { bucket: string }
const key = (request.params as { '*': string })['*']
const query = request.query as Record<string, string>
// Always validate bucket exists:
if (!metadataStore.bucketExists(bucket)) {
sendNoSuchBucket(reply, bucket)
return
}
Important: S3 overloads HTTP methods with query parameters. Distinguish operations by checking query params:
// Example: GET /bucket can be ListObjectsV2, GetBucketLocation, or GetBucketVersioning
if ('location' in query) { /* GetBucketLocation */ }
if ('versioning' in query) { /* GetBucketVersioning */ }
// Default: ListObjectsV2
If the operation needs new database queries, add methods to src/storage/metadata-store.ts:
// Use prepared statements for performance
someMethod(bucket: string, key: string): SomeType | undefined {
const stmt = this.db.prepare(`SELECT ... FROM objects WHERE bucket = ? AND key = ? AND deleted = 0`)
return stmt.get(bucket, key) as SomeType | undefined
}
// Use transactions for multi-statement operations
someTransaction(bucket: string): void {
const transaction = this.db.transaction(() => {
// Multiple statements here run atomically
})
transaction()
}
If the S3 operation has a WebDAV counterpart, implement it in src/webdav/routes.ts:
parseDavPath(request.url) to extract { bucket, key }metadataStore, localStore, synapseClient)Add tests in the appropriate test file:
src/s3/xml.test.tssrc/storage/metadata-store.test.tssrc/webdav/routes.test.ts// src/s3/xml.test.ts
describe('buildNewOperationXml', () => {
it('should produce valid XML', () => {
const xml = buildNewOperationXml({ /* test data */ })
expect(xml).toContain('<NewOperationResult')
// assert specific fields
})
})
npm run lint:fix && npm run typecheck && npm run test:unit
import { sendS3Error, sendNoSuchBucket, sendNoSuchKey, sendInternalError } from '../s3/errors.js'
// Common errors:
sendNoSuchBucket(reply, bucket) // 404
sendNoSuchKey(reply, key) // 404
sendInternalError(reply, 'message') // 500
sendS3Error(reply, 409, 'BucketAlreadyOwnedByYou', 'message', bucket) // custom
For operations that involve uploads:
Content-Length header first (fast reject)localStore.stageUpload(id, request.raw)metadataStore.stageObject() to queue for async uploadUploadWorker will handle the FOC upload in background.js extensions (from './foo.js')GET /bucket/ has empty key '' — handle as bucket-levelasNeeded)query['param'] due to noUncheckedIndexedAccess