From da286e9db30e53baa1917b882e933be922a322e1 Mon Sep 17 00:00:00 2001 From: "agent-kanban[bot]" <295243365+agent-kanban[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:30:22 -0400 Subject: [PATCH] feat: declare explicit route authorization policies (#535) Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5 Co-authored-by: Marina Zhou --- server/http/admin-overview.ts | 25 +- server/http/admin-stats.ts | 33 +- server/http/background-jobs.ts | 170 ++++++---- server/http/configz.ts | 27 +- server/http/downloads/downloaders.ts | 172 +++++----- server/http/image-hosting/config.ts | 85 ++--- server/http/image-hosting/images.ts | 161 +++++----- server/http/notifications.ts | 97 +++--- server/http/objects.ts | 196 +++++++----- server/http/openapi.ts | 11 +- server/http/quotas.ts | 52 +-- server/http/shares.ts | 280 ++++++++-------- server/http/site/announcements.ts | 138 ++++---- server/http/site/audit.ts | 27 +- server/http/site/auth-providers.ts | 83 ++--- server/http/site/branding.ts | 68 ++-- server/http/site/email-config.ts | 81 ++--- server/http/site/image-domain-provider.ts | 81 ++--- server/http/site/invitations.ts | 143 +++++---- server/http/site/invite-codes.ts | 108 ++++--- server/http/site/licensing.ts | 148 +++++---- server/http/site/settings.ts | 185 ++++++----- server/http/site/storages.ts | 197 +++++++----- server/http/site/system.ts | 48 +-- server/http/storage-usage.ts | 79 ++--- server/http/store/storefront.ts | 360 ++++++++++++--------- server/http/teams.ts | 374 ++++++++++++---------- server/http/trash.ts | 121 +++---- server/http/users.ts | 228 +++++++------ server/middleware/authz.ts | 7 + server/openapi.test.ts | 35 ++ 31 files changed, 2147 insertions(+), 1673 deletions(-) diff --git a/server/http/admin-overview.ts b/server/http/admin-overview.ts index 5d7602db..e2e6b86d 100644 --- a/server/http/admin-overview.ts +++ b/server/http/admin-overview.ts @@ -1,19 +1,22 @@ -import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { OpenAPIHono } from '@hono/zod-openapi' import { adminOverviewSchema } from '@shared/schemas' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' import { getAdminOverview } from '../usecases/admin-overview' -import { jsonContent } from './openapi' +import { authRoute, jsonContent } from './openapi' -const route = createRoute({ - operationId: 'getSiteAnalytics', - summary: 'Get site analytics', - tags: ['Site Analytics'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(adminOverviewSchema, 'Site analytics') }, -}) +const route = authRoute( + { access: 'admin' }, + { + operationId: 'getSiteAnalytics', + summary: 'Get site analytics', + tags: ['Site Analytics'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(adminOverviewSchema, 'Site analytics') }, + }, +) export const adminOverview = new OpenAPIHono().openapi(route, async (c) => c.json(await getAdminOverview(c.get('deps')), 200), diff --git a/server/http/admin-stats.ts b/server/http/admin-stats.ts index 4921fbe6..b9c8da40 100644 --- a/server/http/admin-stats.ts +++ b/server/http/admin-stats.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { adminAnalyticsGrowthSchema, adminAnalyticsOperationsSchema, @@ -19,7 +19,7 @@ import { getAdminDashboardStorageStats, getAdminDashboardTrafficStats, } from '../usecases/admin-stats' -import { jsonContent } from './openapi' +import { authRoute, jsonContent } from './openapi' const dashboardDateSchema = z .string() @@ -60,19 +60,22 @@ function analyticsRoute( schema: Parameters[0], gated = true, ) { - return createRoute({ - operationId, - summary, - tags: ['Site Analytics'], - method: 'get', - path, - middleware: (gated ? [requireAdmin, requireFeature('analytics')] : [requireAdmin]) as [ - typeof requireAdmin, - ...Array>, - ], - request: { query: rangeQuerySchema }, - responses: { 200: jsonContent(schema, summary) }, - }) + return authRoute( + { access: 'admin' }, + { + operationId, + summary, + tags: ['Site Analytics'], + method: 'get', + path, + middleware: (gated ? [requireAdmin, requireFeature('analytics')] : [requireAdmin]) as [ + typeof requireAdmin, + ...Array>, + ], + request: { query: rangeQuerySchema }, + responses: { 200: jsonContent(schema, summary) }, + }, + ) } const overviewRoute = analyticsRoute( diff --git a/server/http/background-jobs.ts b/server/http/background-jobs.ts index ae9d26eb..54789b78 100644 --- a/server/http/background-jobs.ts +++ b/server/http/background-jobs.ts @@ -1,4 +1,5 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' +import { AuthorizationScope } from '@shared/authorization' import { createBackgroundJobRequestSchema, cursorPageSchema, listBackgroundJobsQuerySchema } from '../../shared/schemas' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' @@ -11,7 +12,7 @@ import { retryBackgroundJob, } from '../usecases/background-job' import { BackgroundJobError, notFound } from '../usecases/ports' -import { errorResponse, jsonBody, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' import { createdAtIdCursorCodec, decodeOptionalPageToken, @@ -64,87 +65,116 @@ function requireOrg(c: { get(key: 'orgId'): string | null }): string { return orgId } -const listRoute = createRoute({ - operationId: 'listBackgroundJobs', - summary: 'List background jobs', - tags: ['Background Jobs'], - method: 'get', - path: '/', - request: { query: listBackgroundJobsQuerySchema }, - responses: { - 200: jsonContent(backgroundJobPageSchema, 'Background jobs'), - 404: errorResponse('No organization found'), +const listRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], }, -}) + { + operationId: 'listBackgroundJobs', + summary: 'List background jobs', + tags: ['Background Jobs'], + method: 'get', + path: '/', + request: { query: listBackgroundJobsQuerySchema }, + responses: { + 200: jsonContent(backgroundJobPageSchema, 'Background jobs'), + 404: errorResponse('No organization found'), + }, + }, +) -const createJobRoute = createRoute({ - operationId: 'createBackgroundJob', - summary: 'Create background job', - tags: ['Background Jobs'], - method: 'post', - path: '/', - request: jsonBody(createBackgroundJobRequestSchema), - responses: { - 201: jsonContent(backgroundJobSchema, 'Created background job'), - 404: errorResponse('Not found'), +const createJobRoute = authRoute( + { access: 'session' }, + { + operationId: 'createBackgroundJob', + summary: 'Create background job', + tags: ['Background Jobs'], + method: 'post', + path: '/', + middleware: [requireAuth] as const, + request: jsonBody(createBackgroundJobRequestSchema), + responses: { + 201: jsonContent(backgroundJobSchema, 'Created background job'), + 404: errorResponse('Not found'), + }, }, -}) +) -const statsRoute = createRoute({ - operationId: 'getBackgroundJobStats', - summary: 'Get active background job count', - tags: ['Background Jobs'], - method: 'get', - path: '/stats', - responses: { - 200: jsonContent(z.object({ activeCount: z.number().int() }), 'Background job stats'), - 404: errorResponse('No organization found'), +const statsRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], }, -}) + { + operationId: 'getBackgroundJobStats', + summary: 'Get active background job count', + tags: ['Background Jobs'], + method: 'get', + path: '/stats', + responses: { + 200: jsonContent(z.object({ activeCount: z.number().int() }), 'Background job stats'), + 404: errorResponse('No organization found'), + }, + }, +) -const getJobRoute = createRoute({ - operationId: 'getBackgroundJob', - summary: 'Get background job', - tags: ['Background Jobs'], - method: 'get', - path: '/{id}', - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(backgroundJobSchema, 'Background job'), - 404: errorResponse('Not found'), +const getJobRoute = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.DOWNLOAD_TASKS_READ], }, -}) + { + operationId: 'getBackgroundJob', + summary: 'Get background job', + tags: ['Background Jobs'], + method: 'get', + path: '/{id}', + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(backgroundJobSchema, 'Background job'), + 404: errorResponse('Not found'), + }, + }, +) -const cancelJobRoute = createRoute({ - operationId: 'cancelBackgroundJob', - summary: 'Cancel background job', - tags: ['Background Jobs'], - method: 'put', - path: '/{id}/status', - request: { params: z.object({ id: z.string() }), ...jsonBody(cancelJobSchema) }, - responses: { - 200: jsonContent(backgroundJobSchema, 'Canceled background job'), - 404: errorResponse('Not found'), - 409: errorResponse('Background job cannot be canceled'), +const cancelJobRoute = authRoute( + { access: 'session' }, + { + operationId: 'cancelBackgroundJob', + summary: 'Cancel background job', + tags: ['Background Jobs'], + method: 'put', + path: '/{id}/status', + middleware: [requireAuth] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(cancelJobSchema) }, + responses: { + 200: jsonContent(backgroundJobSchema, 'Canceled background job'), + 404: errorResponse('Not found'), + 409: errorResponse('Background job cannot be canceled'), + }, }, -}) +) -const retryJobRoute = createRoute({ - operationId: 'retryBackgroundJob', - summary: 'Retry background job', - tags: ['Background Jobs'], - method: 'post', - path: '/{id}/retries', - request: { params: z.object({ id: z.string() }) }, - responses: { - 201: jsonContent(backgroundJobSchema, 'Retried background job'), - 404: errorResponse('Not found'), - 409: errorResponse('Background job cannot be retried'), +const retryJobRoute = authRoute( + { access: 'session' }, + { + operationId: 'retryBackgroundJob', + summary: 'Retry background job', + tags: ['Background Jobs'], + method: 'post', + path: '/{id}/retries', + middleware: [requireAuth] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 201: jsonContent(backgroundJobSchema, 'Retried background job'), + 404: errorResponse('Not found'), + 409: errorResponse('Background job cannot be retried'), + }, }, -}) +) const app = new OpenAPIHono() -app.use(requireAuth) const backgroundJobs = app .openapi(listRoute, async (c) => { diff --git a/server/http/configz.ts b/server/http/configz.ts index 14447ccb..0bfcc160 100644 --- a/server/http/configz.ts +++ b/server/http/configz.ts @@ -1,22 +1,25 @@ -import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { OpenAPIHono } from '@hono/zod-openapi' import { siteConfigSchema } from '@shared/schemas' import { currentCacheEvents } from '../cache/context' import type { Env } from '../middleware/platform' import { siteConfigCacheControl } from '../usecases/site/config-cache' import { getSiteConfig } from '../usecases/site/configz' -import { jsonContent } from './openapi' +import { authRoute, jsonContent } from './openapi' -const getRoute = createRoute({ - operationId: 'getSiteConfig', - summary: 'Get public site configuration', - tags: ['Site Config'], - method: 'get', - path: '/', - responses: { - 200: jsonContent(siteConfigSchema, 'Public site configuration'), - 304: { description: 'Not modified' }, +const getRoute = authRoute( + { access: 'public' }, + { + operationId: 'getSiteConfig', + summary: 'Get public site configuration', + tags: ['Site Config'], + method: 'get', + path: '/', + responses: { + 200: jsonContent(siteConfigSchema, 'Public site configuration'), + 304: { description: 'Not modified' }, + }, }, -}) +) const encoder = new TextEncoder() diff --git a/server/http/downloads/downloaders.ts b/server/http/downloads/downloaders.ts index 651f274b..65b92f3f 100644 --- a/server/http/downloads/downloaders.ts +++ b/server/http/downloads/downloaders.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { createDownloaderResponseSchema, createDownloaderSchema, @@ -23,96 +23,114 @@ import { } from '../../usecases/downloads/downloads' import { featureBlocked, unauthorized } from '../../usecases/ports' import { loadBindingState } from '../../usecases/site/licensing' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' const downloaderListSchema = pageSchema(downloaderSchema, 'DownloaderList') -const listRoute = createRoute({ - operationId: 'listDownloaders', - summary: 'List downloaders', - tags: ['Downloaders'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { - 200: jsonContent(downloaderListSchema, 'Downloaders'), - 401: errorResponse('Unauthorized'), +const listRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listDownloaders', + summary: 'List downloaders', + tags: ['Downloaders'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { + 200: jsonContent(downloaderListSchema, 'Downloaders'), + 401: errorResponse('Unauthorized'), + }, }, -}) +) -const createRouteDoc = createRoute({ - operationId: 'createDownloader', - summary: 'Register downloader', - tags: ['Downloaders'], - method: 'post', - path: '/', - middleware: [requireAdmin] as const, - request: jsonBody(createDownloaderSchema), - responses: { - 201: jsonContent(createDownloaderResponseSchema, 'Downloader registration'), - 401: errorResponse('Unauthorized'), - 402: errorResponse('Feature not available'), +const createRouteDoc = authRoute( + { access: 'admin' }, + { + operationId: 'createDownloader', + summary: 'Register downloader', + tags: ['Downloaders'], + method: 'post', + path: '/', + middleware: [requireAdmin] as const, + request: jsonBody(createDownloaderSchema), + responses: { + 201: jsonContent(createDownloaderResponseSchema, 'Downloader registration'), + 401: errorResponse('Unauthorized'), + 402: errorResponse('Feature not available'), + }, }, -}) +) -const updateRoute = createRoute({ - operationId: 'updateDownloader', - summary: 'Update downloader', - tags: ['Downloaders'], - method: 'patch', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderSchema) }, - responses: { - 200: jsonContent(downloaderSchema, 'Updated downloader'), - 402: errorResponse('Feature not available'), - 404: errorResponse('Not found'), +const updateRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateDownloader', + summary: 'Update downloader', + tags: ['Downloaders'], + method: 'patch', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderSchema) }, + responses: { + 200: jsonContent(downloaderSchema, 'Updated downloader'), + 402: errorResponse('Feature not available'), + 404: errorResponse('Not found'), + }, }, -}) +) -const updateCreditBillingRoute = createRoute({ - operationId: 'updateDownloaderCreditBilling', - summary: 'Update downloader credit billing', - tags: ['Downloaders'], - method: 'put', - path: '/{id}/credit-billing', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderCreditBillingSchema) }, - responses: { - 200: jsonContent(downloaderSchema, 'Updated downloader'), - 402: errorResponse('Feature not available'), - 404: errorResponse('Not found'), +const updateCreditBillingRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateDownloaderCreditBilling', + summary: 'Update downloader credit billing', + tags: ['Downloaders'], + method: 'put', + path: '/{id}/credit-billing', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderCreditBillingSchema) }, + responses: { + 200: jsonContent(downloaderSchema, 'Updated downloader'), + 402: errorResponse('Feature not available'), + 404: errorResponse('Not found'), + }, }, -}) +) -const deleteRoute = createRoute({ - operationId: 'deleteDownloader', - summary: 'Delete downloader', - tags: ['Downloaders'], - method: 'delete', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Deleted downloader' }, - 404: errorResponse('Not found'), +const deleteRoute = authRoute( + { access: 'admin' }, + { + operationId: 'deleteDownloader', + summary: 'Delete downloader', + tags: ['Downloaders'], + method: 'delete', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Deleted downloader' }, + 404: errorResponse('Not found'), + }, }, -}) +) -const heartbeatRoute = createRoute({ - operationId: 'recordDownloaderHeartbeat', - summary: 'Send downloader heartbeat', - tags: ['Downloaders'], - method: 'post', - path: '/me/heartbeats', - middleware: [requireDownloader] as const, - request: jsonBody(downloaderHeartbeatSchema), - responses: { - 200: jsonContent(downloaderHeartbeatResultSchema, 'Updated downloader and task commands'), - 401: errorResponse('Unauthorized'), - 404: errorResponse('Not found'), +const heartbeatRoute = authRoute( + { access: 'downloader' }, + { + operationId: 'recordDownloaderHeartbeat', + summary: 'Send downloader heartbeat', + tags: ['Downloaders'], + method: 'post', + path: '/me/heartbeats', + middleware: [requireDownloader] as const, + request: jsonBody(downloaderHeartbeatSchema), + responses: { + 200: jsonContent(downloaderHeartbeatResultSchema, 'Updated downloader and task commands'), + 401: errorResponse('Unauthorized'), + 404: errorResponse('Not found'), + }, }, -}) +) // A missing downloader makes the usecase throw DownloadError('not_found'); the // global onError maps it to 404, so these handlers carry no error plumbing. diff --git a/server/http/image-hosting/config.ts b/server/http/image-hosting/config.ts index c1b7ba50..56451a31 100644 --- a/server/http/image-hosting/config.ts +++ b/server/http/image-hosting/config.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { putIhostConfigSchema } from '../../../shared/schemas' import type { IhostConfigResponse } from '../../../shared/types' import { requireAuth, requireTeamRole } from '../../middleware/auth' @@ -10,7 +10,7 @@ import { } from '../../usecases/image-hosting/config' import type { ImageDomainProviderConfig, ImageHostingConfigRecord } from '../../usecases/ports' import { unauthorized } from '../../usecases/ports' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' const ihostConfigSchema = z .object({ @@ -81,49 +81,58 @@ function buildResponse( } } -const getRoute = createRoute({ - operationId: 'getImageHostingConfig', - summary: 'Get image-hosting config', - tags: ['Image Hosting'], - method: 'get', - path: '/', - responses: { - 200: jsonContent(ihostConfigSchema, 'Image-hosting config'), - 401: errorResponse('Unauthorized'), +const getRoute = authRoute( + { access: 'session' }, + { + operationId: 'getImageHostingConfig', + summary: 'Get image-hosting config', + tags: ['Image Hosting'], + method: 'get', + path: '/', + middleware: [requireAuth] as const, + responses: { + 200: jsonContent(ihostConfigSchema, 'Image-hosting config'), + 401: errorResponse('Unauthorized'), + }, }, -}) +) -const putRoute = createRoute({ - operationId: 'updateImageHostingConfig', - summary: 'Update image-hosting config', - tags: ['Image Hosting'], - method: 'put', - path: '/', - middleware: [requireTeamRole('owner')] as const, - request: jsonBody(putIhostConfigSchema), - responses: { - 200: jsonContent(ihostConfigSchema, 'Updated config'), - 400: errorResponse('Custom domain or provider is invalid'), - 401: errorResponse('Unauthorized'), - 409: errorResponse('Domain already registered by another organization'), +const putRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'updateImageHostingConfig', + summary: 'Update image-hosting config', + tags: ['Image Hosting'], + method: 'put', + path: '/', + middleware: [requireAuth, requireTeamRole('owner')] as const, + request: jsonBody(putIhostConfigSchema), + responses: { + 200: jsonContent(ihostConfigSchema, 'Updated config'), + 400: errorResponse('Custom domain or provider is invalid'), + 401: errorResponse('Unauthorized'), + 409: errorResponse('Domain already registered by another organization'), + }, }, -}) +) -const deleteRoute = createRoute({ - operationId: 'deleteImageHostingConfig', - summary: 'Delete image-hosting config', - tags: ['Image Hosting'], - method: 'delete', - path: '/', - middleware: [requireTeamRole('owner')] as const, - responses: { - 204: { description: 'Deleted' }, - 401: errorResponse('Unauthorized'), +const deleteRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'deleteImageHostingConfig', + summary: 'Delete image-hosting config', + tags: ['Image Hosting'], + method: 'delete', + path: '/', + middleware: [requireAuth, requireTeamRole('owner')] as const, + responses: { + 204: { description: 'Deleted' }, + 401: errorResponse('Unauthorized'), + }, }, -}) +) const app = new OpenAPIHono() -app.use(requireAuth) const ihostConfig = app .openapi(getRoute, async (c) => { diff --git a/server/http/image-hosting/images.ts b/server/http/image-hosting/images.ts index 3cb59d17..ef54aad3 100644 --- a/server/http/image-hosting/images.ts +++ b/server/http/image-hosting/images.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { nanoid } from 'nanoid' import { ALLOWED_IMAGE_MIMES, @@ -30,7 +30,7 @@ import { unauthorized, unsupportedMediaType, } from '../../usecases/ports' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' import { createdAtIdCursorCodec, decodeOptionalPageToken, @@ -117,86 +117,101 @@ function detectMimeFromBytes(bytes: Uint8Array): string | null { return null } -const presignRoute = createRoute({ - operationId: 'presignImageHostingUpload', - summary: 'Presign an image upload', - tags: ['Image Hosting'], - method: 'post', - path: '/images/presign', - middleware: [requireAuth, requireTeamRole('editor')] as const, - request: jsonBody(createIhostImageSchema), - responses: { - 201: jsonContent(imageDraftSchema, 'Image upload draft'), - 400: errorResponse('No active organization or invalid path'), - 403: errorResponse('Image hosting not enabled'), - 413: errorResponse('File too large'), - 503: errorResponse('No storage configured'), +const presignRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'presignImageHostingUpload', + summary: 'Presign an image upload', + tags: ['Image Hosting'], + method: 'post', + path: '/images/presign', + middleware: [requireAuth, requireTeamRole('editor')] as const, + request: jsonBody(createIhostImageSchema), + responses: { + 201: jsonContent(imageDraftSchema, 'Image upload draft'), + 400: errorResponse('No active organization or invalid path'), + 403: errorResponse('Image hosting not enabled'), + 413: errorResponse('File too large'), + 503: errorResponse('No storage configured'), + }, }, -}) +) -const listRoute = createRoute({ - operationId: 'listImageHostings', - summary: 'List hosted images', - tags: ['Image Hosting'], - method: 'get', - path: '/images', - middleware: [requireAuth, requireTeamRole('viewer')] as const, - request: { query: listIhostImagesSchema }, - responses: { - 200: jsonContent(imageListSchema, 'Hosted images'), - 400: errorResponse('No active organization'), - 403: errorResponse('Image hosting not enabled'), +const listRoute = authRoute( + { access: 'session', minTeamRole: 'viewer' }, + { + operationId: 'listImageHostings', + summary: 'List hosted images', + tags: ['Image Hosting'], + method: 'get', + path: '/images', + middleware: [requireAuth, requireTeamRole('viewer')] as const, + request: { query: listIhostImagesSchema }, + responses: { + 200: jsonContent(imageListSchema, 'Hosted images'), + 400: errorResponse('No active organization'), + 403: errorResponse('Image hosting not enabled'), + }, }, -}) +) -const getRoute = createRoute({ - operationId: 'getImageHosting', - summary: 'Get a hosted image', - tags: ['Image Hosting'], - method: 'get', - path: '/images/{id}', - middleware: [requireAuth, requireTeamRole('viewer')] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(imageHostingSchema, 'Hosted image'), - 400: errorResponse('No active organization'), - 403: errorResponse('Image hosting not enabled'), - 404: errorResponse('Not found'), +const getRoute = authRoute( + { access: 'session', minTeamRole: 'viewer' }, + { + operationId: 'getImageHosting', + summary: 'Get a hosted image', + tags: ['Image Hosting'], + method: 'get', + path: '/images/{id}', + middleware: [requireAuth, requireTeamRole('viewer')] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(imageHostingSchema, 'Hosted image'), + 400: errorResponse('No active organization'), + 403: errorResponse('Image hosting not enabled'), + 404: errorResponse('Not found'), + }, }, -}) +) -const confirmRoute = createRoute({ - operationId: 'confirmImageHosting', - summary: 'Confirm an uploaded image', - tags: ['Image Hosting'], - method: 'put', - path: '/images/{id}/status', - middleware: [requireAuth, requireTeamRole('editor')] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(imageHostingSchema, 'Confirmed image'), - 400: errorResponse('No active organization'), - 403: errorResponse('Image hosting not enabled'), - 404: errorResponse('Not found or not in draft status'), - 422: errorResponse('Quota exceeded'), +const confirmRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'confirmImageHosting', + summary: 'Confirm an uploaded image', + tags: ['Image Hosting'], + method: 'put', + path: '/images/{id}/status', + middleware: [requireAuth, requireTeamRole('editor')] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(imageHostingSchema, 'Confirmed image'), + 400: errorResponse('No active organization'), + 403: errorResponse('Image hosting not enabled'), + 404: errorResponse('Not found or not in draft status'), + 422: errorResponse('Quota exceeded'), + }, }, -}) +) -const deleteRoute = createRoute({ - operationId: 'deleteImageHosting', - summary: 'Delete a hosted image', - tags: ['Image Hosting'], - method: 'delete', - path: '/images/{id}', - middleware: [requireAuth, requireTeamRole('editor')] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Deleted' }, - 400: errorResponse('No active organization'), - 403: errorResponse('Image hosting not enabled'), - 404: errorResponse('Not found'), +const deleteRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'deleteImageHosting', + summary: 'Delete a hosted image', + tags: ['Image Hosting'], + method: 'delete', + path: '/images/{id}', + middleware: [requireAuth, requireTeamRole('editor')] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Deleted' }, + 400: errorResponse('No active organization'), + 403: errorResponse('Image hosting not enabled'), + 404: errorResponse('Not found'), + }, }, -}) +) const app = new OpenAPIHono() diff --git a/server/http/notifications.ts b/server/http/notifications.ts index e1e33c55..47226bc6 100644 --- a/server/http/notifications.ts +++ b/server/http/notifications.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { cursorPageSchema, listNotificationsQuerySchema } from '@shared/schemas' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' @@ -9,7 +9,7 @@ import { markNotificationRead, } from '../usecases/notification' import { type NotificationRecord, notFound } from '../usecases/ports' -import { errorResponse, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonContent } from './openapi' import { createdAtIdCursorCodec, decodeOptionalPageToken, @@ -55,49 +55,64 @@ function toNotificationDTO(n: NotificationRecord): NotificationDTO { // at GET /stats so the list shares the one Page shape with every other resource. const notificationPageSchema = cursorPageSchema(notificationSchema, 'NotificationPage') -const listRoute = createRoute({ - operationId: 'listNotifications', - summary: 'List notifications', - tags: ['Notifications'], - method: 'get', - path: '/', - request: { query: listNotificationsQuerySchema }, - responses: { 200: jsonContent(notificationPageSchema, 'Notifications') }, -}) - -const statsRoute = createRoute({ - operationId: 'getNotificationStats', - summary: 'Get unread notification count', - tags: ['Notifications'], - method: 'get', - path: '/stats', - responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Unread count') }, -}) - -const markReadRoute = createRoute({ - operationId: 'markNotificationRead', - summary: 'Mark a notification read', - tags: ['Notifications'], - method: 'patch', - path: '/{id}', - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Marked read' }, - 404: errorResponse('Not found'), +const listRoute = authRoute( + { access: 'session' }, + { + operationId: 'listNotifications', + summary: 'List notifications', + tags: ['Notifications'], + method: 'get', + path: '/', + middleware: [requireAuth] as const, + request: { query: listNotificationsQuerySchema }, + responses: { 200: jsonContent(notificationPageSchema, 'Notifications') }, }, -}) +) -const markAllReadRoute = createRoute({ - operationId: 'markAllNotificationsRead', - summary: 'Mark all notifications read', - tags: ['Notifications'], - method: 'patch', - path: '/', - responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Number marked read') }, -}) +const statsRoute = authRoute( + { access: 'session' }, + { + operationId: 'getNotificationStats', + summary: 'Get unread notification count', + tags: ['Notifications'], + method: 'get', + path: '/stats', + middleware: [requireAuth] as const, + responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Unread count') }, + }, +) + +const markReadRoute = authRoute( + { access: 'session' }, + { + operationId: 'markNotificationRead', + summary: 'Mark a notification read', + tags: ['Notifications'], + method: 'patch', + path: '/{id}', + middleware: [requireAuth] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Marked read' }, + 404: errorResponse('Not found'), + }, + }, +) + +const markAllReadRoute = authRoute( + { access: 'session' }, + { + operationId: 'markAllNotificationsRead', + summary: 'Mark all notifications read', + tags: ['Notifications'], + method: 'patch', + path: '/', + middleware: [requireAuth] as const, + responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Number marked read') }, + }, +) const app = new OpenAPIHono() -app.use(requireAuth) export const notifications = app .openapi(listRoute, async (c) => { diff --git a/server/http/objects.ts b/server/http/objects.ts index f7fbdb0d..63233386 100644 --- a/server/http/objects.ts +++ b/server/http/objects.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { completeObjectUploadSchema, copyObjectBodySchema, @@ -35,7 +35,7 @@ import { } from '../usecases/object' import { badRequest, forbidden, type Matter, type MatterListItem, unauthorized } from '../usecases/ports' import { recordDownloadIssued } from '../usecases/transfer-activity' -import { errorResponse, jsonBody, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' import { decodeOptionalPageToken, directoryCursorCodec, encodeNextPageToken, pageQueryFingerprint } from './page-token' // The wire shape of a file/folder — exactly what the API serializes. Timestamps @@ -163,22 +163,30 @@ const requireObjectWriteAccess = createMiddleware(async (c, next) => { await next() }) -const listRoute = createRoute({ - operationId: 'listObjects', - summary: 'List objects', - tags: ['Objects'], - method: 'get', - path: '/', - middleware: [requireTeamRole('viewer')] as const, - request: { query: listObjectsQuerySchema }, - responses: { - 200: jsonContent(objectPageSchema, 'Objects'), - 400: errorResponse('No active organization'), - 403: errorResponse('Forbidden'), - }, -}) +const objectWriteAuth = { + access: 'anyOf', + policies: [{ access: 'session', minTeamRole: 'editor' }, { access: 'task-upload-token' }], +} as const -const createObjectRoute = createRoute({ +const listRoute = authRoute( + { access: 'session', minTeamRole: 'viewer' }, + { + operationId: 'listObjects', + summary: 'List objects', + tags: ['Objects'], + method: 'get', + path: '/', + middleware: [requireTeamRole('viewer')] as const, + request: { query: listObjectsQuerySchema }, + responses: { + 200: jsonContent(objectPageSchema, 'Objects'), + 400: errorResponse('No active organization'), + 403: errorResponse('Forbidden'), + }, + }, +) + +const createObjectRoute = authRoute(objectWriteAuth, { operationId: 'createObject', summary: 'Create object', tags: ['Objects'], @@ -195,7 +203,7 @@ const createObjectRoute = createRoute({ }, }) -const presignPartsRoute = createRoute({ +const presignPartsRoute = authRoute(objectWriteAuth, { operationId: 'presignObjectUploadParts', summary: 'Re-presign upload parts', tags: ['Objects'], @@ -212,7 +220,7 @@ const presignPartsRoute = createRoute({ }, }) -const completionsRoute = createRoute({ +const completionsRoute = authRoute(objectWriteAuth, { operationId: 'completeObjectUpload', summary: 'Complete upload', tags: ['Objects'], @@ -230,7 +238,7 @@ const completionsRoute = createRoute({ }, }) -const abortUploadRoute = createRoute({ +const abortUploadRoute = authRoute(objectWriteAuth, { operationId: 'abortObjectUpload', summary: 'Abort upload', tags: ['Objects'], @@ -247,24 +255,27 @@ const abortUploadRoute = createRoute({ }, }) -const getObjectRoute = createRoute({ - operationId: 'getObject', - summary: 'Get object', - tags: ['Objects'], - method: 'get', - path: '/{id}', - middleware: [requireTeamRole('viewer')] as const, - request: { params: idParam }, - responses: { - 200: jsonContent(objectWithDownloadSchema, 'Object'), - 400: errorResponse('No active organization'), - 402: errorResponse('Insufficient credits'), - 404: errorResponse('Not found'), - 422: errorResponse('Traffic quota exceeded'), +const getObjectRoute = authRoute( + { access: 'session', minTeamRole: 'viewer' }, + { + operationId: 'getObject', + summary: 'Get object', + tags: ['Objects'], + method: 'get', + path: '/{id}', + middleware: [requireTeamRole('viewer')] as const, + request: { params: idParam }, + responses: { + 200: jsonContent(objectWithDownloadSchema, 'Object'), + 400: errorResponse('No active organization'), + 402: errorResponse('Insufficient credits'), + 404: errorResponse('Not found'), + 422: errorResponse('Traffic quota exceeded'), + }, }, -}) +) -const patchObjectRoute = createRoute({ +const patchObjectRoute = authRoute(objectWriteAuth, { operationId: 'updateObject', summary: 'Update object', tags: ['Objects'], @@ -279,63 +290,72 @@ const patchObjectRoute = createRoute({ }, }) -const deleteObjectRoute = createRoute({ - operationId: 'deleteObject', - summary: 'Delete object', - tags: ['Objects'], - method: 'delete', - path: '/{id}', - middleware: [requireTeamRole('editor')] as const, - request: { params: idParam }, - responses: { - // Soft delete: the object moves to trash (GET /trash/objects). Permanent - // removal is DELETE /trash/objects/{id}. - 204: { description: 'Object moved to trash' }, - 400: errorResponse('No active organization'), - 404: errorResponse('Not found'), +const deleteObjectRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'deleteObject', + summary: 'Delete object', + tags: ['Objects'], + method: 'delete', + path: '/{id}', + middleware: [requireTeamRole('editor')] as const, + request: { params: idParam }, + responses: { + // Soft delete: the object moves to trash (GET /trash/objects). Permanent + // removal is DELETE /trash/objects/{id}. + 204: { description: 'Object moved to trash' }, + 400: errorResponse('No active organization'), + 404: errorResponse('Not found'), + }, }, -}) +) -const copyObjectRoute = createRoute({ - operationId: 'copyObject', - summary: 'Copy object', - tags: ['Objects'], - method: 'post', - path: '/{id}/copies', - middleware: [requireTeamRole('editor')] as const, - request: { params: idParam, ...jsonBody(copyObjectBodySchema) }, - responses: { - 201: jsonContent(matterSchema, 'Copied object'), - 400: errorResponse('No active organization'), - 404: errorResponse('Not found'), +const copyObjectRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'copyObject', + summary: 'Copy object', + tags: ['Objects'], + method: 'post', + path: '/{id}/copies', + middleware: [requireTeamRole('editor')] as const, + request: { params: idParam, ...jsonBody(copyObjectBodySchema) }, + responses: { + 201: jsonContent(matterSchema, 'Copied object'), + 400: errorResponse('No active organization'), + 404: errorResponse('Not found'), + }, }, -}) +) -const transferObjectRoute = createRoute({ - operationId: 'transferObject', - summary: 'Transfer object to another space', - tags: ['Objects'], - method: 'post', - path: '/{id}/transfers', - middleware: [requireTeamRole('viewer')] as const, - request: { params: idParam, ...jsonBody(transferMatterSchema) }, - responses: { - 201: jsonContent( - z - .object({ - saved: z.array(matterSchema), - skipped: z.array(z.object({ name: z.string(), reason: z.string() })), - sourceDeleted: z.boolean(), - }) - .openapi('TransferResult'), - 'Transferred object', - ), - 400: errorResponse('Invalid transfer target'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Not found'), - 422: errorResponse('Quota exceeded'), +const transferObjectRoute = authRoute( + { access: 'session', minTeamRole: 'viewer' }, + { + operationId: 'transferObject', + summary: 'Transfer object to another space', + tags: ['Objects'], + method: 'post', + path: '/{id}/transfers', + middleware: [requireTeamRole('viewer')] as const, + request: { params: idParam, ...jsonBody(transferMatterSchema) }, + responses: { + 201: jsonContent( + z + .object({ + saved: z.array(matterSchema), + skipped: z.array(z.object({ name: z.string(), reason: z.string() })), + sourceDeleted: z.boolean(), + }) + .openapi('TransferResult'), + 'Transferred object', + ), + 400: errorResponse('Invalid transfer target'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Not found'), + 422: errorResponse('Quota exceeded'), + }, }, -}) +) const app = new OpenAPIHono() // Blanket auth gate for every object route. Applied as a statement, not chained, diff --git a/server/http/openapi.ts b/server/http/openapi.ts index 50ddd718..87f3b1fb 100644 --- a/server/http/openapi.ts +++ b/server/http/openapi.ts @@ -28,7 +28,7 @@ export function authRoute

config: T, ): T & { getRoutingPath(): string } { const middleware = - auth.access === 'public' ? config.middleware : [authorize(auth), ...((config.middleware ?? []) as [])] + auth.access === 'protected' ? [authorize(auth), ...((config.middleware ?? []) as [])] : config.middleware return createRoute({ ...config, middleware, @@ -51,14 +51,19 @@ export function findOperationsMissingAuthContract(paths: Record[] { + if (auth.access === 'anyOf') return auth.policies.flatMap(openApiSecurity) + if (auth.access === 'public' || auth.access === 'internal' || auth.access === 'signed-webhook') return [] + if (auth.access === 'admin' || auth.access === 'session') return [{ cookieAuth: [] }] + if (auth.access === 'downloader' || auth.access === 'task-upload-token') return [{ bearerAuth: [] }] return auth.scopes?.length ? [{ bearerAuth: [...auth.scopes] }, { cookieAuth: [] }] : [{ bearerAuth: [] }, { cookieAuth: [] }] } function openApiAuthMetadata(auth: RouteAuthorizationDeclaration): Record { + if (auth.access === 'anyOf') return { access: auth.access, policies: auth.policies.map(openApiAuthMetadata) } + if (auth.access === 'session') return { access: auth.access, minTeamRole: auth.minTeamRole ?? null } if (auth.access !== 'protected') return { access: auth.access } return { access: auth.access, diff --git a/server/http/quotas.ts b/server/http/quotas.ts index 2e2a39ff..92534e53 100644 --- a/server/http/quotas.ts +++ b/server/http/quotas.ts @@ -1,10 +1,10 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { pageSchema } from '@shared/schemas' import { requireAdmin, requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' import { notFound } from '../usecases/ports' import { getUserQuota, listQuotaOverview } from '../usecases/quota' -import { errorResponse, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonContent } from './openapi' // Quota types are already wire-shaped (timestamps are ISO strings, not Date), so // the schemas match the usecase return types directly — no DTO mapper needed. @@ -45,28 +45,34 @@ const quotaOverviewItemSchema = effectiveQuotaSchema const quotaOverviewSchema = pageSchema(quotaOverviewItemSchema, 'QuotaOverview') -const listQuotaOverviewRoute = createRoute({ - operationId: 'listQuotaOverview', - summary: 'List quota overview across all spaces', - tags: ['Quotas'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(quotaOverviewSchema, 'Quota overview') }, -}) - -const getMyQuotaRoute = createRoute({ - operationId: 'getMyQuota', - summary: "Get the current user's effective quota", - tags: ['Quotas'], - method: 'get', - path: '/me', - middleware: [requireAuth] as const, - responses: { - 200: jsonContent(effectiveQuotaSchema, 'Effective quota'), - 404: errorResponse('No organization found'), +const listQuotaOverviewRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listQuotaOverview', + summary: 'List quota overview across all spaces', + tags: ['Quotas'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(quotaOverviewSchema, 'Quota overview') }, }, -}) +) + +const getMyQuotaRoute = authRoute( + { access: 'session' }, + { + operationId: 'getMyQuota', + summary: "Get the current user's effective quota", + tags: ['Quotas'], + method: 'get', + path: '/me', + middleware: [requireAuth] as const, + responses: { + 200: jsonContent(effectiveQuotaSchema, 'Effective quota'), + 404: errorResponse('No organization found'), + }, + }, +) // Quota overview across all orgs (personal + team), used by the admin dashboard. // Per-team entitlement management lives under /api/teams. diff --git a/server/http/shares.ts b/server/http/shares.ts index 4bfbd786..6ebd7963 100644 --- a/server/http/shares.ts +++ b/server/http/shares.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import type { Context } from 'hono' import { getCookie, setCookie } from 'hono/cookie' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' @@ -30,7 +30,7 @@ import { viewShare, } from '../usecases/share' import { recordDownloadIssued } from '../usecases/transfer-activity' -import { errorResponse, jsonBody, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' import { createdAtIdCursorCodec, decodeOptionalPageToken, @@ -198,66 +198,78 @@ const listObjectsQuerySchema = z.object({ const verifyPasswordSchema = z.object({ password: z.string() }) // ─── PUBLIC SEGMENT ────────────────────────────────────────────────────────── -const viewShareRoute = createRoute({ - operationId: 'getShare', - summary: 'View a share', - tags: ['Shares'], - method: 'get', - path: '/{token}', - request: { params: z.object({ token: z.string() }) }, - responses: { - 200: jsonContent(shareViewSchema, 'Share'), - 404: errorResponse('Share not found or revoked'), - 410: errorResponse('File no longer available'), +const viewShareRoute = authRoute( + { access: 'public' }, + { + operationId: 'getShare', + summary: 'View a share', + tags: ['Shares'], + method: 'get', + path: '/{token}', + request: { params: z.object({ token: z.string() }) }, + responses: { + 200: jsonContent(shareViewSchema, 'Share'), + 404: errorResponse('Share not found or revoked'), + 410: errorResponse('File no longer available'), + }, }, -}) +) -const verifyShareRoute = createRoute({ - operationId: 'verifySharePassword', - summary: 'Verify a share password', - tags: ['Shares'], - method: 'post', - path: '/{token}/sessions', - request: { params: z.object({ token: z.string() }), ...jsonBody(verifyPasswordSchema) }, - responses: { - 200: jsonContent(z.object({ ok: z.literal(true) }), 'Verified'), - 403: errorResponse('Invalid password'), - 404: errorResponse('Share not found or revoked'), +const verifyShareRoute = authRoute( + { access: 'public' }, + { + operationId: 'verifySharePassword', + summary: 'Verify a share password', + tags: ['Shares'], + method: 'post', + path: '/{token}/sessions', + request: { params: z.object({ token: z.string() }), ...jsonBody(verifyPasswordSchema) }, + responses: { + 200: jsonContent(z.object({ ok: z.literal(true) }), 'Verified'), + 403: errorResponse('Invalid password'), + 404: errorResponse('Share not found or revoked'), + }, }, -}) +) -const listShareObjectsRoute = createRoute({ - operationId: 'listShareObjects', - summary: 'List objects in a folder share', - tags: ['Shares'], - method: 'get', - path: '/{token}/objects', - request: { params: z.object({ token: z.string() }), query: listObjectsQuerySchema }, - responses: { - 200: jsonContent(shareObjectsSchema, 'Share objects'), - 400: errorResponse('Bad request'), - 401: errorResponse('Password required'), - 404: errorResponse('Share not found'), - 410: errorResponse('Share expired or unavailable'), +const listShareObjectsRoute = authRoute( + { access: 'public' }, + { + operationId: 'listShareObjects', + summary: 'List objects in a folder share', + tags: ['Shares'], + method: 'get', + path: '/{token}/objects', + request: { params: z.object({ token: z.string() }), query: listObjectsQuerySchema }, + responses: { + 200: jsonContent(shareObjectsSchema, 'Share objects'), + 400: errorResponse('Bad request'), + 401: errorResponse('Password required'), + 404: errorResponse('Share not found'), + 410: errorResponse('Share expired or unavailable'), + }, }, -}) +) -const readShareReadmeRoute = createRoute({ - operationId: 'readShareReadme', - summary: 'Read a shared folder README', - tags: ['Shares'], - method: 'get', - path: '/{token}/readme', - request: { params: z.object({ token: z.string() }) }, - responses: { - 200: jsonContent(shareReadmeResponseSchema, 'README.md content'), - 400: errorResponse('README.md is not valid UTF-8'), - 401: errorResponse('Password required'), - 404: errorResponse('README.md not found'), - 410: errorResponse('Share expired'), - 413: errorResponse('README.md is too large'), +const readShareReadmeRoute = authRoute( + { access: 'public' }, + { + operationId: 'readShareReadme', + summary: 'Read a shared folder README', + tags: ['Shares'], + method: 'get', + path: '/{token}/readme', + request: { params: z.object({ token: z.string() }) }, + responses: { + 200: jsonContent(shareReadmeResponseSchema, 'README.md content'), + 400: errorResponse('README.md is not valid UTF-8'), + 401: errorResponse('Password required'), + 404: errorResponse('README.md not found'), + 410: errorResponse('Share expired'), + 413: errorResponse('README.md is too large'), + }, }, -}) +) const pub = new OpenAPIHono() @@ -396,88 +408,106 @@ export const publicShares = pub }) // ─── AUTHED SEGMENT ───────────────────────────────────────────────────────── -const listSharesRoute = createRoute({ - operationId: 'listShares', - summary: 'List my shares', - tags: ['Shares'], - method: 'get', - path: '/', - request: { query: listSharesQuerySchema }, - responses: { 200: jsonContent(shareListSchema, 'Shares') }, -}) +const listSharesRoute = authRoute( + { access: 'session' }, + { + operationId: 'listShares', + summary: 'List my shares', + tags: ['Shares'], + method: 'get', + path: '/', + middleware: [requireAuth] as const, + request: { query: listSharesQuerySchema }, + responses: { 200: jsonContent(shareListSchema, 'Shares') }, + }, +) -const createShareRoute = createRoute({ - operationId: 'createShare', - summary: 'Create a share', - tags: ['Shares'], - method: 'post', - path: '/', - middleware: [requireTeamRole('editor')] as const, - request: jsonBody(createShareRequestSchema), - responses: { - 201: jsonContent(createdShareSchema, 'Created share'), - 400: errorResponse('Invalid share configuration'), - 404: errorResponse('Matter not found'), +const createShareRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'createShare', + summary: 'Create a share', + tags: ['Shares'], + method: 'post', + path: '/', + middleware: [requireAuth, requireTeamRole('editor')] as const, + request: jsonBody(createShareRequestSchema), + responses: { + 201: jsonContent(createdShareSchema, 'Created share'), + 400: errorResponse('Invalid share configuration'), + 404: errorResponse('Matter not found'), + }, }, -}) +) -const revokeShareRoute = createRoute({ - operationId: 'revokeShare', - summary: 'Revoke a share', - tags: ['Shares'], - method: 'put', - path: '/{token}/status', - request: { - params: z.object({ token: z.string() }), - ...jsonBody(z.object({ status: z.literal('revoked') })), +const revokeShareRoute = authRoute( + { access: 'session' }, + { + operationId: 'revokeShare', + summary: 'Revoke a share', + tags: ['Shares'], + method: 'put', + path: '/{token}/status', + middleware: [requireAuth] as const, + request: { + params: z.object({ token: z.string() }), + ...jsonBody(z.object({ status: z.literal('revoked') })), + }, + responses: { + 200: jsonContent(shareViewSchema, 'Revoked share'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Not found'), + }, }, - responses: { - 200: jsonContent(shareViewSchema, 'Revoked share'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Not found'), - }, -}) +) const sharePrivacySchema = z.object({ private: z.boolean() }).openapi('SharePrivacy') -const putSharePrivacyRoute = createRoute({ - operationId: 'putSharePrivacy', - summary: 'Set whether a share is hidden from the owner public profile', - tags: ['Shares'], - method: 'put', - path: '/{token}/privacy', - request: { - params: z.object({ token: z.string() }), - ...jsonBody(sharePrivacySchema), +const putSharePrivacyRoute = authRoute( + { access: 'session' }, + { + operationId: 'putSharePrivacy', + summary: 'Set whether a share is hidden from the owner public profile', + tags: ['Shares'], + method: 'put', + path: '/{token}/privacy', + middleware: [requireAuth] as const, + request: { + params: z.object({ token: z.string() }), + ...jsonBody(sharePrivacySchema), + }, + responses: { + 200: jsonContent(sharePrivacySchema, 'Share privacy'), + 400: errorResponse('Share does not have configurable privacy'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Not found'), + }, }, - responses: { - 200: jsonContent(sharePrivacySchema, 'Share privacy'), - 400: errorResponse('Share does not have configurable privacy'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Not found'), - }, -}) +) -const saveShareRoute = createRoute({ - operationId: 'saveShare', - summary: 'Save a share to my drive', - tags: ['Shares'], - method: 'post', - path: '/{token}/objects', - request: { params: z.object({ token: z.string() }), ...jsonBody(saveShareRequestSchema) }, - responses: { - 201: jsonContent(saveShareResultSchema, 'Saved'), - 400: errorResponse('Bad request'), - 401: errorResponse('Authentication required'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Share not found'), - 410: errorResponse('Share target deleted'), - 422: errorResponse('Quota exceeded'), +const saveShareRoute = authRoute( + { access: 'session' }, + { + operationId: 'saveShare', + summary: 'Save a share to my drive', + tags: ['Shares'], + method: 'post', + path: '/{token}/objects', + middleware: [requireAuth] as const, + request: { params: z.object({ token: z.string() }), ...jsonBody(saveShareRequestSchema) }, + responses: { + 201: jsonContent(saveShareResultSchema, 'Saved'), + 400: errorResponse('Bad request'), + 401: errorResponse('Authentication required'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Share not found'), + 410: errorResponse('Share target deleted'), + 422: errorResponse('Quota exceeded'), + }, }, -}) +) const authedApp = new OpenAPIHono() -authedApp.use(requireAuth) export const authedShares = authedApp .openapi(listSharesRoute, async (c) => { diff --git a/server/http/site/announcements.ts b/server/http/site/announcements.ts index 7f2cf1f9..4400e18f 100644 --- a/server/http/site/announcements.ts +++ b/server/http/site/announcements.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { announcementInputSchema, announcementStatusSchema, pageQuerySchema, pageSchema } from '@shared/schemas' import { requireAdmin, requireAuth } from '../../middleware/auth' import type { Env } from '../../middleware/platform' @@ -12,7 +12,7 @@ import { listUserAnnouncements, updateAnnouncement, } from '../../usecases/site/announcement' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' const announcementSchema = z .object({ @@ -50,75 +50,89 @@ const listAnnouncementsQuerySchema = pageQuerySchema.extend({ status: announcementStatusSchema.optional(), }) -const listRoute = createRoute({ - operationId: 'listAnnouncements', - summary: 'List announcements', - tags: ['Announcements'], - method: 'get', - path: '/', - request: { query: listAnnouncementsQuerySchema }, - responses: { - 200: jsonContent(announcementListSchema, 'Announcements'), - 403: errorResponse('Forbidden'), +const listRoute = authRoute( + { access: 'session' }, + { + operationId: 'listAnnouncements', + summary: 'List announcements', + tags: ['Announcements'], + method: 'get', + path: '/', + middleware: [requireAuth, requireFeature('site_announcements')] as const, + request: { query: listAnnouncementsQuerySchema }, + responses: { + 200: jsonContent(announcementListSchema, 'Announcements'), + 403: errorResponse('Forbidden'), + }, }, -}) +) -const createAnnouncementRoute = createRoute({ - operationId: 'createAnnouncement', - summary: 'Create announcement', - tags: ['Announcements'], - method: 'post', - path: '/', - middleware: [requireAdmin] as const, - request: jsonBody(announcementInputSchema), - responses: { 201: jsonContent(announcementSchema, 'Created announcement') }, -}) - -const getAnnouncementRoute = createRoute({ - operationId: 'getAnnouncement', - summary: 'Get announcement', - tags: ['Announcements'], - method: 'get', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(announcementSchema, 'Announcement'), - 404: errorResponse('Announcement not found'), +const createAnnouncementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'createAnnouncement', + summary: 'Create announcement', + tags: ['Announcements'], + method: 'post', + path: '/', + middleware: [requireAdmin, requireFeature('site_announcements')] as const, + request: jsonBody(announcementInputSchema), + responses: { 201: jsonContent(announcementSchema, 'Created announcement') }, }, -}) +) -const updateAnnouncementRoute = createRoute({ - operationId: 'updateAnnouncement', - summary: 'Update announcement', - tags: ['Announcements'], - method: 'put', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(announcementInputSchema) }, - responses: { - 200: jsonContent(announcementSchema, 'Updated announcement'), - 404: errorResponse('Announcement not found'), +const getAnnouncementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getAnnouncement', + summary: 'Get announcement', + tags: ['Announcements'], + method: 'get', + path: '/{id}', + middleware: [requireAdmin, requireFeature('site_announcements')] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(announcementSchema, 'Announcement'), + 404: errorResponse('Announcement not found'), + }, }, -}) +) -const deleteAnnouncementRoute = createRoute({ - operationId: 'deleteAnnouncement', - summary: 'Delete announcement', - tags: ['Announcements'], - method: 'delete', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Deleted announcement' }, - 404: errorResponse('Announcement not found'), +const updateAnnouncementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateAnnouncement', + summary: 'Update announcement', + tags: ['Announcements'], + method: 'put', + path: '/{id}', + middleware: [requireAdmin, requireFeature('site_announcements')] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(announcementInputSchema) }, + responses: { + 200: jsonContent(announcementSchema, 'Updated announcement'), + 404: errorResponse('Announcement not found'), + }, }, -}) +) + +const deleteAnnouncementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'deleteAnnouncement', + summary: 'Delete announcement', + tags: ['Announcements'], + method: 'delete', + path: '/{id}', + middleware: [requireAdmin, requireFeature('site_announcements')] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Deleted announcement' }, + 404: errorResponse('Announcement not found'), + }, + }, +) const app = new OpenAPIHono() -app.use(requireAuth) -app.use(requireFeature('site_announcements')) // One announcements resource. GET / is the caller's live feed by default; // `?scope=all` (or a `?status=` filter) returns the admin management list. Writes diff --git a/server/http/site/audit.ts b/server/http/site/audit.ts index 38020539..b0e8cf1b 100644 --- a/server/http/site/audit.ts +++ b/server/http/site/audit.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { pageQuerySchema, pageSchema } from '@shared/schemas' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' @@ -6,7 +6,7 @@ import { requireFeature } from '../../middleware/require-feature' import type { AdminAuditEventWithOrg } from '../../usecases/ports' import { badRequest } from '../../usecases/ports' import { listAuditEvents } from '../../usecases/site/audit' -import { errorResponse, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonContent } from '../openapi' const auditEventSchema = z .object({ @@ -43,16 +43,19 @@ const listAuditQuerySchema = pageQuerySchema.extend({ createdTo: z.string().datetime().optional(), }) -const listRoute = createRoute({ - operationId: 'listAuditEvents', - summary: 'List audit events', - tags: ['Audit'], - method: 'get', - path: '/', - middleware: [requireAdmin, requireFeature('audit_log')] as const, - request: { query: listAuditQuerySchema }, - responses: { 200: jsonContent(auditPageSchema, 'Audit events'), 400: errorResponse('Invalid query') }, -}) +const listRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listAuditEvents', + summary: 'List audit events', + tags: ['Audit'], + method: 'get', + path: '/', + middleware: [requireAdmin, requireFeature('audit_log')] as const, + request: { query: listAuditQuerySchema }, + responses: { 200: jsonContent(auditPageSchema, 'Audit events'), 400: errorResponse('Invalid query') }, + }, +) export const adminAudit = new OpenAPIHono().openapi(listRoute, async (c) => { const { page, pageSize, orgId, userId, action, targetType, createdFrom, createdTo } = c.req.valid('query') diff --git a/server/http/site/auth-providers.ts b/server/http/site/auth-providers.ts index 416f26b0..5a48f944 100644 --- a/server/http/site/auth-providers.ts +++ b/server/http/site/auth-providers.ts @@ -1,8 +1,8 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { deleteAuthProvider, listAuthProviders, upsertAuthProvider } from '../../usecases/site/auth-provider' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' // Full management shape. Public consumers receive the minimal provider projection // from configz instead. @@ -40,44 +40,53 @@ const upsertSchema = z.object({ scopes: z.array(z.string()).optional(), }) -const listRoute = createRoute({ - operationId: 'listAuthProviders', - summary: 'List auth providers', - tags: ['Auth Providers'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(authProviderListSchema, 'Auth providers') }, -}) - -const upsertRoute = createRoute({ - operationId: 'upsertAuthProvider', - summary: 'Create or update an auth provider', - tags: ['Auth Providers'], - method: 'put', - path: '/{providerId}', - middleware: [requireAdmin] as const, - request: { params: z.object({ providerId: z.string() }), ...jsonBody(upsertSchema) }, - responses: { - 200: jsonContent(authProviderSchema, 'Upserted auth provider'), - 400: errorResponse('Invalid provider'), - 402: errorResponse('Feature not available'), +const listRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listAuthProviders', + summary: 'List auth providers', + tags: ['Auth Providers'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(authProviderListSchema, 'Auth providers') }, }, -}) +) -const deleteProviderRoute = createRoute({ - operationId: 'deleteAuthProvider', - summary: 'Delete an auth provider', - tags: ['Auth Providers'], - method: 'delete', - path: '/{providerId}', - middleware: [requireAdmin] as const, - request: { params: z.object({ providerId: z.string() }) }, - responses: { - 204: { description: 'Deleted auth provider' }, - 400: errorResponse('Invalid provider'), +const upsertRoute = authRoute( + { access: 'admin' }, + { + operationId: 'upsertAuthProvider', + summary: 'Create or update an auth provider', + tags: ['Auth Providers'], + method: 'put', + path: '/{providerId}', + middleware: [requireAdmin] as const, + request: { params: z.object({ providerId: z.string() }), ...jsonBody(upsertSchema) }, + responses: { + 200: jsonContent(authProviderSchema, 'Upserted auth provider'), + 400: errorResponse('Invalid provider'), + 402: errorResponse('Feature not available'), + }, }, -}) +) + +const deleteProviderRoute = authRoute( + { access: 'admin' }, + { + operationId: 'deleteAuthProvider', + summary: 'Delete an auth provider', + tags: ['Auth Providers'], + method: 'delete', + path: '/{providerId}', + middleware: [requireAdmin] as const, + request: { params: z.object({ providerId: z.string() }) }, + responses: { + 204: { description: 'Deleted auth provider' }, + 400: errorResponse('Invalid provider'), + }, + }, +) function resolveAuthBaseUri(c: { get(key: 'platform'): Env['Variables']['platform']; req: { url: string } }): string { // Prefer the configured Better Auth base URL because OAuth providers validate diff --git a/server/http/site/branding.ts b/server/http/site/branding.ts index c2ef49ee..ed9ee2f8 100644 --- a/server/http/site/branding.ts +++ b/server/http/site/branding.ts @@ -1,11 +1,11 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { type BrandingField, type BrandingThemeMode, isBrandingThemePresetId } from '../../../shared/types' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { requireFeature } from '../../middleware/require-feature' import { AppError, badRequest, payloadTooLarge, unsupportedMediaType } from '../../usecases/ports' import { applyBrandingUpdate, resetBranding, type ThemeUpdate } from '../../usecases/site/branding' -import { errorResponse, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonContent } from '../openapi' const brandingThemeValuesSchema = z.object({ primary_color: z.string(), @@ -89,38 +89,44 @@ function parseThemeUpdate(form: FormData): { ok: true; values: ThemeUpdate } | { return { ok: true, values } } -const updateRoute = createRoute({ - operationId: 'updateBranding', - summary: 'Update branding', - tags: ['Branding'], - method: 'put', - path: '/', - middleware: [requireAdmin, requireFeature('white_label')] as const, - // Body is multipart/form-data (logo/favicon files + theme fields); parsed - // directly in the handler rather than via a request schema (the form validator - // conflicts with formData()). - responses: { - 200: jsonContent(brandingConfigSchema, 'Updated branding'), - 400: errorResponse('Invalid upload'), - 413: errorResponse('File too large'), - 415: errorResponse('Expected multipart/form-data'), - 422: errorResponse('Invalid theme or wordmark'), +const updateRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateBranding', + summary: 'Update branding', + tags: ['Branding'], + method: 'put', + path: '/', + middleware: [requireAdmin, requireFeature('white_label')] as const, + // Body is multipart/form-data (logo/favicon files + theme fields); parsed + // directly in the handler rather than via a request schema (the form validator + // conflicts with formData()). + responses: { + 200: jsonContent(brandingConfigSchema, 'Updated branding'), + 400: errorResponse('Invalid upload'), + 413: errorResponse('File too large'), + 415: errorResponse('Expected multipart/form-data'), + 422: errorResponse('Invalid theme or wordmark'), + }, }, -}) +) -const resetRoute = createRoute({ - operationId: 'resetBrandingField', - summary: 'Reset a branding field', - tags: ['Branding'], - method: 'delete', - path: '/{field}', - middleware: [requireAdmin, requireFeature('white_label')] as const, - request: { params: z.object({ field: z.string() }) }, - responses: { - 204: { description: 'Reset field' }, - 400: errorResponse('Invalid field'), +const resetRoute = authRoute( + { access: 'admin' }, + { + operationId: 'resetBrandingField', + summary: 'Reset a branding field', + tags: ['Branding'], + method: 'delete', + path: '/{field}', + middleware: [requireAdmin, requireFeature('white_label')] as const, + request: { params: z.object({ field: z.string() }) }, + responses: { + 204: { description: 'Reset field' }, + 400: errorResponse('Invalid field'), + }, }, -}) +) // Admin — requires auth + admin role + white_label feature. export const brandingAdmin = new OpenAPIHono() diff --git a/server/http/site/email-config.ts b/server/http/site/email-config.ts index 8edd6cb7..d5108de8 100644 --- a/server/http/site/email-config.ts +++ b/server/http/site/email-config.ts @@ -1,49 +1,58 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { createTestEmailSchema, emailSettingsSchema, updateEmailSettingsSchema } from '@shared/schemas' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { getEmailConfig, saveEmailConfig, sendTestEmail } from '../../usecases/site/email-config' -import { errorResponse, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonContent } from '../openapi' const successSchema = z.object({ success: z.boolean() }) -const getRoute = createRoute({ - operationId: 'getEmailConfig', - summary: 'Get email configuration', - tags: ['Email Config'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(emailSettingsSchema, 'Email settings') }, -}) - -const saveRoute = createRoute({ - operationId: 'saveEmailConfig', - summary: 'Save email configuration', - tags: ['Email Config'], - method: 'put', - path: '/', - middleware: [requireAdmin] as const, - request: { body: { content: { 'application/json': { schema: updateEmailSettingsSchema } }, required: true } }, - responses: { - 200: jsonContent(successSchema, 'Saved'), - 400: errorResponse('Invalid email configuration'), +const getRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getEmailConfig', + summary: 'Get email configuration', + tags: ['Email Config'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(emailSettingsSchema, 'Email settings') }, }, -}) +) -const testRoute = createRoute({ - operationId: 'sendTestEmail', - summary: 'Send a test email', - tags: ['Email Config'], - method: 'post', - path: '/test-messages', - middleware: [requireAdmin] as const, - request: { body: { content: { 'application/json': { schema: createTestEmailSchema } }, required: true } }, - responses: { - 200: jsonContent(successSchema, 'Sent'), - 400: errorResponse('Send failed'), +const saveRoute = authRoute( + { access: 'admin' }, + { + operationId: 'saveEmailConfig', + summary: 'Save email configuration', + tags: ['Email Config'], + method: 'put', + path: '/', + middleware: [requireAdmin] as const, + request: { body: { content: { 'application/json': { schema: updateEmailSettingsSchema } }, required: true } }, + responses: { + 200: jsonContent(successSchema, 'Saved'), + 400: errorResponse('Invalid email configuration'), + }, }, -}) +) + +const testRoute = authRoute( + { access: 'admin' }, + { + operationId: 'sendTestEmail', + summary: 'Send a test email', + tags: ['Email Config'], + method: 'post', + path: '/test-messages', + middleware: [requireAdmin] as const, + request: { body: { content: { 'application/json': { schema: createTestEmailSchema } }, required: true } }, + responses: { + 200: jsonContent(successSchema, 'Sent'), + 400: errorResponse('Send failed'), + }, + }, +) const app = new OpenAPIHono() diff --git a/server/http/site/image-domain-provider.ts b/server/http/site/image-domain-provider.ts index b1a918f4..bdbd8655 100644 --- a/server/http/site/image-domain-provider.ts +++ b/server/http/site/image-domain-provider.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { imageDomainProviderResponseSchema, updateImageDomainSettingsSchema } from '@shared/schemas' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' @@ -8,48 +8,57 @@ import { saveImageDomainProvider, testImageDomainProvider, } from '../../usecases/site/image-domain-provider' -import { errorResponse, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonContent } from '../openapi' const successSchema = z.object({ success: z.literal(true) }) -const getRoute = createRoute({ - operationId: 'getImageDomainProvider', - summary: 'Get image custom-domain provider', - tags: ['Image Domain Provider'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(imageDomainProviderResponseSchema, 'Image custom-domain provider') }, -}) +const getRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getImageDomainProvider', + summary: 'Get image custom-domain provider', + tags: ['Image Domain Provider'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(imageDomainProviderResponseSchema, 'Image custom-domain provider') }, + }, +) -const saveRoute = createRoute({ - operationId: 'saveImageDomainProvider', - summary: 'Save image custom-domain provider', - tags: ['Image Domain Provider'], - method: 'put', - path: '/', - middleware: [requireAdmin, requireFeature('image_custom_domains')] as const, - request: { - body: { content: { 'application/json': { schema: updateImageDomainSettingsSchema } }, required: true }, +const saveRoute = authRoute( + { access: 'admin' }, + { + operationId: 'saveImageDomainProvider', + summary: 'Save image custom-domain provider', + tags: ['Image Domain Provider'], + method: 'put', + path: '/', + middleware: [requireAdmin, requireFeature('image_custom_domains')] as const, + request: { + body: { content: { 'application/json': { schema: updateImageDomainSettingsSchema } }, required: true }, + }, + responses: { + 200: jsonContent(successSchema, 'Saved'), + 400: errorResponse('Invalid provider configuration'), + }, }, - responses: { - 200: jsonContent(successSchema, 'Saved'), - 400: errorResponse('Invalid provider configuration'), - }, -}) +) -const testRoute = createRoute({ - operationId: 'testImageDomainProvider', - summary: 'Test image custom-domain provider and reconcile domains', - tags: ['Image Domain Provider'], - method: 'post', - path: '/tests', - middleware: [requireAdmin, requireFeature('image_custom_domains')] as const, - responses: { - 200: jsonContent(successSchema, 'Provider ready'), - 400: errorResponse('Provider test failed'), +const testRoute = authRoute( + { access: 'admin' }, + { + operationId: 'testImageDomainProvider', + summary: 'Test image custom-domain provider and reconcile domains', + tags: ['Image Domain Provider'], + method: 'post', + path: '/tests', + middleware: [requireAdmin, requireFeature('image_custom_domains')] as const, + responses: { + 200: jsonContent(successSchema, 'Provider ready'), + 400: errorResponse('Provider test failed'), + }, }, -}) +) const app = new OpenAPIHono() diff --git a/server/http/site/invitations.ts b/server/http/site/invitations.ts index 40c0ef31..069913e2 100644 --- a/server/http/site/invitations.ts +++ b/server/http/site/invitations.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { pageQuerySchema, pageSchema } from '@shared/schemas' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' @@ -10,7 +10,7 @@ import { resendSiteInvitation, revokeSiteInvitation, } from '../../usecases/site/invitation' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' // SiteInvitation is already wire-shaped (ISO string timestamps) — no DTO mapper. const siteInvitationSchema = z @@ -35,75 +35,90 @@ const siteInvitationListSchema = pageSchema(siteInvitationSchema, 'SiteInvitatio const createSchema = z.object({ email: z.string().email() }) -const listRoute = createRoute({ - operationId: 'listSiteInvitations', - summary: 'List site invitations', - tags: ['Invitations'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - request: { query: pageQuerySchema }, - responses: { 200: jsonContent(siteInvitationListSchema, 'Invitations') }, -}) - -const createRouteDoc = createRoute({ - operationId: 'createSiteInvitation', - summary: 'Create site invitation', - tags: ['Invitations'], - method: 'post', - path: '/', - middleware: [requireAdmin] as const, - request: jsonBody(createSchema), - responses: { - 201: jsonContent(siteInvitationSchema, 'Created invitation'), - 401: errorResponse('Unauthorized'), - 409: errorResponse('Invitation conflict'), +const listRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listSiteInvitations', + summary: 'List site invitations', + tags: ['Invitations'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + request: { query: pageQuerySchema }, + responses: { 200: jsonContent(siteInvitationListSchema, 'Invitations') }, }, -}) +) -const resendRoute = createRoute({ - operationId: 'resendSiteInvitation', - summary: 'Resend a site invitation', - tags: ['Invitations'], - method: 'post', - path: '/{id}/deliveries', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(siteInvitationSchema, 'Resent invitation'), - 400: errorResponse('Invitation is no longer pending'), - 404: errorResponse('Invitation not found'), +const createRouteDoc = authRoute( + { access: 'admin' }, + { + operationId: 'createSiteInvitation', + summary: 'Create site invitation', + tags: ['Invitations'], + method: 'post', + path: '/', + middleware: [requireAdmin] as const, + request: jsonBody(createSchema), + responses: { + 201: jsonContent(siteInvitationSchema, 'Created invitation'), + 401: errorResponse('Unauthorized'), + 409: errorResponse('Invitation conflict'), + }, }, -}) +) -const revokeRoute = createRoute({ - operationId: 'revokeSiteInvitation', - summary: 'Revoke a site invitation', - tags: ['Invitations'], - method: 'delete', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Revoked invitation' }, - 400: errorResponse('Invitation is no longer pending'), - 401: errorResponse('Unauthorized'), - 404: errorResponse('Invitation not found'), +const resendRoute = authRoute( + { access: 'admin' }, + { + operationId: 'resendSiteInvitation', + summary: 'Resend a site invitation', + tags: ['Invitations'], + method: 'post', + path: '/{id}/deliveries', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(siteInvitationSchema, 'Resent invitation'), + 400: errorResponse('Invitation is no longer pending'), + 404: errorResponse('Invitation not found'), + }, }, -}) +) -const getByTokenRoute = createRoute({ - operationId: 'getSiteInvitation', - summary: 'Get a site invitation by token', - tags: ['Invitations'], - method: 'get', - path: '/{token}', - request: { params: z.object({ token: z.string() }) }, - responses: { - 200: jsonContent(siteInvitationSchema, 'Invitation'), - 404: errorResponse('Invitation not found'), +const revokeRoute = authRoute( + { access: 'admin' }, + { + operationId: 'revokeSiteInvitation', + summary: 'Revoke a site invitation', + tags: ['Invitations'], + method: 'delete', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Revoked invitation' }, + 400: errorResponse('Invitation is no longer pending'), + 401: errorResponse('Unauthorized'), + 404: errorResponse('Invitation not found'), + }, }, -}) +) + +const getByTokenRoute = authRoute( + { access: 'public' }, + { + operationId: 'getSiteInvitation', + summary: 'Get a site invitation by token', + tags: ['Invitations'], + method: 'get', + path: '/{token}', + request: { params: z.object({ token: z.string() }) }, + responses: { + 200: jsonContent(siteInvitationSchema, 'Invitation'), + 404: errorResponse('Invitation not found'), + }, + }, +) export const adminSiteInvitations = new OpenAPIHono() .openapi(listRoute, async (c) => { diff --git a/server/http/site/invite-codes.ts b/server/http/site/invite-codes.ts index 108d61d7..67df196f 100644 --- a/server/http/site/invite-codes.ts +++ b/server/http/site/invite-codes.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { pageQuerySchema, pageSchema } from '@shared/schemas' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' @@ -9,7 +9,7 @@ import { listInviteCodes, validateInviteCode, } from '../../usecases/site/invite-code' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' const inviteCodeSchema = z .object({ @@ -48,57 +48,69 @@ const validateSchema = z.object({ .regex(/^[0-9A-Z]{8}$/), }) -const listRoute = createRoute({ - operationId: 'listInviteCodes', - summary: 'List invite codes', - tags: ['Invite Codes'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - request: { query: pageQuerySchema }, - responses: { 200: jsonContent(inviteCodeListSchema, 'Invite codes') }, -}) - -const generateRoute = createRoute({ - operationId: 'generateInviteCodes', - summary: 'Generate invite codes', - tags: ['Invite Codes'], - method: 'post', - path: '/', - middleware: [requireAdmin] as const, - request: jsonBody(generateSchema), - responses: { - 201: jsonContent(z.object({ codes: z.array(inviteCodeSchema) }), 'Generated invite codes'), - 401: errorResponse('Unauthorized'), +const listRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listInviteCodes', + summary: 'List invite codes', + tags: ['Invite Codes'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + request: { query: pageQuerySchema }, + responses: { 200: jsonContent(inviteCodeListSchema, 'Invite codes') }, }, -}) +) -const deleteRoute = createRoute({ - operationId: 'deleteInviteCode', - summary: 'Delete invite code', - tags: ['Invite Codes'], - method: 'delete', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Deleted invite code' }, - 409: errorResponse('Cannot delete a used invite code'), - 404: errorResponse('Invite code not found'), +const generateRoute = authRoute( + { access: 'admin' }, + { + operationId: 'generateInviteCodes', + summary: 'Generate invite codes', + tags: ['Invite Codes'], + method: 'post', + path: '/', + middleware: [requireAdmin] as const, + request: jsonBody(generateSchema), + responses: { + 201: jsonContent(z.object({ codes: z.array(inviteCodeSchema) }), 'Generated invite codes'), + 401: errorResponse('Unauthorized'), + }, }, -}) +) -const validateRoute = createRoute({ - operationId: 'validateInviteCode', - summary: 'Validate an invite code', - tags: ['Invite Codes'], - method: 'post', - path: '/validations', - request: jsonBody(validateSchema), - responses: { - 200: jsonContent(z.object({ valid: z.boolean(), error: z.string().optional() }), 'Validation result'), +const deleteRoute = authRoute( + { access: 'admin' }, + { + operationId: 'deleteInviteCode', + summary: 'Delete invite code', + tags: ['Invite Codes'], + method: 'delete', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Deleted invite code' }, + 409: errorResponse('Cannot delete a used invite code'), + 404: errorResponse('Invite code not found'), + }, }, -}) +) + +const validateRoute = authRoute( + { access: 'public' }, + { + operationId: 'validateInviteCode', + summary: 'Validate an invite code', + tags: ['Invite Codes'], + method: 'post', + path: '/validations', + request: jsonBody(validateSchema), + responses: { + 200: jsonContent(z.object({ valid: z.boolean(), error: z.string().optional() }), 'Validation result'), + }, + }, +) export const adminInviteCodes = new OpenAPIHono() .openapi(listRoute, async (c) => { diff --git a/server/http/site/licensing.ts b/server/http/site/licensing.ts index 3d8db829..9aab5fdf 100644 --- a/server/http/site/licensing.ts +++ b/server/http/site/licensing.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import type { Context } from 'hono' import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants' import type { BindingState } from '../../../shared/types' @@ -15,7 +15,7 @@ import { unbindLicense, } from '../../usecases/site/licensing' import { getSitePublicOrigin } from '../../usecases/site/public-origin' -import { errorResponse, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonContent } from '../openapi' function getCloudBaseUrl(c: Context): string { return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT @@ -78,74 +78,92 @@ const pairingStatusSchema = z .object({ status: z.string(), edition: z.string().optional(), cloud_store_id: z.string().optional() }) .openapi('LicensePairingStatus') -const entitlementsRoute = createRoute({ - operationId: 'getLicenseEntitlements', - summary: 'Get the current user-visible license entitlements', - tags: ['Licensing'], - method: 'get', - path: '/entitlements', - middleware: [requireAuth] as const, - responses: { 200: jsonContent(licenseEntitlementsSchema, 'License entitlements') }, -}) - -const bindingRoute = createRoute({ - operationId: 'getLicenseBinding', - summary: 'Get license binding details', - tags: ['Licensing'], - method: 'get', - path: '/binding', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(bindingStateSchema, 'License binding') }, -}) - -const initiatePairingRoute = createRoute({ - operationId: 'initiateLicensePairing', - summary: 'Initiate cloud pairing', - tags: ['Licensing'], - method: 'post', - path: '/pairings', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(pairingSchema, 'Pairing') }, -}) - -const pollPairingRoute = createRoute({ - operationId: 'pollLicensePairing', - summary: 'Poll cloud pairing status', - tags: ['Licensing'], - method: 'get', - path: '/pairings/{code}', - middleware: [requireAdmin] as const, - request: { params: z.object({ code: z.string() }) }, - responses: { - 200: jsonContent(pairingStatusSchema, 'Pairing status'), - 502: errorResponse('Cloud error'), +const entitlementsRoute = authRoute( + { access: 'session' }, + { + operationId: 'getLicenseEntitlements', + summary: 'Get the current user-visible license entitlements', + tags: ['Licensing'], + method: 'get', + path: '/entitlements', + middleware: [requireAuth] as const, + responses: { 200: jsonContent(licenseEntitlementsSchema, 'License entitlements') }, }, -}) +) -const refreshRoute = createRoute({ - operationId: 'refreshLicense', - summary: 'Refresh the license', - tags: ['Licensing'], - method: 'post', - path: '/refresh-runs', - middleware: [requireAdmin] as const, - responses: { - 200: jsonContent(z.object({ success: z.boolean(), last_refresh_at: z.number().int().nullable() }), 'Refreshed'), +const bindingRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getLicenseBinding', + summary: 'Get license binding details', + tags: ['Licensing'], + method: 'get', + path: '/binding', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(bindingStateSchema, 'License binding') }, }, -}) +) -const unbindRoute = createRoute({ - operationId: 'unbindLicense', - summary: 'Unbind the license', - tags: ['Licensing'], - method: 'delete', - path: '/binding', - middleware: [requireAdmin] as const, - responses: { - 204: { description: 'Unbound' }, - 502: errorResponse('Cloud unbind failed'), +const initiatePairingRoute = authRoute( + { access: 'admin' }, + { + operationId: 'initiateLicensePairing', + summary: 'Initiate cloud pairing', + tags: ['Licensing'], + method: 'post', + path: '/pairings', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(pairingSchema, 'Pairing') }, }, -}) +) + +const pollPairingRoute = authRoute( + { access: 'admin' }, + { + operationId: 'pollLicensePairing', + summary: 'Poll cloud pairing status', + tags: ['Licensing'], + method: 'get', + path: '/pairings/{code}', + middleware: [requireAdmin] as const, + request: { params: z.object({ code: z.string() }) }, + responses: { + 200: jsonContent(pairingStatusSchema, 'Pairing status'), + 502: errorResponse('Cloud error'), + }, + }, +) + +const refreshRoute = authRoute( + { access: 'admin' }, + { + operationId: 'refreshLicense', + summary: 'Refresh the license', + tags: ['Licensing'], + method: 'post', + path: '/refresh-runs', + middleware: [requireAdmin] as const, + responses: { + 200: jsonContent(z.object({ success: z.boolean(), last_refresh_at: z.number().int().nullable() }), 'Refreshed'), + }, + }, +) + +const unbindRoute = authRoute( + { access: 'admin' }, + { + operationId: 'unbindLicense', + summary: 'Unbind the license', + tags: ['Licensing'], + method: 'delete', + path: '/binding', + middleware: [requireAdmin] as const, + responses: { + 204: { description: 'Unbound' }, + 502: errorResponse('Cloud unbind failed'), + }, + }, +) async function loadCurrentBinding(c: Context) { const cloudBaseUrl = getCloudBaseUrl(c) diff --git a/server/http/site/settings.ts b/server/http/site/settings.ts index 3f278535..eaa42506 100644 --- a/server/http/site/settings.ts +++ b/server/http/site/settings.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono } from '@hono/zod-openapi' +import { OpenAPIHono } from '@hono/zod-openapi' import { siteCaptchaSettingsSchema, siteIdentitySettingsSchema, @@ -23,95 +23,116 @@ import { updateSiteWebDav, verifySiteWebDav, } from '../../usecases/site/settings' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' -const getRoute = createRoute({ - operationId: 'getSiteSettings', - summary: 'Get editable site settings', - tags: ['Site Settings'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(siteSettingsSchema, 'Editable site settings') }, -}) - -const updateIdentityRoute = createRoute({ - operationId: 'updateSiteIdentity', - summary: 'Update site identity settings', - tags: ['Site Settings'], - method: 'put', - path: '/identity', - middleware: [requireAdmin] as const, - request: jsonBody(updateSiteIdentitySchema), - responses: { - 200: jsonContent(siteIdentitySettingsSchema, 'Updated site identity settings'), - 400: errorResponse('Invalid site identity'), - 402: errorResponse('Feature not available'), +const getRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getSiteSettings', + summary: 'Get editable site settings', + tags: ['Site Settings'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(siteSettingsSchema, 'Editable site settings') }, }, -}) +) -const updateRegistrationRoute = createRoute({ - operationId: 'updateSiteRegistration', - summary: 'Update registration settings', - tags: ['Site Settings'], - method: 'put', - path: '/registration', - middleware: [requireAdmin] as const, - request: jsonBody(updateSiteRegistrationSchema), - responses: { - 200: jsonContent(siteRegistrationSettingsSchema, 'Updated registration settings'), - 402: errorResponse('Feature not available'), +const updateIdentityRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateSiteIdentity', + summary: 'Update site identity settings', + tags: ['Site Settings'], + method: 'put', + path: '/identity', + middleware: [requireAdmin] as const, + request: jsonBody(updateSiteIdentitySchema), + responses: { + 200: jsonContent(siteIdentitySettingsSchema, 'Updated site identity settings'), + 400: errorResponse('Invalid site identity'), + 402: errorResponse('Feature not available'), + }, }, -}) +) -const updateCaptchaRoute = createRoute({ - operationId: 'updateSiteCaptcha', - summary: 'Update captcha settings', - tags: ['Site Settings'], - method: 'put', - path: '/captcha', - middleware: [requireAdmin] as const, - request: jsonBody(updateSiteCaptchaSchema), - responses: { - 200: jsonContent(siteCaptchaSettingsSchema, 'Updated captcha settings'), - 400: errorResponse('Invalid captcha settings'), +const updateRegistrationRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateSiteRegistration', + summary: 'Update registration settings', + tags: ['Site Settings'], + method: 'put', + path: '/registration', + middleware: [requireAdmin] as const, + request: jsonBody(updateSiteRegistrationSchema), + responses: { + 200: jsonContent(siteRegistrationSettingsSchema, 'Updated registration settings'), + 402: errorResponse('Feature not available'), + }, }, -}) +) -const updateQuotasRoute = createRoute({ - operationId: 'updateSiteQuotas', - summary: 'Update default quota settings', - tags: ['Site Settings'], - method: 'put', - path: '/quotas', - middleware: [requireAdmin] as const, - request: jsonBody(updateSiteQuotasSchema), - responses: { 200: jsonContent(siteQuotaSettingsSchema, 'Updated quota settings') }, -}) - -const verifyWebDavRoute = createRoute({ - operationId: 'verifySiteWebDav', - summary: 'Verify the configured or derived WebDAV domain', - tags: ['Site Settings'], - method: 'post', - path: '/webdav/verifications', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(siteWebDavSettingsSchema, 'Current WebDAV verification status') }, -}) - -const updateWebDavRoute = createRoute({ - operationId: 'updateSiteWebDav', - summary: 'Update WebDAV settings', - tags: ['Site Settings'], - method: 'put', - path: '/webdav', - middleware: [requireAdmin] as const, - request: jsonBody(updateSiteWebDavSchema), - responses: { - 200: jsonContent(siteWebDavSettingsSchema, 'Updated WebDAV settings'), - 400: errorResponse('Invalid WebDAV settings'), +const updateCaptchaRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateSiteCaptcha', + summary: 'Update captcha settings', + tags: ['Site Settings'], + method: 'put', + path: '/captcha', + middleware: [requireAdmin] as const, + request: jsonBody(updateSiteCaptchaSchema), + responses: { + 200: jsonContent(siteCaptchaSettingsSchema, 'Updated captcha settings'), + 400: errorResponse('Invalid captcha settings'), + }, }, -}) +) + +const updateQuotasRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateSiteQuotas', + summary: 'Update default quota settings', + tags: ['Site Settings'], + method: 'put', + path: '/quotas', + middleware: [requireAdmin] as const, + request: jsonBody(updateSiteQuotasSchema), + responses: { 200: jsonContent(siteQuotaSettingsSchema, 'Updated quota settings') }, + }, +) + +const verifyWebDavRoute = authRoute( + { access: 'admin' }, + { + operationId: 'verifySiteWebDav', + summary: 'Verify the configured or derived WebDAV domain', + tags: ['Site Settings'], + method: 'post', + path: '/webdav/verifications', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(siteWebDavSettingsSchema, 'Current WebDAV verification status') }, + }, +) + +const updateWebDavRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateSiteWebDav', + summary: 'Update WebDAV settings', + tags: ['Site Settings'], + method: 'put', + path: '/webdav', + middleware: [requireAdmin] as const, + request: jsonBody(updateSiteWebDavSchema), + responses: { + 200: jsonContent(siteWebDavSettingsSchema, 'Updated WebDAV settings'), + 400: errorResponse('Invalid WebDAV settings'), + }, + }, +) export const siteSettings = new OpenAPIHono() .openapi(getRoute, async (c) => c.json(await getSiteSettings(c.get('deps'), c.req.url), 200)) diff --git a/server/http/site/storages.ts b/server/http/site/storages.ts index 0f9b00e0..c54ba63f 100644 --- a/server/http/site/storages.ts +++ b/server/http/site/storages.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { createStorageSchema, pageSchema, @@ -18,7 +18,7 @@ import { replaceStorage, updateStorageEgressBilling, } from '../../usecases/site/storage' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' // Admin storage config. The response intentionally includes the S3 credentials // (accessKey/secretKey) so the admin UI can pre-fill the edit form — admin-only. @@ -63,103 +63,124 @@ function toStorageDTO(s: StorageRecord): StorageDTO { const storageListSchema = pageSchema(storageSchema, 'StorageList') -const listRoute = createRoute({ - operationId: 'listStorages', - summary: 'List storages', - tags: ['Storages'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(storageListSchema, 'Storages') }, -}) - -const createStorageRoute = createRoute({ - operationId: 'createStorage', - summary: 'Create storage', - tags: ['Storages'], - method: 'post', - path: '/', - middleware: [requireAdmin] as const, - request: jsonBody(createStorageSchema), - responses: { - 201: jsonContent(storageSchema, 'Created storage'), - 402: errorResponse('Feature not available'), +const listRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listStorages', + summary: 'List storages', + tags: ['Storages'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(storageListSchema, 'Storages') }, }, -}) +) -const getStorageRoute = createRoute({ - operationId: 'getStorage', - summary: 'Get storage', - tags: ['Storages'], - method: 'get', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 200: jsonContent(storageSchema, 'Storage'), - 404: errorResponse('Storage not found'), +const createStorageRoute = authRoute( + { access: 'admin' }, + { + operationId: 'createStorage', + summary: 'Create storage', + tags: ['Storages'], + method: 'post', + path: '/', + middleware: [requireAdmin] as const, + request: jsonBody(createStorageSchema), + responses: { + 201: jsonContent(storageSchema, 'Created storage'), + 402: errorResponse('Feature not available'), + }, }, -}) +) -const replaceStorageRoute = createRoute({ - operationId: 'replaceStorage', - summary: 'Replace storage', - tags: ['Storages'], - method: 'put', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(replaceStorageSchema) }, - responses: { - 200: jsonContent(storageSchema, 'Replaced storage'), - 402: errorResponse('Feature not available'), - 404: errorResponse('Storage not found'), +const getStorageRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getStorage', + summary: 'Get storage', + tags: ['Storages'], + method: 'get', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: jsonContent(storageSchema, 'Storage'), + 404: errorResponse('Storage not found'), + }, }, -}) +) -const patchStorageRoute = createRoute({ - operationId: 'patchStorage', - summary: 'Patch storage', - tags: ['Storages'], - method: 'patch', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(patchStorageSchema) }, - responses: { - 200: jsonContent(storageSchema, 'Updated storage'), - 402: errorResponse('Feature not available'), - 404: errorResponse('Storage not found'), +const replaceStorageRoute = authRoute( + { access: 'admin' }, + { + operationId: 'replaceStorage', + summary: 'Replace storage', + tags: ['Storages'], + method: 'put', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(replaceStorageSchema) }, + responses: { + 200: jsonContent(storageSchema, 'Replaced storage'), + 402: errorResponse('Feature not available'), + 404: errorResponse('Storage not found'), + }, }, -}) +) -const updateStorageEgressBillingRoute = createRoute({ - operationId: 'updateStorageEgressBilling', - summary: 'Update storage egress billing', - tags: ['Storages'], - method: 'put', - path: '/{id}/egress-billing', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageEgressBillingSchema) }, - responses: { - 200: jsonContent(storageSchema, 'Updated storage'), - 402: errorResponse('Feature not available'), - 404: errorResponse('Storage not found'), +const patchStorageRoute = authRoute( + { access: 'admin' }, + { + operationId: 'patchStorage', + summary: 'Patch storage', + tags: ['Storages'], + method: 'patch', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(patchStorageSchema) }, + responses: { + 200: jsonContent(storageSchema, 'Updated storage'), + 402: errorResponse('Feature not available'), + 404: errorResponse('Storage not found'), + }, }, -}) +) -const deleteStorageRoute = createRoute({ - operationId: 'deleteStorage', - summary: 'Delete storage', - tags: ['Storages'], - method: 'delete', - path: '/{id}', - middleware: [requireAdmin] as const, - request: { params: z.object({ id: z.string() }) }, - responses: { - 204: { description: 'Deleted storage' }, - 404: errorResponse('Storage not found'), - 409: errorResponse('Storage is referenced by existing files'), +const updateStorageEgressBillingRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateStorageEgressBilling', + summary: 'Update storage egress billing', + tags: ['Storages'], + method: 'put', + path: '/{id}/egress-billing', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageEgressBillingSchema) }, + responses: { + 200: jsonContent(storageSchema, 'Updated storage'), + 402: errorResponse('Feature not available'), + 404: errorResponse('Storage not found'), + }, }, -}) +) + +const deleteStorageRoute = authRoute( + { access: 'admin' }, + { + operationId: 'deleteStorage', + summary: 'Delete storage', + tags: ['Storages'], + method: 'delete', + path: '/{id}', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: 'Deleted storage' }, + 404: errorResponse('Storage not found'), + 409: errorResponse('Storage is referenced by existing files'), + }, + }, +) const storages = new OpenAPIHono() .openapi(listRoute, async (c) => { diff --git a/server/http/site/system.ts b/server/http/site/system.ts index 6d2de27b..99de67d5 100644 --- a/server/http/site/system.ts +++ b/server/http/site/system.ts @@ -1,9 +1,9 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { runtimeInfo } from '../../usecases/site/instance-info' import { getChangelog, resolveInstanceInfo } from '../../usecases/site/system' -import { jsonContent } from '../openapi' +import { authRoute, jsonContent } from '../openapi' const instanceInfoSchema = z .object({ @@ -40,26 +40,32 @@ const changelogSchema = z }) .openapi('Changelog') -const instanceRoute = createRoute({ - operationId: 'getInstanceInfo', - summary: 'Get instance info', - tags: ['System'], - method: 'get', - path: '/instance', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(instanceInfoSchema, 'Instance info') }, -}) +const instanceRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getInstanceInfo', + summary: 'Get instance info', + tags: ['System'], + method: 'get', + path: '/instance', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(instanceInfoSchema, 'Instance info') }, + }, +) -const changelogRoute = createRoute({ - operationId: 'getChangelog', - summary: 'Get changelog', - tags: ['System'], - method: 'get', - path: '/changelog', - middleware: [requireAdmin] as const, - request: { query: z.object({ refresh: z.string().optional() }) }, - responses: { 200: jsonContent(changelogSchema, 'Changelog') }, -}) +const changelogRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getChangelog', + summary: 'Get changelog', + tags: ['System'], + method: 'get', + path: '/changelog', + middleware: [requireAdmin] as const, + request: { query: z.object({ refresh: z.string().optional() }) }, + responses: { 200: jsonContent(changelogSchema, 'Changelog') }, + }, +) const system = new OpenAPIHono() .openapi(instanceRoute, async (c) => { diff --git a/server/http/storage-usage.ts b/server/http/storage-usage.ts index 771ddb1b..67d88ee4 100644 --- a/server/http/storage-usage.ts +++ b/server/http/storage-usage.ts @@ -1,10 +1,10 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { STORAGE_USAGE_CATEGORIES, STORAGE_USAGE_SORT_FIELDS } from '@shared/storage-usage' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' import { notFound } from '../usecases/ports' import { getStorageUsage, listStorageUsageItems } from '../usecases/storage-usage-dashboard' -import { jsonContent } from './openapi' +import { authRoute, jsonContent } from './openapi' const categorySchema = z.enum(STORAGE_USAGE_CATEGORIES) const sortFieldSchema = z.enum(STORAGE_USAGE_SORT_FIELDS) @@ -34,42 +34,50 @@ const itemSchema = z.object({ source: z.enum(['files', 'image_hosting', 'trash']), }) -const getUsageRoute = createRoute({ - operationId: 'getStorageUsage', - summary: 'Get current storage usage by category', - tags: ['Storage Usage'], - method: 'get', - path: '/', - responses: { 200: jsonContent(usageSchema, 'Storage usage') }, -}) +const getUsageRoute = authRoute( + { access: 'session' }, + { + operationId: 'getStorageUsage', + summary: 'Get current storage usage by category', + tags: ['Storage Usage'], + method: 'get', + path: '/', + middleware: [requireAuth] as const, + responses: { 200: jsonContent(usageSchema, 'Storage usage') }, + }, +) -const listItemsRoute = createRoute({ - operationId: 'listStorageUsageItems', - summary: 'List files in a storage usage category', - tags: ['Storage Usage'], - method: 'get', - path: '/items', - request: { - query: z.object({ - category: categorySchema, - page: z.coerce.number().int().min(1).default(1), - pageSize: z.coerce.number().int().min(1).max(100).default(20), - sortBy: sortFieldSchema.default('size'), - sortDir: z.enum(['asc', 'desc']).default('desc'), - }), - }, - responses: { - 200: jsonContent( - z.object({ - items: z.array(itemSchema), - total: z.number().int(), - page: z.number().int(), - pageSize: z.number().int(), +const listItemsRoute = authRoute( + { access: 'session' }, + { + operationId: 'listStorageUsageItems', + summary: 'List files in a storage usage category', + tags: ['Storage Usage'], + method: 'get', + path: '/items', + middleware: [requireAuth] as const, + request: { + query: z.object({ + category: categorySchema, + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + sortBy: sortFieldSchema.default('size'), + sortDir: z.enum(['asc', 'desc']).default('desc'), }), - 'Storage usage items', - ), + }, + responses: { + 200: jsonContent( + z.object({ + items: z.array(itemSchema), + total: z.number().int(), + page: z.number().int(), + pageSize: z.number().int(), + }), + 'Storage usage items', + ), + }, }, -}) +) function requireOrg(c: { get(key: 'orgId'): string | null }) { const orgId = c.get('orgId') @@ -78,7 +86,6 @@ function requireOrg(c: { get(key: 'orgId'): string | null }) { } const app = new OpenAPIHono() -app.use(requireAuth) const storageUsage = app .openapi(getUsageRoute, async (c) => c.json(await getStorageUsage(c.get('deps'), requireOrg(c)), 200)) diff --git a/server/http/store/storefront.ts b/server/http/store/storefront.ts index f089f2f0..2ddfb3ba 100644 --- a/server/http/store/storefront.ts +++ b/server/http/store/storefront.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { checkoutInputSchema, discountQuoteInputSchema, redeemGiftCardInputSchema } from '@shared/schemas' import { requireAuth, requireTeamRole } from '../../middleware/auth' import type { Env } from '../../middleware/platform' @@ -18,7 +18,7 @@ import { listTargets, redeemGiftCard, } from '../../usecases/store/store' -import { errorResponse, jsonBody, jsonContent } from '../openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi' import { cloudStoreOrdersQuerySchema, getCloudBaseUrl } from './helpers' import { getCloudOrders, getInstanceOrigin } from './shared' @@ -28,182 +28,228 @@ import { getCloudOrders, getInstanceOrigin } from './shared' const cloudValue = z.unknown().openapi('CloudStoreValue') const cloudBody = (description: string) => jsonContent(cloudValue, description) -const packagesRoute = createRoute({ - operationId: 'listStorePackages', - summary: 'List store packages', - tags: ['Store'], - method: 'get', - path: '/packages', - responses: { 200: cloudBody('Packages'), 403: errorResponse('License not bound'), 502: errorResponse('Cloud error') }, -}) - -const creditProductsRoute = createRoute({ - operationId: 'listCreditProducts', - summary: 'List credit products', - tags: ['Store'], - method: 'get', - path: '/credits/products', - responses: { - 200: cloudBody('Credit products'), - 403: errorResponse('License not bound'), - 502: errorResponse('Cloud error'), +const packagesRoute = authRoute( + { access: 'session' }, + { + operationId: 'listStorePackages', + summary: 'List store packages', + tags: ['Store'], + method: 'get', + path: '/packages', + middleware: [requireAuth, requireFeature('quota_store')] as const, + responses: { + 200: cloudBody('Packages'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const targetsRoute = createRoute({ - operationId: 'listStoreTargets', - summary: 'List store targets', - tags: ['Store'], - method: 'get', - path: '/targets', - responses: { 200: cloudBody('Targets'), 403: errorResponse('License not bound'), 502: errorResponse('Cloud error') }, -}) - -const creditsRoute = createRoute({ - operationId: 'getCreditBalance', - summary: 'Get credit balance', - tags: ['Store'], - method: 'get', - path: '/credits', - middleware: [requireTeamRole('owner')] as const, - responses: { - 200: cloudBody('Credit balance'), - 400: errorResponse('No active organization'), - 403: errorResponse('License not bound'), - 502: errorResponse('Cloud error'), +const creditProductsRoute = authRoute( + { access: 'session' }, + { + operationId: 'listCreditProducts', + summary: 'List credit products', + tags: ['Store'], + method: 'get', + path: '/credits/products', + middleware: [requireAuth, requireFeature('quota_store')] as const, + responses: { + 200: cloudBody('Credit products'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const ledgerRoute = createRoute({ - operationId: 'getCreditLedger', - summary: 'Get credit ledger', - tags: ['Store'], - method: 'get', - path: '/credits/ledger-entries', - middleware: [requireTeamRole('owner')] as const, - responses: { - 200: cloudBody('Credit ledger'), - 400: errorResponse('No active organization'), - 403: errorResponse('License not bound'), - 502: errorResponse('Cloud error'), +const targetsRoute = authRoute( + { access: 'session' }, + { + operationId: 'listStoreTargets', + summary: 'List store targets', + tags: ['Store'], + method: 'get', + path: '/targets', + middleware: [requireAuth, requireFeature('quota_store')] as const, + responses: { + 200: cloudBody('Targets'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const redeemRoute = createRoute({ - operationId: 'redeemGiftCard', - summary: 'Redeem a gift card', - tags: ['Store'], - method: 'post', - path: '/credits/redemptions', - middleware: [requireTeamRole('owner')] as const, - request: jsonBody(redeemGiftCardInputSchema), - responses: { - 200: cloudBody('Redemption result'), - 400: errorResponse('No active organization'), - 403: errorResponse('License not bound'), - 502: errorResponse('Cloud error'), +const creditsRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'getCreditBalance', + summary: 'Get credit balance', + tags: ['Store'], + method: 'get', + path: '/credits', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + responses: { + 200: cloudBody('Credit balance'), + 400: errorResponse('No active organization'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const checkoutRoute = createRoute({ - operationId: 'createCheckout', - summary: 'Create a checkout', - tags: ['Store'], - method: 'post', - path: '/checkouts', - middleware: [requireTeamRole('owner')] as const, - request: jsonBody(checkoutInputSchema), - responses: { - 200: cloudBody('Checkout session'), - 400: errorResponse('Bad request'), - 403: errorResponse('License not bound'), - 409: errorResponse('Workspace plan already exists'), - 502: errorResponse('Cloud error'), +const ledgerRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'getCreditLedger', + summary: 'Get credit ledger', + tags: ['Store'], + method: 'get', + path: '/credits/ledger-entries', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + responses: { + 200: cloudBody('Credit ledger'), + 400: errorResponse('No active organization'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const discountRoute = createRoute({ - operationId: 'getDiscountQuote', - summary: 'Get a discount quote', - tags: ['Store'], - method: 'post', - path: '/discount-quotes', - request: jsonBody(discountQuoteInputSchema), - responses: { - 200: cloudBody('Discount quote'), - 403: errorResponse('License not bound'), - 502: errorResponse('Cloud error'), +const redeemRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'redeemGiftCard', + summary: 'Redeem a gift card', + tags: ['Store'], + method: 'post', + path: '/credits/redemptions', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + request: jsonBody(redeemGiftCardInputSchema), + responses: { + 200: cloudBody('Redemption result'), + 400: errorResponse('No active organization'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const billingPortalRoute = createRoute({ - operationId: 'createBillingPortalSession', - summary: 'Create a billing portal session', - tags: ['Store'], - method: 'post', - path: '/billing-portal-sessions', - middleware: [requireTeamRole('owner')] as const, - responses: { - 200: cloudBody('Billing portal session'), - 400: errorResponse('No active organization'), - 403: errorResponse('License not bound'), - 502: errorResponse('Cloud error'), +const checkoutRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'createCheckout', + summary: 'Create a checkout', + tags: ['Store'], + method: 'post', + path: '/checkouts', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + request: jsonBody(checkoutInputSchema), + responses: { + 200: cloudBody('Checkout session'), + 400: errorResponse('Bad request'), + 403: errorResponse('License not bound'), + 409: errorResponse('Workspace plan already exists'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const ordersRoute = createRoute({ - operationId: 'listOrders', - summary: 'List orders', - tags: ['Store'], - method: 'get', - path: '/orders', - middleware: [requireTeamRole('owner')] as const, - request: { query: cloudStoreOrdersQuerySchema }, - responses: { - 200: cloudBody('Orders'), - 400: errorResponse('No active organization'), - 403: errorResponse('Store not ready'), - 502: errorResponse('Cloud error'), +const discountRoute = authRoute( + { access: 'session' }, + { + operationId: 'getDiscountQuote', + summary: 'Get a discount quote', + tags: ['Store'], + method: 'post', + path: '/discount-quotes', + middleware: [requireAuth, requireFeature('quota_store')] as const, + request: jsonBody(discountQuoteInputSchema), + responses: { + 200: cloudBody('Discount quote'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const continuePaymentRoute = createRoute({ - operationId: 'continueOrderPayment', - summary: 'Continue an order payment', - tags: ['Store'], - method: 'post', - path: '/orders/{orderId}/payments', - middleware: [requireTeamRole('owner')] as const, - request: { params: z.object({ orderId: z.string() }) }, - responses: { - 200: cloudBody('Payment continuation'), - 400: errorResponse('No active organization'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Order not found'), - 502: errorResponse('Cloud error'), +const billingPortalRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'createBillingPortalSession', + summary: 'Create a billing portal session', + tags: ['Store'], + method: 'post', + path: '/billing-portal-sessions', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + responses: { + 200: cloudBody('Billing portal session'), + 400: errorResponse('No active organization'), + 403: errorResponse('License not bound'), + 502: errorResponse('Cloud error'), + }, }, -}) +) -const cancelOrderRoute = createRoute({ - operationId: 'cancelOrder', - summary: 'Cancel an order', - tags: ['Store'], - method: 'put', - path: '/orders/{orderId}/status', - middleware: [requireTeamRole('owner')] as const, - request: { params: z.object({ orderId: z.string() }), ...jsonBody(z.object({ status: z.literal('canceled') })) }, - responses: { - 200: cloudBody('Canceled order'), - 400: errorResponse('No active organization'), - 403: errorResponse('Forbidden'), - 404: errorResponse('Order not found'), - 502: errorResponse('Cloud error'), +const ordersRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'listOrders', + summary: 'List orders', + tags: ['Store'], + method: 'get', + path: '/orders', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + request: { query: cloudStoreOrdersQuerySchema }, + responses: { + 200: cloudBody('Orders'), + 400: errorResponse('No active organization'), + 403: errorResponse('Store not ready'), + 502: errorResponse('Cloud error'), + }, }, -}) +) + +const continuePaymentRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'continueOrderPayment', + summary: 'Continue an order payment', + tags: ['Store'], + method: 'post', + path: '/orders/{orderId}/payments', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + request: { params: z.object({ orderId: z.string() }) }, + responses: { + 200: cloudBody('Payment continuation'), + 400: errorResponse('No active organization'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Order not found'), + 502: errorResponse('Cloud error'), + }, + }, +) + +const cancelOrderRoute = authRoute( + { access: 'session', minTeamRole: 'owner' }, + { + operationId: 'cancelOrder', + summary: 'Cancel an order', + tags: ['Store'], + method: 'put', + path: '/orders/{orderId}/status', + middleware: [requireAuth, requireFeature('quota_store'), requireTeamRole('owner')] as const, + request: { params: z.object({ orderId: z.string() }), ...jsonBody(z.object({ status: z.literal('canceled') })) }, + responses: { + 200: cloudBody('Canceled order'), + 400: errorResponse('No active organization'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Order not found'), + 502: errorResponse('Cloud error'), + }, + }, +) const app = new OpenAPIHono() -app.use(requireAuth) -app.use(requireFeature('quota_store')) export const cloudStore = app .openapi(packagesRoute, async (c) => { diff --git a/server/http/teams.ts b/server/http/teams.ts index 382f12b4..0179503c 100644 --- a/server/http/teams.ts +++ b/server/http/teams.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { pageQuerySchema, pageSchema } from '@shared/schemas' import { requireAdmin, requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' @@ -37,7 +37,7 @@ import { toEntitlementResultDTO, toQuotaEntitlementDTO, } from './entitlements' -import { errorResponse, jsonBody, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' const inviteLinkInfoSchema = z .object({ @@ -146,18 +146,21 @@ function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) { } // ── publicTeams ────────────────────────────────────────────────────────────── -const inviteLinkInfoRoute = createRoute({ - operationId: 'getTeamInviteLink', - summary: 'Get team invite link info', - tags: ['Teams'], - method: 'get', - path: '/invite-links/{token}', - request: { params: z.object({ token: z.string() }) }, - responses: { - 200: jsonContent(inviteLinkInfoSchema, 'Invite link info'), - 404: errorResponse('Invalid or expired invite link'), +const inviteLinkInfoRoute = authRoute( + { access: 'public' }, + { + operationId: 'getTeamInviteLink', + summary: 'Get team invite link info', + tags: ['Teams'], + method: 'get', + path: '/invite-links/{token}', + request: { params: z.object({ token: z.string() }) }, + responses: { + 200: jsonContent(inviteLinkInfoSchema, 'Invite link info'), + 404: errorResponse('Invalid or expired invite link'), + }, }, -}) +) export const publicTeams = new OpenAPIHono().openapi(inviteLinkInfoRoute, async (c) => { const info = await getInviteLinkInfo(c.get('deps'), c.req.valid('param').token) @@ -166,97 +169,120 @@ export const publicTeams = new OpenAPIHono().openapi(inviteLinkInfoRoute, a }) // ── teams (member-scoped) ──────────────────────────────────────────────────── -const createInviteLinkRoute = createRoute({ - operationId: 'createTeamInviteLink', - summary: 'Create a team invite link', - tags: ['Teams'], - method: 'post', - path: '/{teamId}/invite-links', - request: { params: z.object({ teamId: z.string() }), ...jsonBody(createLinkSchema) }, - responses: { - 201: jsonContent(inviteLinkCreatedSchema, 'Created invite link'), - 403: errorResponse('Forbidden'), +const createInviteLinkRoute = authRoute( + { access: 'session' }, + { + operationId: 'createTeamInviteLink', + summary: 'Create a team invite link', + tags: ['Teams'], + method: 'post', + path: '/{teamId}/invite-links', + middleware: [requireAuth] as const, + request: { params: z.object({ teamId: z.string() }), ...jsonBody(createLinkSchema) }, + responses: { + 201: jsonContent(inviteLinkCreatedSchema, 'Created invite link'), + 403: errorResponse('Forbidden'), + }, }, -}) +) -const listInvitationsRoute = createRoute({ - operationId: 'listTeamInvitations', - summary: 'List pending team invitations', - tags: ['Teams'], - method: 'get', - path: '/{teamId}/invitations', - request: { params: z.object({ teamId: z.string() }) }, - responses: { - 200: jsonContent(pendingInvitationListSchema, 'Pending invitations'), - 403: errorResponse('Forbidden'), +const listInvitationsRoute = authRoute( + { access: 'session' }, + { + operationId: 'listTeamInvitations', + summary: 'List pending team invitations', + tags: ['Teams'], + method: 'get', + path: '/{teamId}/invitations', + middleware: [requireAuth] as const, + request: { params: z.object({ teamId: z.string() }) }, + responses: { + 200: jsonContent(pendingInvitationListSchema, 'Pending invitations'), + 403: errorResponse('Forbidden'), + }, }, -}) +) -const joinTeamRoute = createRoute({ - operationId: 'joinTeam', - summary: 'Join a team with an invite token', - tags: ['Teams'], - method: 'post', - path: '/{teamId}/members', - request: { params: z.object({ teamId: z.string() }), ...jsonBody(joinSchema) }, - responses: { - 200: jsonContent(z.object({ ok: z.literal(true) }), 'Joined'), - 404: errorResponse('Invalid invite link'), - 409: errorResponse('Already a member'), - 410: errorResponse('Invite link expired'), +const joinTeamRoute = authRoute( + { access: 'session' }, + { + operationId: 'joinTeam', + summary: 'Join a team with an invite token', + tags: ['Teams'], + method: 'post', + path: '/{teamId}/members', + middleware: [requireAuth] as const, + request: { params: z.object({ teamId: z.string() }), ...jsonBody(joinSchema) }, + responses: { + 200: jsonContent(z.object({ ok: z.literal(true) }), 'Joined'), + 404: errorResponse('Invalid invite link'), + 409: errorResponse('Already a member'), + 410: errorResponse('Invite link expired'), + }, }, -}) +) -const activityRoute = createRoute({ - operationId: 'listTeamActivity', - summary: 'List team activity', - tags: ['Teams'], - method: 'get', - path: '/{teamId}/activity', - request: { - params: z.object({ teamId: z.string() }), - query: pageQuerySchema, +const activityRoute = authRoute( + { access: 'session' }, + { + operationId: 'listTeamActivity', + summary: 'List team activity', + tags: ['Teams'], + method: 'get', + path: '/{teamId}/activity', + middleware: [requireAuth] as const, + request: { + params: z.object({ teamId: z.string() }), + query: pageQuerySchema, + }, + responses: { + 200: jsonContent(activityPageSchema, 'Activity'), + 403: errorResponse('Forbidden'), + }, }, - responses: { - 200: jsonContent(activityPageSchema, 'Activity'), - 403: errorResponse('Forbidden'), - }, -}) +) -const setLogoRoute = createRoute({ - operationId: 'setTeamLogo', - summary: 'Set team logo', - tags: ['Teams'], - method: 'put', - path: '/{teamId}/logo', - // Body is multipart/form-data (a `file` field); parsed directly in the handler - // rather than via a request schema (the form validator conflicts with formData()). - request: { params: z.object({ teamId: z.string() }) }, - responses: { - 200: jsonContent(z.object({ url: z.string() }), 'Logo URL'), - 400: errorResponse('Bad request'), - 403: errorResponse('Forbidden'), - 413: errorResponse('File too large'), - 415: errorResponse('Expected multipart/form-data'), - 503: errorResponse('No public storage configured'), +const setLogoRoute = authRoute( + { access: 'session' }, + { + operationId: 'setTeamLogo', + summary: 'Set team logo', + tags: ['Teams'], + method: 'put', + path: '/{teamId}/logo', + middleware: [requireAuth] as const, + // Body is multipart/form-data (a `file` field); parsed directly in the handler + // rather than via a request schema (the form validator conflicts with formData()). + request: { params: z.object({ teamId: z.string() }) }, + responses: { + 200: jsonContent(z.object({ url: z.string() }), 'Logo URL'), + 400: errorResponse('Bad request'), + 403: errorResponse('Forbidden'), + 413: errorResponse('File too large'), + 415: errorResponse('Expected multipart/form-data'), + 503: errorResponse('No public storage configured'), + }, }, -}) +) -const deleteLogoRoute = createRoute({ - operationId: 'deleteTeamLogo', - summary: 'Delete team logo', - tags: ['Teams'], - method: 'delete', - path: '/{teamId}/logo', - request: { params: z.object({ teamId: z.string() }) }, - responses: { - 204: { description: 'Deleted' }, - 403: errorResponse('Forbidden'), +const deleteLogoRoute = authRoute( + { access: 'session' }, + { + operationId: 'deleteTeamLogo', + summary: 'Delete team logo', + tags: ['Teams'], + method: 'delete', + path: '/{teamId}/logo', + middleware: [requireAuth] as const, + request: { params: z.object({ teamId: z.string() }) }, + responses: { + 204: { description: 'Deleted' }, + 403: errorResponse('Forbidden'), + }, }, -}) +) const teamsApp = new OpenAPIHono() -teamsApp.use(requireAuth) export const teams = teamsApp .openapi(createInviteLinkRoute, async (c) => { @@ -329,89 +355,107 @@ export const teams = teamsApp }) // ── adminTeams ─────────────────────────────────────────────────────────────── -const listTeamsRoute = createRoute({ - operationId: 'listTeams', - summary: 'List teams', - tags: ['Teams'], - method: 'get', - path: '/', - middleware: [requireAdmin] as const, - responses: { 200: jsonContent(teamListSchema, 'Teams') }, -}) - -const getTeamRoute = createRoute({ - operationId: 'getTeam', - summary: 'Get a team', - tags: ['Teams'], - method: 'get', - path: '/{teamId}', - middleware: [requireAdmin] as const, - request: { params: z.object({ teamId: z.string() }) }, - responses: { - 200: jsonContent(teamSummarySchema, 'Team'), - 404: errorResponse('Team not found'), +const listTeamsRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listTeams', + summary: 'List teams', + tags: ['Teams'], + method: 'get', + path: '/', + middleware: [requireAdmin] as const, + responses: { 200: jsonContent(teamListSchema, 'Teams') }, }, -}) +) -const listEntitlementsRoute = createRoute({ - operationId: 'listTeamEntitlements', - summary: 'List team quota entitlements', - tags: ['Teams'], - method: 'get', - path: '/{teamId}/entitlements', - middleware: [requireAdmin] as const, - request: { params: z.object({ teamId: z.string() }) }, - responses: { - 200: jsonContent(entitlementListSchema, 'Entitlements'), - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const getTeamRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getTeam', + summary: 'Get a team', + tags: ['Teams'], + method: 'get', + path: '/{teamId}', + middleware: [requireAdmin] as const, + request: { params: z.object({ teamId: z.string() }) }, + responses: { + 200: jsonContent(teamSummarySchema, 'Team'), + 404: errorResponse('Team not found'), + }, }, -}) +) -const grantEntitlementRoute = createRoute({ - operationId: 'grantTeamEntitlement', - summary: 'Grant a team entitlement', - tags: ['Teams'], - method: 'post', - path: '/{teamId}/entitlements', - middleware: [requireAdmin] as const, - request: { params: z.object({ teamId: z.string() }), ...jsonBody(grantEntitlementSchema) }, - responses: { - 201: jsonContent(entitlementResultSchema, 'Granted entitlement'), - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const listEntitlementsRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listTeamEntitlements', + summary: 'List team quota entitlements', + tags: ['Teams'], + method: 'get', + path: '/{teamId}/entitlements', + middleware: [requireAdmin] as const, + request: { params: z.object({ teamId: z.string() }) }, + responses: { + 200: jsonContent(entitlementListSchema, 'Entitlements'), + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, }, -}) +) -const updateEntitlementRoute = createRoute({ - operationId: 'updateTeamEntitlement', - summary: 'Update a team entitlement', - tags: ['Teams'], - method: 'patch', - path: '/{teamId}/entitlements/{eid}', - middleware: [requireAdmin] as const, - request: { params: z.object({ teamId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) }, - responses: { - 200: jsonContent(entitlementResultSchema, 'Updated entitlement'), - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const grantEntitlementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'grantTeamEntitlement', + summary: 'Grant a team entitlement', + tags: ['Teams'], + method: 'post', + path: '/{teamId}/entitlements', + middleware: [requireAdmin] as const, + request: { params: z.object({ teamId: z.string() }), ...jsonBody(grantEntitlementSchema) }, + responses: { + 201: jsonContent(entitlementResultSchema, 'Granted entitlement'), + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, }, -}) +) -const revokeEntitlementRoute = createRoute({ - operationId: 'revokeTeamEntitlement', - summary: 'Revoke a team entitlement', - tags: ['Teams'], - method: 'delete', - path: '/{teamId}/entitlements/{eid}', - middleware: [requireAdmin] as const, - request: { params: z.object({ teamId: z.string(), eid: z.string() }) }, - responses: { - 204: { description: 'Revoked entitlement' }, - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const updateEntitlementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateTeamEntitlement', + summary: 'Update a team entitlement', + tags: ['Teams'], + method: 'patch', + path: '/{teamId}/entitlements/{eid}', + middleware: [requireAdmin] as const, + request: { params: z.object({ teamId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) }, + responses: { + 200: jsonContent(entitlementResultSchema, 'Updated entitlement'), + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, }, -}) +) + +const revokeEntitlementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'revokeTeamEntitlement', + summary: 'Revoke a team entitlement', + tags: ['Teams'], + method: 'delete', + path: '/{teamId}/entitlements/{eid}', + middleware: [requireAdmin] as const, + request: { params: z.object({ teamId: z.string(), eid: z.string() }) }, + responses: { + 204: { description: 'Revoked entitlement' }, + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, + }, +) export const adminTeams = new OpenAPIHono() .openapi(listTeamsRoute, async (c) => { diff --git a/server/http/trash.ts b/server/http/trash.ts index e95ba8e3..c691d37d 100644 --- a/server/http/trash.ts +++ b/server/http/trash.ts @@ -1,10 +1,10 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { cursorPageQuerySchema, cursorPageSchema, restoreObjectSchema } from '@shared/schemas' import { requireAuth, requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' import { deleteObject, getTrashObject, listTrashedObjects, restoreObject } from '../usecases/object' import { badRequest, type Matter, notFound } from '../usecases/ports' -import { errorResponse, jsonBody, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' import { decodeOptionalPageToken, encodeNextPageToken, pageQueryFingerprint, trashCursorCodec } from './page-token' // The trashed-object wire shape mirrors the live Matter model; trash is a @@ -52,68 +52,79 @@ function toMatterDTO(m: Matter): MatterDTO { const trashPageSchema = cursorPageSchema(matterSchema, 'TrashObjectPage') const idParam = z.object({ id: z.string() }) -const listTrashRoute = createRoute({ - operationId: 'listTrashObjects', - summary: 'List trashed objects', - tags: ['Trash'], - method: 'get', - path: '/objects', - middleware: [requireTeamRole('viewer')] as const, - request: { query: cursorPageQuerySchema }, - responses: { - 200: jsonContent(trashPageSchema, 'Trashed objects (roots only)'), - 400: errorResponse('No active organization'), +const listTrashRoute = authRoute( + { access: 'session', minTeamRole: 'viewer' }, + { + operationId: 'listTrashObjects', + summary: 'List trashed objects', + tags: ['Trash'], + method: 'get', + path: '/objects', + middleware: [requireAuth, requireTeamRole('viewer')] as const, + request: { query: cursorPageQuerySchema }, + responses: { + 200: jsonContent(trashPageSchema, 'Trashed objects (roots only)'), + 400: errorResponse('No active organization'), + }, }, -}) +) -const getTrashObjectRoute = createRoute({ - operationId: 'getTrashObject', - summary: 'Get trashed object', - tags: ['Trash'], - method: 'get', - path: '/objects/{id}', - middleware: [requireTeamRole('viewer')] as const, - request: { params: idParam }, - responses: { - 200: jsonContent(matterSchema, 'Trashed object'), - 400: errorResponse('No active organization'), - 404: errorResponse('Not found'), +const getTrashObjectRoute = authRoute( + { access: 'session', minTeamRole: 'viewer' }, + { + operationId: 'getTrashObject', + summary: 'Get trashed object', + tags: ['Trash'], + method: 'get', + path: '/objects/{id}', + middleware: [requireAuth, requireTeamRole('viewer')] as const, + request: { params: idParam }, + responses: { + 200: jsonContent(matterSchema, 'Trashed object'), + 400: errorResponse('No active organization'), + 404: errorResponse('Not found'), + }, }, -}) +) -const restoreObjectRoute = createRoute({ - operationId: 'restoreObject', - summary: 'Restore trashed object', - tags: ['Trash'], - method: 'post', - path: '/objects/{id}/restorations', - middleware: [requireTeamRole('editor')] as const, - request: { params: idParam, ...jsonBody(restoreObjectSchema) }, - responses: { - 200: jsonContent(matterSchema, 'Restored object'), - 400: errorResponse('No active organization'), - 404: errorResponse('Not found'), - 409: errorResponse('Name conflict'), +const restoreObjectRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'restoreObject', + summary: 'Restore trashed object', + tags: ['Trash'], + method: 'post', + path: '/objects/{id}/restorations', + middleware: [requireAuth, requireTeamRole('editor')] as const, + request: { params: idParam, ...jsonBody(restoreObjectSchema) }, + responses: { + 200: jsonContent(matterSchema, 'Restored object'), + 400: errorResponse('No active organization'), + 404: errorResponse('Not found'), + 409: errorResponse('Name conflict'), + }, }, -}) +) -const purgeObjectRoute = createRoute({ - operationId: 'purgeTrashObject', - summary: 'Permanently delete trashed object', - tags: ['Trash'], - method: 'delete', - path: '/objects/{id}', - middleware: [requireTeamRole('editor')] as const, - request: { params: idParam }, - responses: { - 204: { description: 'Permanently removed (recursive subtree purge)' }, - 400: errorResponse('No active organization'), - 404: errorResponse('Not found'), +const purgeObjectRoute = authRoute( + { access: 'session', minTeamRole: 'editor' }, + { + operationId: 'purgeTrashObject', + summary: 'Permanently delete trashed object', + tags: ['Trash'], + method: 'delete', + path: '/objects/{id}', + middleware: [requireAuth, requireTeamRole('editor')] as const, + request: { params: idParam }, + responses: { + 204: { description: 'Permanently removed (recursive subtree purge)' }, + 400: errorResponse('No active organization'), + 404: errorResponse('Not found'), + }, }, -}) +) const app = new OpenAPIHono() -app.use(requireAuth) const trash = app .openapi(listTrashRoute, async (c) => { diff --git a/server/http/users.ts b/server/http/users.ts index ab6ce2b3..221941dc 100644 --- a/server/http/users.ts +++ b/server/http/users.ts @@ -1,4 +1,4 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' +import { OpenAPIHono, z } from '@hono/zod-openapi' import { publicProfileSchema } from '@shared/schemas/profile' import { requireAdmin, requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' @@ -28,7 +28,7 @@ import { toEntitlementResultDTO, toQuotaEntitlementDTO, } from './entitlements' -import { errorResponse, jsonBody, jsonContent } from './openapi' +import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' // Admin user management (list / disable / delete) is served directly by // better-auth's /api/auth/admin/* endpoints and called from the frontend admin @@ -66,46 +66,55 @@ function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) { return badRequest(error) } -const setAvatarRoute = createRoute({ - operationId: 'setMyAvatar', - summary: 'Set my avatar', - tags: ['Users'], - method: 'put', - path: '/me/avatar', - middleware: [requireAuth] as const, - // Body is multipart/form-data (a `file` field); parsed directly in the handler - // rather than via a request schema (the form validator conflicts with formData()). - responses: { - 200: jsonContent(z.object({ url: z.string() }), 'Avatar URL'), - 400: errorResponse('Bad request'), - 413: errorResponse('File too large'), - 415: errorResponse('Expected multipart/form-data'), - 503: errorResponse('No public storage configured'), +const setAvatarRoute = authRoute( + { access: 'session' }, + { + operationId: 'setMyAvatar', + summary: 'Set my avatar', + tags: ['Users'], + method: 'put', + path: '/me/avatar', + middleware: [requireAuth] as const, + // Body is multipart/form-data (a `file` field); parsed directly in the handler + // rather than via a request schema (the form validator conflicts with formData()). + responses: { + 200: jsonContent(z.object({ url: z.string() }), 'Avatar URL'), + 400: errorResponse('Bad request'), + 413: errorResponse('File too large'), + 415: errorResponse('Expected multipart/form-data'), + 503: errorResponse('No public storage configured'), + }, }, -}) +) -const deleteAvatarRoute = createRoute({ - operationId: 'deleteMyAvatar', - summary: 'Remove my avatar', - tags: ['Users'], - method: 'delete', - path: '/me/avatar', - middleware: [requireAuth] as const, - responses: { 204: { description: 'Removed' } }, -}) - -const getUserRoute = createRoute({ - operationId: 'getUserProfile', - summary: 'Get a user public profile', - tags: ['Users'], - method: 'get', - path: '/{username}', - request: { params: z.object({ username: z.string() }) }, - responses: { - 200: jsonContent(publicProfileResponseSchema, 'User'), - 404: errorResponse('User not found'), +const deleteAvatarRoute = authRoute( + { access: 'session' }, + { + operationId: 'deleteMyAvatar', + summary: 'Remove my avatar', + tags: ['Users'], + method: 'delete', + path: '/me/avatar', + middleware: [requireAuth] as const, + responses: { 204: { description: 'Removed' } }, }, -}) +) + +const getUserRoute = authRoute( + { access: 'public' }, + { + operationId: 'getUserProfile', + summary: 'Get a user public profile', + tags: ['Users'], + method: 'get', + path: '/{username}', + request: { params: z.object({ username: z.string() }) }, + responses: { + 200: jsonContent(publicProfileResponseSchema, 'User'), + 404: errorResponse('User not found'), + }, + }, +) // Per-user storage used/total — a user sub-resource the admin UI fans out over // (one request per visible user) to enrich better-auth's admin list, which knows @@ -115,76 +124,91 @@ const userQuotaSchema = z .object({ used: z.number().int(), total: z.number().int(), hasPersonalOrg: z.boolean() }) .openapi('AdminUserQuota') -const getUserQuotaRoute = createRoute({ - operationId: 'getUserQuota', - summary: "Get a user's storage quota", - tags: ['Users'], - method: 'get', - path: '/{userId}/quota', - middleware: [requireAdmin] as const, - request: { params: z.object({ userId: z.string() }) }, - responses: { 200: jsonContent(userQuotaSchema, 'User quota') }, -}) - -const listUserEntitlementsRoute = createRoute({ - operationId: 'listUserEntitlements', - summary: 'List a user’s entitlements', - tags: ['Users'], - method: 'get', - path: '/{userId}/entitlements', - middleware: [requireAdmin] as const, - request: { params: z.object({ userId: z.string() }) }, - responses: { - 200: jsonContent(entitlementListSchema, 'Entitlements'), - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const getUserQuotaRoute = authRoute( + { access: 'admin' }, + { + operationId: 'getUserQuota', + summary: "Get a user's storage quota", + tags: ['Users'], + method: 'get', + path: '/{userId}/quota', + middleware: [requireAdmin] as const, + request: { params: z.object({ userId: z.string() }) }, + responses: { 200: jsonContent(userQuotaSchema, 'User quota') }, }, -}) +) -const grantUserEntitlementRoute = createRoute({ - operationId: 'grantUserEntitlement', - summary: 'Grant a user entitlement', - tags: ['Users'], - method: 'post', - path: '/{userId}/entitlements', - middleware: [requireAdmin] as const, - request: { params: z.object({ userId: z.string() }), ...jsonBody(grantEntitlementSchema) }, - responses: { - 201: jsonContent(entitlementResultSchema, 'Granted'), - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const listUserEntitlementsRoute = authRoute( + { access: 'admin' }, + { + operationId: 'listUserEntitlements', + summary: 'List a user’s entitlements', + tags: ['Users'], + method: 'get', + path: '/{userId}/entitlements', + middleware: [requireAdmin] as const, + request: { params: z.object({ userId: z.string() }) }, + responses: { + 200: jsonContent(entitlementListSchema, 'Entitlements'), + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, }, -}) +) -const updateUserEntitlementRoute = createRoute({ - operationId: 'updateUserEntitlement', - summary: 'Update a user entitlement', - tags: ['Users'], - method: 'patch', - path: '/{userId}/entitlements/{eid}', - middleware: [requireAdmin] as const, - request: { params: z.object({ userId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) }, - responses: { - 200: jsonContent(entitlementResultSchema, 'Updated'), - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const grantUserEntitlementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'grantUserEntitlement', + summary: 'Grant a user entitlement', + tags: ['Users'], + method: 'post', + path: '/{userId}/entitlements', + middleware: [requireAdmin] as const, + request: { params: z.object({ userId: z.string() }), ...jsonBody(grantEntitlementSchema) }, + responses: { + 201: jsonContent(entitlementResultSchema, 'Granted'), + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, }, -}) +) -const revokeUserEntitlementRoute = createRoute({ - operationId: 'revokeUserEntitlement', - summary: 'Revoke a user entitlement', - tags: ['Users'], - method: 'delete', - path: '/{userId}/entitlements/{eid}', - middleware: [requireAdmin] as const, - request: { params: z.object({ userId: z.string(), eid: z.string() }) }, - responses: { - 204: { description: 'Revoked' }, - 400: errorResponse('Bad request'), - 404: errorResponse('Not found'), +const updateUserEntitlementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'updateUserEntitlement', + summary: 'Update a user entitlement', + tags: ['Users'], + method: 'patch', + path: '/{userId}/entitlements/{eid}', + middleware: [requireAdmin] as const, + request: { params: z.object({ userId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) }, + responses: { + 200: jsonContent(entitlementResultSchema, 'Updated'), + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, }, -}) +) + +const revokeUserEntitlementRoute = authRoute( + { access: 'admin' }, + { + operationId: 'revokeUserEntitlement', + summary: 'Revoke a user entitlement', + tags: ['Users'], + method: 'delete', + path: '/{userId}/entitlements/{eid}', + middleware: [requireAdmin] as const, + request: { params: z.object({ userId: z.string(), eid: z.string() }) }, + responses: { + 204: { description: 'Revoked' }, + 400: errorResponse('Bad request'), + 404: errorResponse('Not found'), + }, + }, +) export const users = new OpenAPIHono() .openapi(setAvatarRoute, async (c) => { diff --git a/server/middleware/authz.ts b/server/middleware/authz.ts index 8aab297d..888b30fd 100644 --- a/server/middleware/authz.ts +++ b/server/middleware/authz.ts @@ -17,6 +17,12 @@ export type TeamRole = 'viewer' | 'editor' | 'owner' export type RouteAuthorizationDeclaration = | { access: 'public' } | { access: 'internal' } + | { access: 'admin' } + | { access: 'session'; minTeamRole?: TeamRole } + | { access: 'downloader' } + | { access: 'signed-webhook' } + | { access: 'task-upload-token' } + | { access: 'anyOf'; policies: readonly RouteAuthorizationDeclaration[] } | { access: 'protected' scopes?: readonly AuthorizationScope[] @@ -50,6 +56,7 @@ export async function evaluateAuthorization(input: { const { context, declaration, deps } = input if (declaration.access === 'public') return { allowed: true, effectiveOrgId: context.orgId, reason: 'allowed' } if (declaration.access === 'internal') return deny(context, 403, 'actor_not_allowed', declaration) + if (declaration.access !== 'protected') return deny(context, 403, 'actor_not_allowed', declaration) if (context.credential === 'anonymous') return deny(context, 401, 'missing_credential', declaration) if (context.credential === 'downloader') { return declaration.allowDownloader diff --git a/server/openapi.test.ts b/server/openapi.test.ts index 62a5df74..e89af626 100644 --- a/server/openapi.test.ts +++ b/server/openapi.test.ts @@ -137,6 +137,41 @@ describe('global OpenAPI document', () => { ).toEqual(['DELETE /missing']) }) + it('emits explicit authorization metadata for every hand-written OpenAPI operation', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const res = await app.request('/api/openapi.json') + const doc = (await res.json()) as { paths: Record> } + const handWrittenPaths = Object.fromEntries( + Object.entries(doc.paths).filter(([path]) => !path.startsWith('/api/auth/')), + ) + + expect(findOperationsMissingAuthContract(handWrittenPaths)).toEqual([]) + }) + + it('documents owner role requirements for store operations that enforce owner team role', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const res = await app.request('/api/openapi.json') + const doc = (await res.json()) as { paths: Record> } + + const ownerOperations = [ + doc.paths['/api/store/credits']?.get, + doc.paths['/api/store/credits/ledger-entries']?.get, + doc.paths['/api/store/credits/redemptions']?.post, + doc.paths['/api/store/checkouts']?.post, + doc.paths['/api/store/billing-portal-sessions']?.post, + doc.paths['/api/store/orders']?.get, + doc.paths['/api/store/orders/{orderId}/payments']?.post, + doc.paths['/api/store/orders/{orderId}/status']?.put, + ] + + for (const operation of ownerOperations) { + expect(operation?.['x-zpan-auth']).toMatchObject({ + access: 'session', + minTeamRole: 'owner', + }) + } + }) + it('documents the concrete public profile contract without the removed objects placeholder', async () => { const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) const res = await app.request('/api/openapi.json')