Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/admin-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { handleMetricsRequest } from '@internal/monitoring/otel-metrics'
import { getGlobal } from '@platformatic/globals'
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify'
import { getConfig } from './config'
import { plugins, routes, setErrorHandler } from './http'
import { plugins, routes, schemas, setErrorHandler } from './http'
import {
createOpenApiTransform,
dedupeTrailingSlashPaths,
nameSchemaByDollarId,
} from './http/routes/openapi-transform'

interface buildOpts extends FastifyServerOptions {
exposeDocs?: boolean
Expand All @@ -19,6 +24,9 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
if (opts.exposeDocs) {
app.register(fastifySwagger, {
exposeHeadRoutes: true,
transform: createOpenApiTransform(),
transformObject: dedupeTrailingSlashPaths,
refResolver: { buildLocalReference: nameSchemaByDollarId },
openapi: {
info: {
title: 'Supabase Storage Admin API',
Expand Down Expand Up @@ -53,6 +61,8 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
})
}

app.addSchema(schemas.errorSchema)

app.register(plugins.requestContext)
app.register(plugins.signals)
app.register(plugins.adminTenantId)
Expand Down
20 changes: 19 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import fastifySwagger from '@fastify/swagger'
import fastifySwaggerUi from '@fastify/swagger-ui'
import { bucketSchema, objectSchema } from '@storage/schemas'
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify'
import { getConfig } from './config'
import { plugins, routes, schemas, setErrorHandler } from './http'
import {
createOpenApiTransform,
dedupeTrailingSlashPaths,
nameSchemaByDollarId,
} from './http/routes/openapi-transform'

interface buildOpts extends FastifyServerOptions {
exposeDocs?: boolean
Expand All @@ -26,6 +32,9 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
if (opts.exposeDocs) {
app.register(fastifySwagger, {
exposeHeadRoutes: true,
transform: createOpenApiTransform(),
transformObject: dedupeTrailingSlashPaths,
refResolver: { buildLocalReference: nameSchemaByDollarId },
openapi: {
info: {
title: 'Supabase Storage API',
Expand All @@ -44,7 +53,14 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
tags: [
{ name: 'object', description: 'Object end-points' },
{ name: 'bucket', description: 'Bucket end-points' },
{ name: 's3', description: 'S3 end-points' },
{
name: 's3',
description:
'S3-compatible protocol. Not enumerated here: each operation is dispatched ' +
'by query string/header on a handful of shared routes, which OpenAPI cannot ' +
'express as distinct operations. See src/http/routes/s3/commands for the ' +
'per-command request/response contract, or use any S3 SDK against this endpoint.',
},
{ name: 'transformation', description: 'Image transformation' },
{ name: 'resumable', description: 'Resumable Upload end-points' },
{ name: 'cdn', description: 'CDN cache management' },
Expand Down Expand Up @@ -72,6 +88,8 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
// add in common schemas
app.addSchema(schemas.authSchema)
app.addSchema(schemas.errorSchema)
app.addSchema(bucketSchema)
app.addSchema(objectSchema)

app.register(plugins.requestContext)
app.register(plugins.signals)
Expand Down
43 changes: 43 additions & 0 deletions src/http/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,51 @@ export const setErrorHandler = (
options?: {
respectStatusCode?: boolean
formatter?: (error: StorageError) => Record<string, unknown>
// JSON schema fragment matching what `formatter` actually produces - required alongside
// `formatter` to keep the doc default (below) truthful for a subtree with a reshaped
// error body, e.g. iceberg/index.ts's REST-catalog-spec {error: {message, type, code}}.
errorResponseSchema?: object
}
) => {
// registerJwtAuth's onRoute hook documents a flat {statusCode, error, message, code} 403 on
// every route it guards, since that's what a real AccessDenied error always renders as - true
// for every subtree except one with a custom `formatter` here that reshapes the wire response
// entirely (e.g. iceberg/index.ts's REST-catalog-spec {error: {message, type, code}}). Without
// this override, that subtree's routes would keep documenting (and, worse, get response-
// schema-serialized against) the wrong flat shape: fast-json-stringify chokes on the type
// mismatch (error typed as a string in errorSchema, sent as an object) and 500s. This hook
// runs after registerJwtAuth's (added earlier in every caller of this file) so it overrides
// rather than only filling a gap.
//
// There's deliberately no equivalent generic default for the plain (no formatter) case: this
// codebase has many handlers that reply with an ad-hoc, partial error body directly
// (`reply.status(400).send({message: '...'})`, bypassing this file's formatter entirely), and
// forcing errorSchema's `required` fields onto those routes' response serialization makes
// fast-json-stringify throw on the ones actually missing a field - a real, previously-hit
// regression (see git history). The generic "errorSchema is probably the shape of a 4xx"
// default lives in openapi-transform.ts's doc-only transform instead, which can't affect
// real request handling either way.
if (options?.formatter && options.errorResponseSchema) {
app.addHook('onRoute', (routeOptions) => {
routeOptions.schema = routeOptions.schema || {}
const hadResponseSchema = Boolean(routeOptions.schema.response)
const existingResponse = { ...(routeOptions.schema.response as object | undefined) }
// Drop any status-specific entry an ancestor already added (registerJwtAuth's 403,
// a shallower setErrorHandler's 4xx) - all of them assumed the flat errorSchema shape,
// which this subtree's formatter doesn't produce, so none of them are still accurate.
for (const status of Object.keys(existingResponse)) {
if (status !== '200') {
delete (existingResponse as Record<string, unknown>)[status]
}
}
routeOptions.schema.response = {
...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }),
...existingResponse,
'4xx': { description: 'Error response', ...options.errorResponseSchema },
}
})
}

app.setErrorHandler<Error>(function (error, request, reply) {
const formatter = options?.formatter || ((e) => e)
// We assign the error received.
Expand Down
12 changes: 12 additions & 0 deletions src/http/plugins/apikey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ export function registerApiKeyAuth(fastify: FastifyInstance) {
fastify.addHook('onRoute', (routeOptions) => {
routeOptions.schema = routeOptions.schema || {}
routeOptions.schema.security = [{ apiKeyAuth: [] }]

// Every route behind this plugin can reject with an empty-bodied 401 when the
// `apikey` header is missing or doesn't match one of the configured admin API keys.
// Routes that don't declare their own response schema fall back to @fastify/swagger's
// own "200: Default Response" placeholder - preserve that instead of losing it, since
// setting `schema.response` at all opts a route out of that fallback.
const hadResponseSchema = Boolean(routeOptions.schema.response)
routeOptions.schema.response = {
...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }),
401: { description: 'Missing or invalid API key' },
...(routeOptions.schema.response as object | undefined),
}
})
fastify.register(apiKeyPlugin)
}
12 changes: 12 additions & 0 deletions src/http/plugins/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export function registerJwtAuth(fastify: FastifyInstance, opts: JWTPluginOptions
fastify.addHook('onRoute', (routeOptions) => {
routeOptions.schema = routeOptions.schema || {}
routeOptions.schema.security = [{ bearerAuth: [] }]

// Every route behind this plugin can reject with 403 (invalid/missing JWT, or -
// when enforceJwtRoles is set - an authenticated role without the required role).
// Routes that don't declare their own response schema fall back to @fastify/swagger's
// own "200: Default Response" placeholder - preserve that instead of losing it, since
// setting `schema.response` at all opts a route out of that fallback.
const hadResponseSchema = Boolean(routeOptions.schema.response)
routeOptions.schema.response = {
...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }),
403: { description: 'Access denied', $ref: 'errorSchema#' },
...(routeOptions.schema.response as object | undefined),
}
})
fastify.register(jwtPlugin, opts)
}
2 changes: 2 additions & 0 deletions src/http/routes/bucket/createBucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export default async function routes(fastify: FastifyInstance) {
allowUnionTypes: true,
body: createBucketBodySchema,
summary,
description:
'The bucket id defaults to the given name when not provided, and the owner is set from the authenticated caller',
tags: ['bucket'],
})
fastify.post<createBucketRequestInterface>(
Expand Down
1 change: 1 addition & 0 deletions src/http/routes/bucket/deleteBucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
params: deleteBucketParamsSchema,
summary,
description: 'Fails if the bucket still contains objects, so it must be emptied first',
tags: ['bucket'],
})

Expand Down
2 changes: 2 additions & 0 deletions src/http/routes/bucket/emptyBucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
params: emptyBucketParamsSchema,
summary,
description:
'Queues asynchronous deletion of all objects inside the bucket without deleting the bucket itself, which may take up to an hour to complete',
tags: ['bucket'],
})

Expand Down
5 changes: 3 additions & 2 deletions src/http/routes/bucket/getAllBuckets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { isClientVersionBefore } from '@storage/limits'
import { bucketSchema } from '@storage/schemas'
import { FastifyInstance } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'
import { createDefaultSchema } from '../../routes-helper'
Expand All @@ -8,7 +7,7 @@ import { ROUTE_OPERATIONS } from '../operations'

const successResponseSchema = {
type: 'array',
items: bucketSchema,
items: { $ref: 'bucketSchema#' },
examples: [
[
{
Expand Down Expand Up @@ -46,6 +45,8 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
querystring: requestQuerySchema,
summary,
description:
'Returns buckets owned by the authenticated user, supporting pagination via limit/offset plus sorting and search filters',
tags: ['bucket'],
})

Expand Down
5 changes: 3 additions & 2 deletions src/http/routes/bucket/getBucket.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { bucketSchema } from '@storage/schemas'
import { FastifyInstance } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'
import { createDefaultSchema } from '../../routes-helper'
Expand All @@ -13,7 +12,7 @@ const getBucketParamsSchema = {
required: ['bucketId'],
} as const

const successResponseSchema = bucketSchema
const successResponseSchema = { $ref: 'bucketSchema#' }
interface getBucketRequestInterface extends AuthenticatedRequest {
Params: FromSchema<typeof getBucketParamsSchema>
}
Expand All @@ -23,6 +22,8 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
params: getBucketParamsSchema,
summary,
description:
'Requires the caller to have access to the bucket via RLS policies, unlike the public bucket listing',
tags: ['bucket'],
})
fastify.get<getBucketRequestInterface>(
Expand Down
7 changes: 2 additions & 5 deletions src/http/routes/bucket/updateBucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ const updateBucketBodySchema = {
items: { type: 'string', examples: [['image/png', 'image/jpg']] },
},
},
anyOf: [
{ required: ['public'] },
{ required: ['file_size_limit'] },
{ required: ['allowed_mime_types'] },
],
} as const
const updateBucketParamsSchema = {
type: 'object',
Expand All @@ -52,6 +47,8 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
body: updateBucketBodySchema,
summary,
description:
'Requires at least one of public, file_size_limit or allowed_mime_types in the body, and only the fields provided are changed',
tags: ['bucket'],
})
fastify.put<updateBucketRequestInterface>(
Expand Down
6 changes: 6 additions & 0 deletions src/http/routes/cdn/purgeCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export default async function routes(fastify: FastifyInstance) {
schema: createDefaultSchema(successResponseSchema, {
querystring: purgeQuerySchema,
summary: 'Purge cache for entire tenant or tenant transformations',
description:
'Pass transformations=true in the query string to purge only cached image transformation renditions instead of the whole tenant cache',
tags: ['cdn'],
}),
config: {
Expand All @@ -83,6 +85,8 @@ export default async function routes(fastify: FastifyInstance) {
params: purgeBucketParamsSchema,
querystring: purgeQuerySchema,
summary: 'Purge cache for an entire bucket or bucket transformations',
description:
'Pass transformations=true in the query string to purge only cached image transformation renditions for the bucket instead of all of its cached responses',
tags: ['cdn'],
}),
config: {
Expand Down Expand Up @@ -111,6 +115,8 @@ export default async function routes(fastify: FastifyInstance) {
params: purgeObjectParamsSchema,
querystring: purgeQuerySchema,
summary: 'Purge cache for an object or object transformations',
description:
'Pass transformations=true in the query string to purge only cached image transformation renditions of the object instead of its plain cached response',
tags: ['cdn'],
}),
config: {
Expand Down
3 changes: 3 additions & 0 deletions src/http/routes/health/healthcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ export default async function routes(fastify: FastifyInstance) {
'/',
{
schema: {
operationId: 'healthcheck',
summary,
description:
'Checks database connectivity and always responds with HTTP 200, reporting healthy: false in the body rather than an error status when the check fails',
tags: ['health'],
},
},
Expand Down
15 changes: 15 additions & 0 deletions src/http/routes/iceberg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export default async function routes(fastify: FastifyInstance) {
},
}
},
errorResponseSchema: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
message: { type: 'string' },
type: { type: 'string' },
code: { type: 'number' },
},
required: ['message', 'type', 'code'],
},
},
required: ['error'],
},
})
})
}
2 changes: 2 additions & 0 deletions src/http/routes/object/copyObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
body: copyRequestBodySchema,
summary,
description:
'Copies metadata along with the object content by default (override via copyMetadata), can target a different destinationBucket, and requires x-upsert to overwrite an existing destination key',
tags: ['object'],
})

Expand Down
2 changes: 2 additions & 0 deletions src/http/routes/object/createObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
params: createObjectParamsSchema,
summary,
description:
'Fails if an object already exists at the given key unless the x-upsert header is set to true',
tags: ['object'],
})

Expand Down
2 changes: 2 additions & 0 deletions src/http/routes/object/deleteObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export default async function routes(fastify: FastifyInstance) {
const schema = createDefaultSchema(successResponseSchema, {
params: deleteObjectParamsSchema,
summary,
description:
'Deletes a single object by its exact key, unlike the bulk-delete endpoint which accepts multiple prefixes',
tags: ['object'],
})

Expand Down
5 changes: 3 additions & 2 deletions src/http/routes/object/deleteObjects.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { DELETE_OBJECTS_LIMIT_DESCRIPTION, enforceDeleteObjectsLimit } from '@storage/limits'
import { objectSchema } from '@storage/schemas/object'
import { FastifyInstance, FastifyRequest } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'
import { createDefaultSchema } from '../../routes-helper'
Expand Down Expand Up @@ -28,7 +27,7 @@ const deleteObjectsBodySchema = {
} as const
const successResponseSchema = {
type: 'array',
items: objectSchema,
items: { $ref: 'objectSchema#' },
}
interface deleteObjectsInterface extends AuthenticatedRequest {
Params: FromSchema<typeof deleteObjectsParamsSchema>
Expand All @@ -42,6 +41,8 @@ export default async function routes(fastify: FastifyInstance) {
body: deleteObjectsBodySchema,
params: deleteObjectsParamsSchema,
summary,
description:
'Accepts a list of object prefixes to delete in one request, capped by a configurable per-request limit',
tags: ['object'],
})

Expand Down
2 changes: 2 additions & 0 deletions src/http/routes/object/getObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export default async function routes(fastify: FastifyInstance) {
querystring: getObjectQuerySchema,
headers: { $ref: 'authSchema#' },
summary,
description:
'Requires a valid auth token and checks bucket/object access via RLS, regardless of whether the bucket is public',
response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } },
tags: ['object'],
},
Expand Down
Loading