mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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:
committed by
GitHub
parent
038e23172b
commit
da286e9db3
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { publicProfileSchema } from '@shared/schemas/profile'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
toEntitlementResultDTO,
|
||||
toQuotaEntitlementDTO,
|
||||
} from './entitlements'
|
||||
import { errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
|
||||
// Admin user management (list / disable / delete) is served directly by
|
||||
// better-auth's /api/auth/admin/* endpoints and called from the frontend admin
|
||||
@@ -66,46 +66,55 @@ function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
|
||||
return badRequest(error)
|
||||
}
|
||||
|
||||
const setAvatarRoute = createRoute({
|
||||
operationId: 'setMyAvatar',
|
||||
summary: 'Set my avatar',
|
||||
tags: ['Users'],
|
||||
method: 'put',
|
||||
path: '/me/avatar',
|
||||
middleware: [requireAuth] as const,
|
||||
// Body is multipart/form-data (a `file` field); parsed directly in the handler
|
||||
// rather than via a request schema (the form validator conflicts with formData()).
|
||||
responses: {
|
||||
200: jsonContent(z.object({ url: z.string() }), 'Avatar URL'),
|
||||
400: errorResponse('Bad request'),
|
||||
413: errorResponse('File too large'),
|
||||
415: errorResponse('Expected multipart/form-data'),
|
||||
503: errorResponse('No public storage configured'),
|
||||
const setAvatarRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'setMyAvatar',
|
||||
summary: 'Set my avatar',
|
||||
tags: ['Users'],
|
||||
method: 'put',
|
||||
path: '/me/avatar',
|
||||
middleware: [requireAuth] as const,
|
||||
// Body is multipart/form-data (a `file` field); parsed directly in the handler
|
||||
// rather than via a request schema (the form validator conflicts with formData()).
|
||||
responses: {
|
||||
200: jsonContent(z.object({ url: z.string() }), 'Avatar URL'),
|
||||
400: errorResponse('Bad request'),
|
||||
413: errorResponse('File too large'),
|
||||
415: errorResponse('Expected multipart/form-data'),
|
||||
503: errorResponse('No public storage configured'),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const deleteAvatarRoute = createRoute({
|
||||
operationId: 'deleteMyAvatar',
|
||||
summary: 'Remove my avatar',
|
||||
tags: ['Users'],
|
||||
method: 'delete',
|
||||
path: '/me/avatar',
|
||||
middleware: [requireAuth] as const,
|
||||
responses: { 204: { description: 'Removed' } },
|
||||
})
|
||||
|
||||
const getUserRoute = createRoute({
|
||||
operationId: 'getUserProfile',
|
||||
summary: 'Get a user public profile',
|
||||
tags: ['Users'],
|
||||
method: 'get',
|
||||
path: '/{username}',
|
||||
request: { params: z.object({ username: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(publicProfileResponseSchema, 'User'),
|
||||
404: errorResponse('User not found'),
|
||||
const deleteAvatarRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'deleteMyAvatar',
|
||||
summary: 'Remove my avatar',
|
||||
tags: ['Users'],
|
||||
method: 'delete',
|
||||
path: '/me/avatar',
|
||||
middleware: [requireAuth] as const,
|
||||
responses: { 204: { description: 'Removed' } },
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const getUserRoute = authRoute(
|
||||
{ access: 'public' },
|
||||
{
|
||||
operationId: 'getUserProfile',
|
||||
summary: 'Get a user public profile',
|
||||
tags: ['Users'],
|
||||
method: 'get',
|
||||
path: '/{username}',
|
||||
request: { params: z.object({ username: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(publicProfileResponseSchema, 'User'),
|
||||
404: errorResponse('User not found'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Per-user storage used/total — a user sub-resource the admin UI fans out over
|
||||
// (one request per visible user) to enrich better-auth's admin list, which knows
|
||||
@@ -115,76 +124,91 @@ const userQuotaSchema = z
|
||||
.object({ used: z.number().int(), total: z.number().int(), hasPersonalOrg: z.boolean() })
|
||||
.openapi('AdminUserQuota')
|
||||
|
||||
const getUserQuotaRoute = createRoute({
|
||||
operationId: 'getUserQuota',
|
||||
summary: "Get a user's storage quota",
|
||||
tags: ['Users'],
|
||||
method: 'get',
|
||||
path: '/{userId}/quota',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string() }) },
|
||||
responses: { 200: jsonContent(userQuotaSchema, 'User quota') },
|
||||
})
|
||||
|
||||
const listUserEntitlementsRoute = createRoute({
|
||||
operationId: 'listUserEntitlements',
|
||||
summary: 'List a user’s entitlements',
|
||||
tags: ['Users'],
|
||||
method: 'get',
|
||||
path: '/{userId}/entitlements',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(entitlementListSchema, 'Entitlements'),
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
const getUserQuotaRoute = authRoute(
|
||||
{ access: 'admin' },
|
||||
{
|
||||
operationId: 'getUserQuota',
|
||||
summary: "Get a user's storage quota",
|
||||
tags: ['Users'],
|
||||
method: 'get',
|
||||
path: '/{userId}/quota',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string() }) },
|
||||
responses: { 200: jsonContent(userQuotaSchema, 'User quota') },
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const grantUserEntitlementRoute = createRoute({
|
||||
operationId: 'grantUserEntitlement',
|
||||
summary: 'Grant a user entitlement',
|
||||
tags: ['Users'],
|
||||
method: 'post',
|
||||
path: '/{userId}/entitlements',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string() }), ...jsonBody(grantEntitlementSchema) },
|
||||
responses: {
|
||||
201: jsonContent(entitlementResultSchema, 'Granted'),
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
const listUserEntitlementsRoute = authRoute(
|
||||
{ access: 'admin' },
|
||||
{
|
||||
operationId: 'listUserEntitlements',
|
||||
summary: 'List a user’s entitlements',
|
||||
tags: ['Users'],
|
||||
method: 'get',
|
||||
path: '/{userId}/entitlements',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(entitlementListSchema, 'Entitlements'),
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const updateUserEntitlementRoute = createRoute({
|
||||
operationId: 'updateUserEntitlement',
|
||||
summary: 'Update a user entitlement',
|
||||
tags: ['Users'],
|
||||
method: 'patch',
|
||||
path: '/{userId}/entitlements/{eid}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) },
|
||||
responses: {
|
||||
200: jsonContent(entitlementResultSchema, 'Updated'),
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
const grantUserEntitlementRoute = authRoute(
|
||||
{ access: 'admin' },
|
||||
{
|
||||
operationId: 'grantUserEntitlement',
|
||||
summary: 'Grant a user entitlement',
|
||||
tags: ['Users'],
|
||||
method: 'post',
|
||||
path: '/{userId}/entitlements',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string() }), ...jsonBody(grantEntitlementSchema) },
|
||||
responses: {
|
||||
201: jsonContent(entitlementResultSchema, 'Granted'),
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const revokeUserEntitlementRoute = createRoute({
|
||||
operationId: 'revokeUserEntitlement',
|
||||
summary: 'Revoke a user entitlement',
|
||||
tags: ['Users'],
|
||||
method: 'delete',
|
||||
path: '/{userId}/entitlements/{eid}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string(), eid: z.string() }) },
|
||||
responses: {
|
||||
204: { description: 'Revoked' },
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
const updateUserEntitlementRoute = authRoute(
|
||||
{ access: 'admin' },
|
||||
{
|
||||
operationId: 'updateUserEntitlement',
|
||||
summary: 'Update a user entitlement',
|
||||
tags: ['Users'],
|
||||
method: 'patch',
|
||||
path: '/{userId}/entitlements/{eid}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) },
|
||||
responses: {
|
||||
200: jsonContent(entitlementResultSchema, 'Updated'),
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const revokeUserEntitlementRoute = authRoute(
|
||||
{ access: 'admin' },
|
||||
{
|
||||
operationId: 'revokeUserEntitlement',
|
||||
summary: 'Revoke a user entitlement',
|
||||
tags: ['Users'],
|
||||
method: 'delete',
|
||||
path: '/{userId}/entitlements/{eid}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ userId: z.string(), eid: z.string() }) },
|
||||
responses: {
|
||||
204: { description: 'Revoked' },
|
||||
400: errorResponse('Bad request'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export const users = new OpenAPIHono<Env>()
|
||||
.openapi(setAvatarRoute, async (c) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user