feat: declare explicit route authorization policies (#535)

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

Co-authored-by: Marina Zhou <marina-zhou@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban[bot]
2026-07-29 00:30:22 -04:00
committed by GitHub
parent 038e23172b
commit da286e9db3
31 changed files with 2147 additions and 1673 deletions
+14 -11
View File
@@ -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<Env>().openapi(route, async (c) =>
c.json(await getAdminOverview(c.get('deps')), 200),
+18 -15
View File
@@ -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<typeof jsonContent>[0],
gated = true,
) {
return createRoute({
operationId,
summary,
tags: ['Site Analytics'],
method: 'get',
path,
middleware: (gated ? [requireAdmin, requireFeature('analytics')] : [requireAdmin]) as [
typeof requireAdmin,
...Array<ReturnType<typeof requireFeature>>,
],
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<ReturnType<typeof requireFeature>>,
],
request: { query: rangeQuerySchema },
responses: { 200: jsonContent(schema, summary) },
},
)
}
const overviewRoute = analyticsRoute(
+100 -70
View File
@@ -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<Env>()
app.use(requireAuth)
const backgroundJobs = app
.openapi(listRoute, async (c) => {
+15 -12
View File
@@ -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()
+95 -77
View File
@@ -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.
+47 -38
View File
@@ -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<Env>()
app.use(requireAuth)
const ihostConfig = app
.openapi(getRoute, async (c) => {
+88 -73
View File
@@ -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<Env>()
+56 -41
View File
@@ -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<T> 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<Env>()
app.use(requireAuth)
export const notifications = app
.openapi(listRoute, async (c) => {
+108 -88
View File
@@ -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<Env>(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<Env>()
// Blanket auth gate for every object route. Applied as a statement, not chained,
+8 -3
View File
@@ -28,7 +28,7 @@ export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'>
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<string, Record<s
return missing
}
function openApiSecurity(auth: RouteAuthorizationDeclaration) {
if (auth.access === 'public' || auth.access === 'internal') return []
function openApiSecurity(auth: RouteAuthorizationDeclaration): Record<string, string[]>[] {
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<string, unknown> {
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,
+29 -23
View File
@@ -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.
+155 -125
View File
@@ -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<Env>()
@@ -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<Env>()
authedApp.use(requireAuth)
export const authedShares = authedApp
.openapi(listSharesRoute, async (c) => {
+76 -62
View File
@@ -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<Env>()
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
+15 -12
View File
@@ -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<Env>().openapi(listRoute, async (c) => {
const { page, pageSize, orgId, userId, action, targetType, createdFrom, createdTo } = c.req.valid('query')
+46 -37
View File
@@ -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
+37 -31
View File
@@ -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<Env>()
+45 -36
View File
@@ -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<Env>()
+45 -36
View File
@@ -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<Env>()
+79 -64
View File
@@ -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<Env>()
.openapi(listRoute, async (c) => {
+60 -48
View File
@@ -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<Env>()
.openapi(listRoute, async (c) => {
+83 -65
View File
@@ -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<Env>): 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<Env>) {
const cloudBaseUrl = getCloudBaseUrl(c)
+103 -82
View File
@@ -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<Env>()
.openapi(getRoute, async (c) => c.json(await getSiteSettings(c.get('deps'), c.req.url), 200))
+109 -88
View File
@@ -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<Env>()
.openapi(listRoute, async (c) => {
+27 -21
View File
@@ -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<Env>()
.openapi(instanceRoute, async (c) => {
+43 -36
View File
@@ -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<Env>()
app.use(requireAuth)
const storageUsage = app
.openapi(getUsageRoute, async (c) => c.json(await getStorageUsage(c.get('deps'), requireOrg(c)), 200))
+203 -157
View File
@@ -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<Env>()
app.use(requireAuth)
app.use(requireFeature('quota_store'))
export const cloudStore = app
.openapi(packagesRoute, async (c) => {
+209 -165
View File
@@ -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<Env>().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<Env>().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<Env>()
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<Env>()
.openapi(listTeamsRoute, async (c) => {
+66 -55
View File
@@ -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<Env>()
app.use(requireAuth)
const trash = app
.openapi(listTrashRoute, async (c) => {
+126 -102
View File
@@ -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 users 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 users 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<Env>()
.openapi(setAvatarRoute, async (c) => {
+7
View File
@@ -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
+35
View File
@@ -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<string, Record<string, unknown>> }
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<string, Record<string, { 'x-zpan-auth'?: unknown }>> }
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')