mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
* refactor(api)!: unify errors to AIP-193 + Page<T> pagination, enrich access log (#443) Settle the API consistency issues from #443 before SDKs ship. Breaking changes across the error envelope, list envelopes, and the generated Go client. Errors → AIP-193 google.rpc.Status (https://google.aip.dev/193): - every error body is now { error: { code, message, status, details:[ErrorInfo] } } - machine-readable, switchable key is details[0].reason (UPPER_SNAKE); status is the canonical google.rpc.Code; dynamic context lives in metadata (string→string) - built once in server/lib/http-errors.ts (buildErrorBody/ApiError/mapDomainError); inline handlers use apiError(c,status,msg,opts?); thrown errors flow through app.onError → renderError. Resolves #8 (one casing; no-storage 503 everywhere) and #9 (resource/maxBytes/conflictingName/licensing fields folded into metadata; featureGateErrorSchema removed) Pagination → Page<T> = { items, total, page, pageSize } via pageSchema + integer pageQuerySchema, applied to every list endpoint. image-hosting/images stays cursor (the one intentional exception). unreadCount moved out of the notifications list into /notifications/stats; entitlements drop the redundant orgId; team invitations use items. Access log: every 4xx/5xx carries reason + full message (set by apiError and renderError); a thrown domain error logs its mapped status (409, not 500); unhandled 500s log the full cause chain while the client gets a generic message. Frontend ApiError exposes reason/metadata/canonicalStatus; consumers updated. Go client regenerated from the new OpenAPI document. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(api): fix e2e name-conflict assertion + cover AIP-193 error branches - e2e/name-conflict.spec.ts: assert body.error.details[0].reason (AIP-193) instead of the removed top-level body.code - unit-test buildErrorBody, ApiError, and every mapDomainError branch (server/lib/http-errors.test.ts) and renderError + isHandledError (server/middleware/error-handler.test.ts) - integration-test the apiError error-branch guards the refactor touched: shares, redirect, site/invitations, objects, store/storefront, and the requirePermission middleware (authz) — restoring patch coverage above target Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(api): drop ad-hoc [spec:] breadcrumbs from new coverage tests lint:spec governs spec↔test traceability: a [spec: id] breadcrumb must map to a documented @id scenario in spec/**/*.feature. The added error-branch coverage tests are not Gherkin scenarios, so reference no spec id — use plain titles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(objects): allow the file-manager pageSize (500) on the objects list The shared pageQuerySchema caps pageSize at 100, but the file manager loads a whole folder client-side (FILES_PAGE_SIZE=500, transfer dialog 200) — the old z.string() query param was unbounded. With the cap, GET /api/objects?pageSize=500 returned 400, the file-manager list query errored and retried, and the toolbar / table never rendered (e2e: responsive @desktop + name-conflict table state). Raise just this list's ceiling to 1000 (default stays 20); other lists keep the 100 cap. Regression-tested: GET /api/objects?pageSize=500 → 200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+634
-617
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ test.describe('Name conflict — folders @all', () => {
|
||||
])
|
||||
expect(firstResp.status()).toBe(409)
|
||||
const body = await firstResp.json()
|
||||
expect(body.code).toBe('NAME_CONFLICT')
|
||||
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
|
||||
|
||||
// --- 2. Conflict dialog: no Replace for folders, click Keep Both → rename ---
|
||||
const conflictDialog = page.getByRole('dialog').filter({ hasText: /already/i })
|
||||
|
||||
@@ -113,7 +113,9 @@ describe('API key rate limits', () => {
|
||||
expect(allowed.status).toBe(200)
|
||||
expect(limited.status).toBe(429)
|
||||
expect(limited.headers.get('Retry-After')).toBe('60')
|
||||
expect(await limited.json()).toEqual({ error: 'Rate limit exceeded.' })
|
||||
const body = (await limited.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Rate limit exceeded.')
|
||||
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
})
|
||||
|
||||
it('WebDAV surfaces a rate-limited API key as too many requests', async () => {
|
||||
|
||||
+8
-9
@@ -33,8 +33,8 @@ import trash from './http/trash'
|
||||
import { users } from './http/users'
|
||||
import webdav from './http/webdav'
|
||||
import { formatError } from './lib/errors'
|
||||
import { mapDomainError } from './lib/http-errors'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
import { isHandledError, renderError } from './middleware/error-handler'
|
||||
import { imageHostingDomain } from './middleware/image-hosting-domain'
|
||||
import { accessLog } from './middleware/logger'
|
||||
import type { Env } from './middleware/platform'
|
||||
@@ -224,15 +224,14 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok' }))
|
||||
|
||||
// Single translation point for errors that escape a handler. A known domain
|
||||
// error becomes its mapped status + JSON body (see server/lib/http-errors.ts);
|
||||
// anything else is logged and surfaced as a generic 500. This is what lets
|
||||
// handlers `throw` domain errors instead of hand-rolling per-route try/catch.
|
||||
// Backstop for errors thrown outside the accessLog boundary (earlier middleware,
|
||||
// or routes without accessLog like /r). For /api and /dav, accessLog already
|
||||
// catches and renders via the same `renderError`, so this rarely fires there.
|
||||
// Genuinely unhandled errors are logged here since those routes aren't access-
|
||||
// logged; mapped/ApiError cases are already carried by their access-log line.
|
||||
app.onError((err, c) => {
|
||||
const mapped = mapDomainError(err)
|
||||
if (mapped) return c.json(mapped.json, mapped.status)
|
||||
console.error(`http.unhandled_error code=${formatError(err)}`)
|
||||
return c.text('Internal Server Error', 500)
|
||||
if (!isHandledError(err)) console.error(`http.unhandled_error code=${formatError(err)}`)
|
||||
return renderError(c, err)
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
@@ -260,7 +260,9 @@ describe('background jobs API', () => {
|
||||
const res = await app.request(`/api/background-jobs/${job.id}`, { headers: viewerHeaders })
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Not found' })
|
||||
const body = (await res.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(body.error.message).toBe('Not found')
|
||||
expect(body.error.details[0].reason).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('cancels only queued or running jobs [spec: background-jobs/cancel]', async () => {
|
||||
@@ -285,7 +287,9 @@ describe('background jobs API', () => {
|
||||
expect(canceledRes.status).toBe(200)
|
||||
await expect(canceledRes.json()).resolves.toMatchObject({ id: queued.id, status: 'canceled' })
|
||||
expect(rejectedRes.status).toBe(409)
|
||||
await expect(rejectedRes.json()).resolves.toEqual({ error: 'Background job cannot be canceled' })
|
||||
const rejectedBody = (await rejectedRes.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(rejectedBody.error.message).toBe('Background job cannot be canceled')
|
||||
expect(rejectedBody.error.details[0].reason).toBe('NOT_CANCELABLE')
|
||||
})
|
||||
|
||||
it('retries only failed retryable jobs without hiding the failed job [spec: background-jobs/retry]', async () => {
|
||||
@@ -319,7 +323,9 @@ describe('background jobs API', () => {
|
||||
expect(retried).toMatchObject({ retriedFromJobId: retryable.id, status: 'queued' })
|
||||
expect(retried.id).not.toBe(retryable.id)
|
||||
expect(rejectedRes.status).toBe(409)
|
||||
await expect(rejectedRes.json()).resolves.toEqual({ error: 'Background job cannot be retried' })
|
||||
const rejectedBody = (await rejectedRes.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(rejectedBody.error.message).toBe('Background job cannot be retried')
|
||||
expect(rejectedBody.error.details[0].reason).toBe('NOT_RETRYABLE')
|
||||
|
||||
const original = await createBackgroundJobRepo(db).get(orgId, retryable.id)
|
||||
expect(original).toMatchObject({ status: 'failed', errorMessage: 'zip_crc_error', retriedFromJobId: null })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { createBackgroundJobRequestSchema, listBackgroundJobsQuerySchema } from '../../shared/schemas'
|
||||
import { createBackgroundJobRequestSchema, listBackgroundJobsQuerySchema, pageSchema } from '../../shared/schemas'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
retryBackgroundJob,
|
||||
} from '../usecases/background-job'
|
||||
import { BackgroundJobError } from '../usecases/ports'
|
||||
import { errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
|
||||
// BackgroundJob is already wire-shaped (ISO string timestamps) — no DTO mapper.
|
||||
const backgroundJobProgressSchema = z.object({
|
||||
@@ -44,14 +44,7 @@ const backgroundJobSchema = z
|
||||
})
|
||||
.openapi('BackgroundJob')
|
||||
|
||||
const backgroundJobPageSchema = z
|
||||
.object({
|
||||
items: z.array(backgroundJobSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi('BackgroundJobPage')
|
||||
const backgroundJobPageSchema = pageSchema(backgroundJobSchema, 'BackgroundJobPage')
|
||||
|
||||
// The only client-driven status transition is cancellation.
|
||||
const cancelJobSchema = z.object({ status: z.literal('canceled') })
|
||||
@@ -137,7 +130,7 @@ app.use(requireAuth)
|
||||
const backgroundJobs = app
|
||||
.openapi(listRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No organization found' }, 404)
|
||||
if (!orgId) return apiError(c, 404, 'No organization found')
|
||||
const query = c.req.valid('query')
|
||||
const result = await listBackgroundJobs(c.get('deps'), orgId, query)
|
||||
return c.json({ ...result, page: query.page, pageSize: query.pageSize }, 200)
|
||||
|
||||
@@ -924,9 +924,9 @@ describe('Download tasks API integration', () => {
|
||||
})
|
||||
|
||||
expect(sessionRes.status).toBe(502)
|
||||
await expect(sessionRes.json()).resolves.toEqual({
|
||||
error: 'Storage multipart upload failed: bucket does not support multipart',
|
||||
})
|
||||
const sessionBody = (await sessionRes.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(sessionBody.error.message).toBe('Storage multipart upload failed: bucket does not support multipart')
|
||||
expect(sessionBody.error.details[0].reason).toBe('STORAGE_FAILURE')
|
||||
})
|
||||
|
||||
it('normalizes target folder paths when creating download tasks [spec: download-tasks/normalize-target]', async () => {
|
||||
@@ -992,9 +992,9 @@ describe('Download tasks API integration', () => {
|
||||
})
|
||||
|
||||
expect(completeRes.status).toBe(502)
|
||||
await expect(completeRes.json()).resolves.toEqual({
|
||||
error: 'Storage multipart upload complete failed: InvalidPart: part missing',
|
||||
})
|
||||
const completeBody = (await completeRes.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(completeBody.error.message).toBe('Storage multipart upload complete failed: InvalidPart: part missing')
|
||||
expect(completeBody.error.details[0].reason).toBe('STORAGE_FAILURE')
|
||||
})
|
||||
|
||||
it('submits user task actions through downloader polling state [spec: download-tasks/user-actions]', async () => {
|
||||
@@ -1048,7 +1048,11 @@ describe('Download tasks API integration', () => {
|
||||
body: JSON.stringify(transferProgress({ downloadBytes: 1024, downloadBps: 512 })),
|
||||
})
|
||||
expect(pausedProgressRes.status).toBe(409)
|
||||
await expect(pausedProgressRes.json()).resolves.toEqual({ error: 'Task is paused' })
|
||||
const pausedProgressBody = (await pausedProgressRes.json()) as {
|
||||
error: { message: string; details: { reason: string }[] }
|
||||
}
|
||||
expect(pausedProgressBody.error.message).toBe('Task is paused')
|
||||
expect(pausedProgressBody.error.details[0].reason).toBe('INVALID_STATE')
|
||||
|
||||
const resumeRes = await app.request(`/api/downloads/tasks/${createdTask.id}/status`, {
|
||||
method: 'PUT',
|
||||
@@ -1090,7 +1094,11 @@ describe('Download tasks API integration', () => {
|
||||
}),
|
||||
})
|
||||
expect(canceledCompleteRes.status).toBe(409)
|
||||
await expect(canceledCompleteRes.json()).resolves.toEqual({ error: 'Task is canceled' })
|
||||
const canceledCompleteBody = (await canceledCompleteRes.json()) as {
|
||||
error: { message: string; details: { reason: string }[] }
|
||||
}
|
||||
expect(canceledCompleteBody.error.message).toBe('Task is canceled')
|
||||
expect(canceledCompleteBody.error.details[0].reason).toBe('INVALID_STATE')
|
||||
|
||||
const deleteRes = await app.request(`/api/downloads/tasks/${createdTask.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -1189,7 +1197,11 @@ describe('Download tasks API integration', () => {
|
||||
body: JSON.stringify({ status: 'downloading', ...transferProgress({ downloadBytes: 3072 }) }),
|
||||
})
|
||||
expect(pausedProgressRes.status).toBe(409)
|
||||
await expect(pausedProgressRes.json()).resolves.toEqual({ error: 'Task is paused' })
|
||||
const pausedProgressBody = (await pausedProgressRes.json()) as {
|
||||
error: { message: string; details: { reason: string }[] }
|
||||
}
|
||||
expect(pausedProgressBody.error.message).toBe('Task is paused')
|
||||
expect(pausedProgressBody.error.details[0].reason).toBe('INVALID_STATE')
|
||||
})
|
||||
|
||||
it('preserves the completed download checkpoint when retrying an upload failure [spec: download-tasks/checkpoint-on-retry]', async () => {
|
||||
@@ -1424,7 +1436,11 @@ describe('Download tasks API integration', () => {
|
||||
body: JSON.stringify(transferProgress({ downloadBytes: 1024 })),
|
||||
})
|
||||
expect(pausingProgressRes.status).toBe(409)
|
||||
await expect(pausingProgressRes.json()).resolves.toEqual({ error: 'Task is pausing' })
|
||||
const pausingProgressBody = (await pausingProgressRes.json()) as {
|
||||
error: { message: string; details: { reason: string }[] }
|
||||
}
|
||||
expect(pausingProgressBody.error.message).toBe('Task is pausing')
|
||||
expect(pausingProgressBody.error.details[0].reason).toBe('INVALID_STATE')
|
||||
|
||||
const pausedRes = await app.request(`/api/downloads/tasks/${createdTask.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -1546,9 +1562,9 @@ describe('Download tasks API integration', () => {
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(deleteRes.status).toBe(409)
|
||||
await expect(deleteRes.json()).resolves.toMatchObject({
|
||||
error: 'Only completed, failed, or canceled tasks can be deleted',
|
||||
})
|
||||
const deleteBody = (await deleteRes.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(deleteBody.error.message).toBe('Only completed, failed, or canceled tasks can be deleted')
|
||||
expect(deleteBody.error.details[0].reason).toBe('INVALID_STATE')
|
||||
})
|
||||
|
||||
it('sorts and filters download tasks on the server [spec: download-tasks/sort-filter]', async () => {
|
||||
@@ -1616,9 +1632,13 @@ describe('Downloaders — free plan limit', () => {
|
||||
|
||||
const second = await postDownloader(app, admin, 'second')
|
||||
expect(second.status).toBe(402)
|
||||
const body = (await second.json()) as Record<string, unknown>
|
||||
expect(body.feature).toBe('downloaders_unlimited')
|
||||
expect(body.limit).toBe(1)
|
||||
const body = (await second.json()) as {
|
||||
error: { message: string; details: { reason: string; metadata: Record<string, string> }[] }
|
||||
}
|
||||
expect(body.error.message).toBe('Feature not available')
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('downloaders_unlimited')
|
||||
expect(body.error.details[0].metadata.limit).toBe('1')
|
||||
})
|
||||
|
||||
it('allows additional downloaders with the downloaders_unlimited entitlement [spec: download-tasks/unlimited-entitlement]', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
performDownloadTaskAction,
|
||||
updateDownloadTask,
|
||||
} from '../../usecases/downloads/downloads'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
// Every task operation surfaces the same DownloadError-based failure model. The
|
||||
// usecases throw it; the global onError converts it (not_found→404, forbidden→403,
|
||||
@@ -133,7 +133,7 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
const principal = c.get('principal')
|
||||
const query = c.req.valid('query')
|
||||
if (query.assignedTo === 'me') {
|
||||
if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (principal?.kind !== 'downloader') return apiError(c, 401, 'Unauthorized')
|
||||
const result = await listDownloadTasks(c.get('deps'), c.get('platform'), {
|
||||
downloaderId: principal.downloaderId,
|
||||
status: query.status,
|
||||
@@ -149,7 +149,7 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
}
|
||||
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
const result = await listDownloadTasks(c.get('deps'), c.get('platform'), {
|
||||
orgId,
|
||||
status: query.status,
|
||||
@@ -165,25 +165,25 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
.openapi(createRouteDoc, async (c) => {
|
||||
const principal = c.get('principal')
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
const actorId = principal?.kind === 'api-key' ? `api-key:${principal.keyId}` : (c.get('userId') as string)
|
||||
return c.json(await createDownloadTask(c.get('deps'), orgId, actorId, c.req.valid('json')), 201)
|
||||
})
|
||||
.openapi(getRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
return c.json(await getDownloadTask(c.get('deps'), orgId, c.req.valid('param').id), 200)
|
||||
})
|
||||
.openapi(statusRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
const { status } = c.req.valid('json')
|
||||
const action = status === 'paused' ? 'pause' : status === 'queued' ? 'resume' : 'cancel'
|
||||
return c.json(await performDownloadTaskAction(c.get('deps'), orgId, c.req.valid('param').id, action), 200)
|
||||
})
|
||||
.openapi(attemptRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
const { fresh } = c.req.valid('json')
|
||||
return c.json(
|
||||
await performDownloadTaskAction(c.get('deps'), orgId, c.req.valid('param').id, fresh ? 'restart' : 'retry'),
|
||||
@@ -192,7 +192,7 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
})
|
||||
.openapi(deleteRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
return c.json(await performDownloadTaskAction(c.get('deps'), orgId, c.req.valid('param').id, 'delete'), 200)
|
||||
})
|
||||
.openapi(updateRoute, async (c) => {
|
||||
@@ -206,7 +206,7 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
)
|
||||
}
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
return c.json(await updateDownloadTask(c.get('deps'), c.get('platform'), id, input, { orgId }), 200)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
createDownloaderSchema,
|
||||
deleteDownloaderResponseSchema,
|
||||
downloaderHeartbeatSchema,
|
||||
downloaderListSchema,
|
||||
downloaderSchema,
|
||||
featureGateErrorSchema,
|
||||
ErrorReason,
|
||||
pageSchema,
|
||||
updateDownloaderSchema,
|
||||
} from '@shared/schemas'
|
||||
import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants'
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
updateDownloader,
|
||||
} from '../../usecases/downloads/downloads'
|
||||
import { loadBindingState } from '../../usecases/site/licensing'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
const downloaderListSchema = pageSchema(downloaderSchema, 'DownloaderList')
|
||||
|
||||
const listRoute = createRoute({
|
||||
operationId: 'listDownloaders',
|
||||
@@ -47,7 +49,7 @@ const createRouteDoc = createRoute({
|
||||
responses: {
|
||||
201: jsonContent(createDownloaderResponseSchema, 'Downloader registration'),
|
||||
401: errorResponse('Unauthorized'),
|
||||
402: jsonContent(featureGateErrorSchema, 'Feature not available'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -61,7 +63,7 @@ const updateRoute = createRoute({
|
||||
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderSchema) },
|
||||
responses: {
|
||||
200: jsonContent(downloaderSchema, 'Updated downloader'),
|
||||
402: jsonContent(featureGateErrorSchema, 'Feature not available'),
|
||||
402: errorResponse('Feature not available'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
})
|
||||
@@ -100,24 +102,23 @@ const heartbeatRoute = createRoute({
|
||||
const downloadersRoute = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, async (c) => {
|
||||
const items = await listDownloaders(c.get('deps'))
|
||||
return c.json({ items, total: items.length }, 200)
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(createRouteDoc, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!userId) return apiError(c, 401, 'Unauthorized')
|
||||
const deps = c.get('deps')
|
||||
const [existing, state] = await Promise.all([listDownloaders(deps), loadBindingState(deps)])
|
||||
if (!hasFeature('downloaders_unlimited', state) && existing.length >= FREE_DOWNLOADER_LIMIT) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'feature_not_available',
|
||||
return apiError(c, 402, 'Feature not available', {
|
||||
reason: ErrorReason.FEATURE_NOT_AVAILABLE,
|
||||
metadata: {
|
||||
feature: 'downloaders_unlimited',
|
||||
currentCount: existing.length,
|
||||
limit: FREE_DOWNLOADER_LIMIT,
|
||||
upgrade_url: '/settings/billing',
|
||||
currentCount: String(existing.length),
|
||||
limit: String(FREE_DOWNLOADER_LIMIT),
|
||||
upgradeUrl: '/settings/billing',
|
||||
},
|
||||
402,
|
||||
)
|
||||
})
|
||||
}
|
||||
const result = await createDownloader(deps, c.get('platform'), c.req.valid('json'), userId)
|
||||
return c.json(result, 201)
|
||||
@@ -128,7 +129,10 @@ const downloadersRoute = new OpenAPIHono<Env>()
|
||||
if (input.remoteDownloadCreditBillingEnabled === true) {
|
||||
const state = await loadBindingState(c.get('deps'))
|
||||
if (!hasFeature('quota_store', state)) {
|
||||
return c.json({ error: 'feature_not_available', feature: 'quota_store' }, 402)
|
||||
return apiError(c, 402, 'Feature not available', {
|
||||
reason: ErrorReason.FEATURE_NOT_AVAILABLE,
|
||||
metadata: { feature: 'quota_store' },
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.json(await updateDownloader(c.get('deps'), id, input), 200)
|
||||
@@ -140,7 +144,7 @@ const downloadersRoute = new OpenAPIHono<Env>()
|
||||
|
||||
export const downloaderSelfRoute = new OpenAPIHono<Env>().openapi(heartbeatRoute, async (c) => {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (principal?.kind !== 'downloader') return apiError(c, 401, 'Unauthorized')
|
||||
return c.json(await recordDownloaderHeartbeat(c.get('deps'), principal.downloaderId, c.req.valid('json')), 200)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi'
|
||||
import { pageSchema } from '@shared/schemas'
|
||||
import type { EntitlementResult, QuotaEntitlementItem } from '../usecases/ports'
|
||||
|
||||
// Quota entitlement DTO shared by the team- and user-scoped admin endpoints. The
|
||||
@@ -41,6 +42,7 @@ export function toEntitlementResultDTO(r: EntitlementResult): z.infer<typeof ent
|
||||
return { orgId: r.orgId, entitlement: toQuotaEntitlementDTO(r.entitlement) }
|
||||
}
|
||||
|
||||
export const entitlementListSchema = z
|
||||
.object({ orgId: z.string(), items: z.array(quotaEntitlementSchema) })
|
||||
.openapi('EntitlementList')
|
||||
// Entitlements are returned as the shared Page<T> envelope like every other list.
|
||||
// They aren't truly paged (the full set is always returned), so handlers set
|
||||
// total = items.length and page = 1. orgId is dropped — it's already in the path.
|
||||
export const entitlementListSchema = pageSchema(quotaEntitlementSchema, 'EntitlementList')
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getImageHostingConfig,
|
||||
putImageHostingConfig,
|
||||
} from '../../usecases/image-hosting/config'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
const ihostConfigSchema = z
|
||||
.object({
|
||||
@@ -126,7 +126,7 @@ app.use(requireAuth)
|
||||
const ihostConfig = app
|
||||
.openapi(getRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
const { isCfConfigured, cnameTarget, cf } = cfFrom(c)
|
||||
const row = await getImageHostingConfig(c.get('deps'), orgId, cf)
|
||||
if (!row) return c.json({ enabled: false as const }, 200)
|
||||
@@ -134,19 +134,18 @@ const ihostConfig = app
|
||||
})
|
||||
.openapi(putRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
const { isCfConfigured, cnameTarget, cf } = cfFrom(c)
|
||||
const result = await putImageHostingConfig(c.get('deps'), orgId, c.req.valid('json'), cf)
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'app_host')
|
||||
return c.json({ error: 'Custom domain cannot be the application default host' }, 400)
|
||||
return c.json({ error: 'Domain already registered by another organization' }, 409)
|
||||
if (result.reason === 'app_host') return apiError(c, 400, 'Custom domain cannot be the application default host')
|
||||
return apiError(c, 409, 'Domain already registered by another organization')
|
||||
}
|
||||
return c.json(buildResponse(result.config, cnameTarget, isCfConfigured), 200)
|
||||
})
|
||||
.openapi(deleteRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
await deleteImageHostingConfig(c.get('deps'), orgId)
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
@@ -140,8 +140,8 @@ describe('POST /api/image-hosting/images (content type handling)', () => {
|
||||
body: JSON.stringify({ path: 'test.png', mime: 'image/png', size: 1024 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('file field')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('file field')
|
||||
})
|
||||
|
||||
it('returns 401 for application/json without any auth [spec: image-hosting/json-auth]', async () => {
|
||||
@@ -221,8 +221,8 @@ describe('POST /api/image-hosting/images (content type handling)', () => {
|
||||
body: JSON.stringify({ file: '!!!not-base64!!!' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('base64')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('base64')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -267,8 +267,8 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: JSON.stringify({ path: 'test.png', mime: 'image/png', size: 1024 }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toContain('image hosting not enabled')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('image hosting not enabled')
|
||||
})
|
||||
|
||||
it('returns 503 when no storage is configured [spec: image-hosting/requires-storage]', async () => {
|
||||
@@ -284,8 +284,9 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: JSON.stringify({ path: 'test.png', mime: 'image/png', size: 1024 }),
|
||||
})
|
||||
expect(res.status).toBe(503)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('storage')
|
||||
const body = (await res.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(body.error.message).toContain('storage')
|
||||
expect(body.error.details[0]?.reason).toBe('NO_STORAGE_CONFIGURED')
|
||||
})
|
||||
|
||||
it('returns 201 with draft row and presigned uploadUrl [spec: image-hosting/presign]', async () => {
|
||||
@@ -322,8 +323,8 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: JSON.stringify({ path: '../etc/passwd.png', mime: 'image/png', size: 1024 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('invalid path')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('invalid path')
|
||||
})
|
||||
|
||||
it('returns 400 for path exceeding depth 5 [spec: image-hosting/path-depth]', async () => {
|
||||
@@ -339,9 +340,9 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: JSON.stringify({ path: 'a/b/c/d/e/f.png', mime: 'image/png', size: 1024 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('invalid path')
|
||||
expect(String(body.detail)).toContain('depth')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('invalid path')
|
||||
expect(body.error.message).toContain('depth')
|
||||
})
|
||||
|
||||
it('returns 400 for disallowed mime (image/svg+xml) [spec: image-hosting/disallowed-svg]', async () => {
|
||||
@@ -445,9 +446,9 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: JSON.stringify({ path: 'a//b.png', mime: 'image/png', size: 1024 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('invalid path')
|
||||
expect(String(body.detail)).toContain('//')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('invalid path')
|
||||
expect(body.error.message).toContain('//')
|
||||
})
|
||||
|
||||
it('returns 400 for path starting with / (multipart)', async () => {
|
||||
@@ -467,9 +468,9 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: formData,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('invalid path')
|
||||
expect(String(body.detail)).toContain('must not start with /')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('invalid path')
|
||||
expect(body.error.message).toContain('must not start with /')
|
||||
})
|
||||
|
||||
it('returns 400 for path ending with / (multipart)', async () => {
|
||||
@@ -489,9 +490,9 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: formData,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('invalid path')
|
||||
expect(String(body.detail)).toContain('must not end with /')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('invalid path')
|
||||
expect(body.error.message).toContain('must not end with /')
|
||||
})
|
||||
|
||||
it('returns 400 for path with invalid characters (multipart)', async () => {
|
||||
@@ -511,9 +512,9 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: formData,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('invalid path')
|
||||
expect(String(body.detail)).toContain('invalid characters')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('invalid path')
|
||||
expect(body.error.message).toContain('invalid characters')
|
||||
})
|
||||
|
||||
it('derives default path from blob filename (uses nanoid fallback) [spec: image-hosting/default-path]', async () => {
|
||||
@@ -556,9 +557,9 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
|
||||
body: formData,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('invalid path')
|
||||
expect(String(body.detail)).toContain('exceeds')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('invalid path')
|
||||
expect(body.error.message).toContain('exceeds')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -793,8 +794,12 @@ describe('POST /api/image-hosting/images (multipart)', () => {
|
||||
body: formData,
|
||||
})
|
||||
expect(res.status).toBe(415)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('Unsupported')
|
||||
const body = (await res.json()) as {
|
||||
error: { message: string; details: { reason: string; metadata?: Record<string, string> }[] }
|
||||
}
|
||||
expect(body.error.message).toContain('Unsupported')
|
||||
expect(body.error.details[0]?.reason).toBe('UNSUPPORTED_MEDIA_TYPE')
|
||||
expect(body.error.details[0]?.metadata?.allowedTypes).toContain('image/png')
|
||||
})
|
||||
|
||||
it('infers MIME from file extension when type is application/octet-stream', async () => {
|
||||
@@ -852,8 +857,8 @@ describe('POST /api/image-hosting/images (multipart)', () => {
|
||||
body: formData,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('file field')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('file field')
|
||||
})
|
||||
|
||||
it('returns 422 when quota is exceeded on multipart upload', async () => {
|
||||
@@ -875,8 +880,10 @@ describe('POST /api/image-hosting/images (multipart)', () => {
|
||||
body: formData,
|
||||
})
|
||||
expect(res.status).toBe(422)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('Quota')
|
||||
const body = (await res.json()) as { error: { message: string; status: string; details: { reason: string }[] } }
|
||||
expect(body.error.message).toContain('Quota')
|
||||
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
expect(body.error.details[0]?.reason).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
|
||||
it('uses nanoid fallback path after exhausting collision retries', async () => {
|
||||
@@ -990,8 +997,12 @@ describe('PUT /api/image-hosting/images/:id/status (confirm)', () => {
|
||||
headers,
|
||||
})
|
||||
expect(patchRes.status).toBe(422)
|
||||
const body = (await patchRes.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('Quota')
|
||||
const body = (await patchRes.json()) as {
|
||||
error: { message: string; status: string; details: { reason: string }[] }
|
||||
}
|
||||
expect(body.error.message).toContain('Quota')
|
||||
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
expect(body.error.details[0]?.reason).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1135,8 +1146,8 @@ describe('DELETE /api/image-hosting/images/:id', () => {
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('image hosting not enabled')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('image hosting not enabled')
|
||||
})
|
||||
|
||||
it('returns 404 for non-existent image', async () => {
|
||||
@@ -1232,8 +1243,8 @@ describe('POST /api/image-hosting/images — API key auth error paths', () => {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 401 when API key verification throws an exception', async () => {
|
||||
@@ -1257,8 +1268,8 @@ describe('POST /api/image-hosting/images — API key auth error paths', () => {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 503 when no storage is configured for multipart upload via API key', async () => {
|
||||
@@ -1280,8 +1291,9 @@ describe('POST /api/image-hosting/images — API key auth error paths', () => {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
})
|
||||
expect(res.status).toBe(503)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(String(body.error)).toContain('storage')
|
||||
const body = (await res.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(body.error.message).toContain('storage')
|
||||
expect(body.error.details[0]?.reason).toBe('NO_STORAGE_CONFIGURED')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid'
|
||||
import {
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
createIhostImageSchema,
|
||||
ErrorReason,
|
||||
listIhostImagesSchema,
|
||||
MAX_IMAGE_SIZE,
|
||||
} from '../../../shared/schemas'
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
uploadImageHosting,
|
||||
} from '../../usecases/image-hosting/images'
|
||||
import type { ImageHostingRecord } from '../../usecases/ports'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
// The stored image's wire shape — timestamps as ISO strings (the record carries
|
||||
// them as Date).
|
||||
@@ -69,8 +70,6 @@ const imageListSchema = z
|
||||
.object({ items: z.array(imageHostingSchema), nextCursor: z.string().nullable() })
|
||||
.openapi('ImageHostingList')
|
||||
|
||||
const tooLargeSchema = z.object({ error: z.string(), maxBytes: z.number().int() })
|
||||
|
||||
// Derive a storage path from the upload's filename, falling back to a random name.
|
||||
function deriveDefaultPath(filename: string, mime: string): string {
|
||||
if (!filename || filename === 'blob') return `image-${nanoid(8)}.${mimeToExt(mime)}`
|
||||
@@ -110,7 +109,7 @@ const presignRoute = createRoute({
|
||||
201: jsonContent(imageDraftSchema, 'Image upload draft'),
|
||||
400: errorResponse('No active organization or invalid path'),
|
||||
403: errorResponse('Image hosting not enabled'),
|
||||
413: jsonContent(tooLargeSchema, 'File too large'),
|
||||
413: errorResponse('File too large'),
|
||||
503: errorResponse('No storage configured'),
|
||||
},
|
||||
})
|
||||
@@ -187,11 +186,11 @@ const app = new OpenAPIHono<Env>()
|
||||
// below keeps its typing.
|
||||
app.post('/images', requirePermission('ihost', 'upload'), async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
|
||||
const contentType = c.req.header('Content-Type') ?? ''
|
||||
const enabled = await requireImageHostingEnabled(c.get('deps'), orgId)
|
||||
if (!enabled.ok) return c.json({ error: 'image hosting not enabled for this organization' }, 403)
|
||||
if (!enabled.ok) return apiError(c, 403, 'image hosting not enabled for this organization')
|
||||
const config = enabled.config
|
||||
|
||||
let fileBytes: Uint8Array
|
||||
@@ -204,14 +203,14 @@ app.post('/images', requirePermission('ihost', 'upload'), async (c) => {
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid JSON body' }, 400)
|
||||
return apiError(c, 400, 'Invalid JSON body')
|
||||
}
|
||||
const b64 = body.file
|
||||
if (typeof b64 !== 'string' || !b64) return c.json({ error: 'file field (base64 string) is required' }, 400)
|
||||
if (typeof b64 !== 'string' || !b64) return apiError(c, 400, 'file field (base64 string) is required')
|
||||
try {
|
||||
fileBytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0))
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid base64 in file field' }, 400)
|
||||
return apiError(c, 400, 'Invalid base64 in file field')
|
||||
}
|
||||
fileName = typeof body.filename === 'string' && body.filename ? body.filename : 'upload'
|
||||
fileMime = detectMimeFromBytes(fileBytes) || 'application/octet-stream'
|
||||
@@ -219,20 +218,29 @@ app.post('/images', requirePermission('ihost', 'upload'), async (c) => {
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
const contentLength = Number(c.req.header('Content-Length') ?? '0')
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_IMAGE_SIZE)
|
||||
return c.json({ error: 'File too large', maxBytes: MAX_IMAGE_SIZE }, 413)
|
||||
return apiError(c, 413, 'File exceeds the maximum allowed size', {
|
||||
reason: ErrorReason.PAYLOAD_TOO_LARGE,
|
||||
metadata: { maxBytes: String(MAX_IMAGE_SIZE) },
|
||||
})
|
||||
const formData = await c.req.formData()
|
||||
const file = formData.get('file')
|
||||
if (!(file instanceof File)) return c.json({ error: 'file field is required' }, 400)
|
||||
if (!(file instanceof File)) return apiError(c, 400, 'file field is required')
|
||||
fileBytes = new Uint8Array(await file.arrayBuffer())
|
||||
fileName = file.name || 'upload'
|
||||
fileMime = file.type || ''
|
||||
const pathParam = formData.get('path')
|
||||
if (typeof pathParam === 'string' && pathParam) explicitPath = pathParam
|
||||
} else {
|
||||
return c.json({ error: 'Unsupported Content-Type. Use multipart/form-data or application/json with base64.' }, 415)
|
||||
return apiError(c, 415, 'Unsupported Content-Type. Use multipart/form-data or application/json with base64.', {
|
||||
reason: ErrorReason.UNSUPPORTED_MEDIA_TYPE,
|
||||
})
|
||||
}
|
||||
|
||||
if (fileBytes.byteLength > MAX_IMAGE_SIZE) return c.json({ error: 'File too large', maxBytes: MAX_IMAGE_SIZE }, 413)
|
||||
if (fileBytes.byteLength > MAX_IMAGE_SIZE)
|
||||
return apiError(c, 413, 'File exceeds the maximum allowed size', {
|
||||
reason: ErrorReason.PAYLOAD_TOO_LARGE,
|
||||
metadata: { maxBytes: String(MAX_IMAGE_SIZE) },
|
||||
})
|
||||
|
||||
let mime = fileMime
|
||||
if (!mime || mime === 'application/octet-stream') mime = detectMimeFromBytes(fileBytes) || ''
|
||||
@@ -247,13 +255,17 @@ app.post('/images', requirePermission('ihost', 'upload'), async (c) => {
|
||||
}
|
||||
mime = (ext && extMap[ext]) || mime || 'application/octet-stream'
|
||||
}
|
||||
if (mime === 'image/svg+xml') return c.json({ error: 'SVG images are not allowed' }, 415)
|
||||
if (mime === 'image/svg+xml')
|
||||
return apiError(c, 415, 'SVG images are not allowed', { reason: ErrorReason.UNSUPPORTED_MEDIA_TYPE })
|
||||
if (!(ALLOWED_IMAGE_MIMES as readonly string[]).includes(mime))
|
||||
return c.json({ error: 'Unsupported media type', allowedTypes: ALLOWED_IMAGE_MIMES }, 415)
|
||||
return apiError(c, 415, 'Unsupported media type', {
|
||||
reason: ErrorReason.UNSUPPORTED_MEDIA_TYPE,
|
||||
metadata: { allowedTypes: ALLOWED_IMAGE_MIMES.join(',') },
|
||||
})
|
||||
|
||||
const requestedPath = explicitPath || deriveDefaultPath(fileName, mime)
|
||||
const pathErr = validatePath(requestedPath)
|
||||
if (pathErr) return c.json(pathErr, 400)
|
||||
if (pathErr) return apiError(c, 400, `${pathErr.error}: ${pathErr.detail}`)
|
||||
|
||||
try {
|
||||
const result = await uploadImageHosting(c.get('deps'), {
|
||||
@@ -262,7 +274,7 @@ app.post('/images', requirePermission('ihost', 'upload'), async (c) => {
|
||||
mime: mime as (typeof ALLOWED_IMAGE_MIMES)[number],
|
||||
bytes: fileBytes,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'No storage configured' }, 503)
|
||||
if (!result.ok) return apiError(c, 503, 'No storage configured', { reason: ErrorReason.NO_STORAGE_CONFIGURED })
|
||||
const row = result.row
|
||||
const origin = new URL(c.req.url).origin
|
||||
const tokenUrl = `${origin}/r/${row.token}`
|
||||
@@ -289,24 +301,28 @@ app.post('/images', requirePermission('ihost', 'upload'), async (c) => {
|
||||
const ihost = app
|
||||
.openapi(presignRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const enabled = await requireImageHostingEnabled(c.get('deps'), orgId)
|
||||
if (!enabled.ok) return c.json({ error: 'image hosting not enabled for this organization' }, 403)
|
||||
if (!enabled.ok) return apiError(c, 403, 'image hosting not enabled for this organization')
|
||||
|
||||
const { path: requestedPath, mime, size } = c.req.valid('json')
|
||||
if (size > MAX_IMAGE_SIZE) return c.json({ error: 'File too large', maxBytes: MAX_IMAGE_SIZE }, 413)
|
||||
if (size > MAX_IMAGE_SIZE)
|
||||
return apiError(c, 413, 'File exceeds the maximum allowed size', {
|
||||
reason: ErrorReason.PAYLOAD_TOO_LARGE,
|
||||
metadata: { maxBytes: String(MAX_IMAGE_SIZE) },
|
||||
})
|
||||
const pathErr = validatePath(requestedPath)
|
||||
if (pathErr) return c.json(pathErr, 400)
|
||||
if (pathErr) return apiError(c, 400, `${pathErr.error}: ${pathErr.detail}`)
|
||||
|
||||
const result = await presignImageHostingUpload(c.get('deps'), { orgId, path: requestedPath, mime, size })
|
||||
if (!result.ok) return c.json({ error: 'No storage configured' }, 503)
|
||||
if (!result.ok) return apiError(c, 503, 'No storage configured', { reason: ErrorReason.NO_STORAGE_CONFIGURED })
|
||||
return c.json(result.result, 201)
|
||||
})
|
||||
.openapi(listRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const enabled = await requireImageHostingEnabled(c.get('deps'), orgId)
|
||||
if (!enabled.ok) return c.json({ error: 'image hosting not enabled for this organization' }, 403)
|
||||
if (!enabled.ok) return apiError(c, 403, 'image hosting not enabled for this organization')
|
||||
|
||||
const { pathPrefix, cursor, limit } = c.req.valid('query')
|
||||
const result = await listImageHostings(c.get('deps'), orgId, { pathPrefix, cursor, limit })
|
||||
@@ -314,33 +330,34 @@ const ihost = app
|
||||
})
|
||||
.openapi(getRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const enabled = await requireImageHostingEnabled(c.get('deps'), orgId)
|
||||
if (!enabled.ok) return c.json({ error: 'image hosting not enabled for this organization' }, 403)
|
||||
if (!enabled.ok) return apiError(c, 403, 'image hosting not enabled for this organization')
|
||||
|
||||
const row = await getImageHosting(c.get('deps'), c.req.valid('param').id, orgId)
|
||||
if (!row) return c.json({ error: 'Not found' }, 404)
|
||||
if (!row) return apiError(c, 404, 'Not found')
|
||||
return c.json(toImageHostingDTO(row), 200)
|
||||
})
|
||||
.openapi(confirmRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const enabled = await requireImageHostingEnabled(c.get('deps'), orgId)
|
||||
if (!enabled.ok) return c.json({ error: 'image hosting not enabled for this organization' }, 403)
|
||||
if (!enabled.ok) return apiError(c, 403, 'image hosting not enabled for this organization')
|
||||
|
||||
const { row, quotaExceeded } = await confirmImageHosting(c.get('deps'), c.req.valid('param').id, orgId)
|
||||
if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422)
|
||||
if (!row) return c.json({ error: 'Not found or not in draft status' }, 404)
|
||||
if (quotaExceeded)
|
||||
return apiError(c, 422, 'Quota exceeded', { reason: ErrorReason.QUOTA_EXCEEDED, status: 'RESOURCE_EXHAUSTED' })
|
||||
if (!row) return apiError(c, 404, 'Not found or not in draft status')
|
||||
return c.json(toImageHostingDTO(row), 200)
|
||||
})
|
||||
.openapi(deleteRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const enabled = await requireImageHostingEnabled(c.get('deps'), orgId)
|
||||
if (!enabled.ok) return c.json({ error: 'image hosting not enabled for this organization' }, 403)
|
||||
if (!enabled.ok) return apiError(c, 403, 'image hosting not enabled for this organization')
|
||||
|
||||
const deleted = await removeImageHosting(c.get('deps'), c.req.valid('param').id, orgId)
|
||||
if (!deleted) return c.json({ error: 'Not found' }, 404)
|
||||
if (!deleted) return apiError(c, 404, 'Not found')
|
||||
return c.body(null, 204)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { constantTimeEqual } from '../lib/constant-time'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { getDeployPlatform } from '../runtime-platform'
|
||||
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../usecases/site/instance-telemetry'
|
||||
import { apiError } from './openapi'
|
||||
|
||||
const INTERNAL_API_TOKEN_ENV = 'ZPAN_INTERNAL_API_TOKEN'
|
||||
|
||||
@@ -16,10 +17,10 @@ function envAllowsIp(value: string | undefined): boolean {
|
||||
internal.post('/instance-telemetry/report', async (c) => {
|
||||
const platform = c.get('platform')
|
||||
const token = platform.getEnv(INTERNAL_API_TOKEN_ENV)?.trim()
|
||||
if (!token) return c.json({ error: 'Not found' }, 404)
|
||||
if (!token) return apiError(c, 404, 'Not found')
|
||||
|
||||
const auth = c.req.header('authorization') ?? ''
|
||||
if (!constantTimeEqual(auth, `Bearer ${token}`)) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!constantTimeEqual(auth, `Bearer ${token}`)) return apiError(c, 401, 'Unauthorized')
|
||||
|
||||
const runtime = platform.getBinding('DB')
|
||||
? {
|
||||
|
||||
@@ -33,9 +33,10 @@ describe('[CF] Notifications API', () => {
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/notifications', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; unreadCount: number }
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
|
||||
expect(body.items).toHaveLength(0)
|
||||
expect(body.unreadCount).toBe(0)
|
||||
expect(body.total).toBe(0)
|
||||
expect(body.page).toBe(1)
|
||||
})
|
||||
|
||||
it('GET /api/notifications/stats returns 0', async () => {
|
||||
|
||||
@@ -50,10 +50,11 @@ describe('GET /api/notifications', () => {
|
||||
|
||||
const res = await app.request('/api/notifications', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; unreadCount: number }
|
||||
expect(body.items).toHaveLength(0)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
|
||||
expect(body.items).toEqual([])
|
||||
expect(body.total).toBe(0)
|
||||
expect(body.unreadCount).toBe(0)
|
||||
expect(body.page).toBe(1)
|
||||
expect(typeof body.pageSize).toBe('number')
|
||||
})
|
||||
|
||||
it('returns notifications with pagination [spec: notifications/list]', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { listNotificationsQuerySchema } from '@shared/schemas'
|
||||
import { listNotificationsQuerySchema, pageSchema } from '@shared/schemas'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
markNotificationRead,
|
||||
} from '../usecases/notification'
|
||||
import type { NotificationRecord } from '../usecases/ports'
|
||||
import { errorResponse, jsonContent } from './openapi'
|
||||
import { apiError, errorResponse, jsonContent } from './openapi'
|
||||
|
||||
const notificationSchema = z
|
||||
.object({
|
||||
@@ -45,15 +45,9 @@ function toNotificationDTO(n: NotificationRecord): NotificationDTO {
|
||||
}
|
||||
}
|
||||
|
||||
const notificationPageSchema = z
|
||||
.object({
|
||||
items: z.array(notificationSchema),
|
||||
total: z.number().int(),
|
||||
unreadCount: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi('NotificationPage')
|
||||
// The unread count is intentionally NOT part of the list envelope — it lives only
|
||||
// at GET /stats so the list shares the one Page<T> shape with every other resource.
|
||||
const notificationPageSchema = pageSchema(notificationSchema, 'NotificationPage')
|
||||
|
||||
const listRoute = createRoute({
|
||||
operationId: 'listNotifications',
|
||||
@@ -101,9 +95,7 @@ app.use(requireAuth)
|
||||
|
||||
export const notifications = app
|
||||
.openapi(listRoute, async (c) => {
|
||||
const { page: pageStr, pageSize: pageSizeStr, unread } = c.req.valid('query')
|
||||
const page = Number(pageStr ?? '1')
|
||||
const pageSize = Number(pageSizeStr ?? '20')
|
||||
const { page, pageSize, unread } = c.req.valid('query')
|
||||
const result = await listNotifications(c.get('deps'), c.get('userId')!, {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -113,7 +105,6 @@ export const notifications = app
|
||||
{
|
||||
items: result.items.map(toNotificationDTO),
|
||||
total: result.total,
|
||||
unreadCount: result.unreadCount,
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
@@ -126,7 +117,7 @@ export const notifications = app
|
||||
})
|
||||
.openapi(markReadRoute, async (c) => {
|
||||
const found = await markNotificationRead(c.get('deps'), c.get('userId')!, c.req.valid('param').id)
|
||||
if (!found) return c.json({ error: 'Not found' }, 404)
|
||||
if (!found) return apiError(c, 404, 'Not found')
|
||||
return c.body(null, 204)
|
||||
})
|
||||
.openapi(markAllReadRoute, async (c) => c.json(await markAllNotificationsRead(c.get('deps'), c.get('userId')!), 200))
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('[CF] Objects API', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('POST /api/objects returns 500 when no storage configured', async () => {
|
||||
it('POST /api/objects returns 503 when no storage configured', async () => {
|
||||
const app = await buildApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/objects', {
|
||||
@@ -56,7 +56,7 @@ describe('[CF] Objects API', () => {
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'test.txt', type: 'text/plain' }),
|
||||
})
|
||||
expect(res.status).toBe(500)
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('GET /api/objects/:id returns 404 for missing object', async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createQuotaRepo } from '../adapters/repos/quota.js'
|
||||
import { createStorageUsageRepo } from '../adapters/repos/storage-usage.js'
|
||||
import { cloudTrafficReports, orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
|
||||
import { currentTrafficPeriod } from '../domain/quota.js'
|
||||
import { authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js'
|
||||
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../usecases/object.js'
|
||||
import type {
|
||||
CopyMatterOptions,
|
||||
@@ -171,6 +171,17 @@ describe('Objects API', () => {
|
||||
expect(body.pageSize).toBe(10)
|
||||
})
|
||||
|
||||
// Regression: the file manager loads a whole folder client-side with
|
||||
// FILES_PAGE_SIZE=500, so the objects list must accept a pageSize above the
|
||||
// shared 100 cap. A stricter cap silently 400s the list and the UI never renders.
|
||||
it('GET /api/objects accepts the file-manager pageSize of 500', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/objects?pageSize=500', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { pageSize: number }).pageSize).toBe(500)
|
||||
})
|
||||
|
||||
it('POST /api/objects creates a folder [spec: objects/create-folder]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
@@ -200,7 +211,7 @@ describe('Objects API', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('POST /api/objects returns 500 when no storage available [spec: objects/create-no-storage]', async () => {
|
||||
it('POST /api/objects returns 503 when no storage available [spec: objects/create-no-storage]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/objects', {
|
||||
@@ -208,8 +219,10 @@ describe('Objects API', () => {
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'test.txt', type: 'text/plain' }),
|
||||
})
|
||||
expect(res.status).toBe(500)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Storage not configured' })
|
||||
expect(res.status).toBe(503)
|
||||
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
|
||||
expect(body.error.message).toBe('No storage configured')
|
||||
expect(body.error.details[0].reason).toBe('NO_STORAGE_CONFIGURED')
|
||||
})
|
||||
|
||||
it('GET /api/objects lists active objects in root', async () => {
|
||||
@@ -1043,10 +1056,12 @@ describe('Objects API — name conflict (409 responses)', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('NAME_CONFLICT')
|
||||
expect(body.conflictingName).toBe('Duplicates')
|
||||
expect(typeof body.conflictingId).toBe('string')
|
||||
const body = (await res.json()) as {
|
||||
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
|
||||
expect(body.error.details[0].metadata.conflictingName).toBe('Duplicates')
|
||||
expect(typeof body.error.details[0].metadata.conflictingId).toBe('string')
|
||||
})
|
||||
|
||||
it('POST /api/objects with onConflict: rename succeeds and returns auto-renamed folder [spec: objects/create-conflict-rename]', async () => {
|
||||
@@ -1082,9 +1097,11 @@ describe('Objects API — name conflict (409 responses)', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('NAME_CONFLICT')
|
||||
expect(body.conflictingName).toBe('beta.txt')
|
||||
const body = (await res.json()) as {
|
||||
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
|
||||
expect(body.error.details[0].metadata.conflictingName).toBe('beta.txt')
|
||||
})
|
||||
|
||||
it('PATCH /api/objects/:id rename with onConflict: rename succeeds', async () => {
|
||||
@@ -1121,8 +1138,8 @@ describe('Objects API — name conflict (409 responses)', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('NAME_CONFLICT')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
|
||||
})
|
||||
|
||||
it('PATCH /api/objects/:id move with onConflict: rename resolves collision', async () => {
|
||||
@@ -1161,8 +1178,8 @@ describe('Objects API — name conflict (409 responses)', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('NAME_CONFLICT')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
|
||||
})
|
||||
|
||||
it('PATCH /api/objects/:id (action: restore) returns 409 when restore name is already taken [spec: objects/restore-conflict]', async () => {
|
||||
@@ -1180,8 +1197,8 @@ describe('Objects API — name conflict (409 responses)', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('NAME_CONFLICT')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
|
||||
})
|
||||
|
||||
it('PATCH /api/objects/:id (action: restore) with onConflict: rename restores with suffix', async () => {
|
||||
@@ -1219,8 +1236,8 @@ describe('Objects API — name conflict (409 responses)', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('NAME_CONFLICT')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
|
||||
})
|
||||
|
||||
it('POST /api/objects/copy auto-renames by default when target has same name', async () => {
|
||||
@@ -1403,8 +1420,8 @@ describe('POST /api/objects/:id/transfers', () => {
|
||||
|
||||
const res = await transferRequest(app, headers, 'src-big', { targetOrgId: 'team-small', mode: 'copy' })
|
||||
expect(res.status).toBe(422)
|
||||
const body = (await res.json()) as { code: string }
|
||||
expect(body.code).toBe('QUOTA_EXCEEDED')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
|
||||
it('rejects transfer to the same space [spec: objects/transfer-same-space]', async () => {
|
||||
@@ -1546,8 +1563,9 @@ describe('Objects API — quota enforcement', () => {
|
||||
body: JSON.stringify({ parent: '' }),
|
||||
})
|
||||
expect(res.status).toBe(422)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Quota exceeded')
|
||||
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
|
||||
expect(body.error.message).toBe('Quota exceeded')
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
|
||||
it('returns 201 and increments orgQuotas.used when copy succeeds within quota', async () => {
|
||||
@@ -1862,7 +1880,12 @@ describe('Objects API — quota enforcement', () => {
|
||||
|
||||
const res = await app.request('/api/objects/m-download-over', { headers })
|
||||
expect(res.status).toBe(422)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Traffic quota exceeded' })
|
||||
const body = (await res.json()) as {
|
||||
error: { message: string; status: string; details: Array<{ reason: string }> }
|
||||
}
|
||||
expect(body.error.message).toBe('Traffic quota exceeded')
|
||||
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
|
||||
const rows = await db.all<{ trafficUsed: number }>(
|
||||
@@ -1942,7 +1965,7 @@ describe('Objects API — quota enforcement', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(422)
|
||||
await expect(res.json()).resolves.toMatchObject({ error: 'Quota exceeded' })
|
||||
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Quota exceeded' } })
|
||||
})
|
||||
|
||||
it('returns 200 and increments storages.used when quota allows', async () => {
|
||||
@@ -1979,8 +2002,9 @@ describe('Objects API — quota enforcement', () => {
|
||||
body: JSON.stringify({ status: 'active' }),
|
||||
})
|
||||
expect(res.status).toBe(422)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Quota exceeded')
|
||||
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
|
||||
expect(body.error.message).toBe('Quota exceeded')
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
|
||||
it('does not change usage when a file with size 0 is confirmed', async () => {
|
||||
@@ -2262,3 +2286,214 @@ describe('object multipart upload API with S3-compatible storage', () => {
|
||||
await expect(downloadRes.text()).resolves.toBe('hello world')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Error-branch coverage (AIP-193 bodies) ───────────────────────────────────
|
||||
// These exercise the inline `apiError(...)` guards in the handlers that the
|
||||
// happy-path tests above don't reach: cross-org list authz, missing-storage
|
||||
// resolution, the download-task-upload confirm guards, and the editor-access
|
||||
// gate for a user-scoped (orgId-less) API key principal.
|
||||
|
||||
// Creates an API key via the real better-auth plugin. A `webdav` config-id key
|
||||
// is user-scoped, so the auth middleware resolves it with userId set and orgId
|
||||
// null — the exact state the editor-access gate denies.
|
||||
async function createUserApiKey(
|
||||
auth: Awaited<ReturnType<typeof createTestApp>>['auth'],
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API not fully typed
|
||||
const result = (await (auth.api as any).createApiKey({
|
||||
body: { configId: 'webdav', userId },
|
||||
})) as { key: string }
|
||||
return result.key
|
||||
}
|
||||
|
||||
const downloaderHeartbeat = {
|
||||
version: '1.0.0',
|
||||
hostname: 'host',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
engine: 'aria2',
|
||||
capabilities: ['http', 'magnet', 'torrent'],
|
||||
maxConcurrentTasks: 2,
|
||||
currentTasks: 0,
|
||||
downloadBps: 0,
|
||||
uploadBps: 0,
|
||||
freeDiskBytes: 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// Registers a downloader, creates and self-assigns a download task to it, and
|
||||
// returns the upload token plus the task's target folder. The token authenticates
|
||||
// as a `download-task-upload` principal scoped to that task/folder.
|
||||
async function mintTaskUploadContext(
|
||||
app: TestApp,
|
||||
db: TestDb,
|
||||
opts: { targetFolder: string },
|
||||
): Promise<{ uploadToken: string; targetFolder: string; orgId: string }> {
|
||||
const admin = await adminHeaders(app)
|
||||
const codeRes = await app.request('/api/auth/device/code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: 'zpan-cli', scope: 'downloader:register' }),
|
||||
})
|
||||
const code = (await codeRes.json()) as { device_code: string; user_code: string }
|
||||
await app.request(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`, { headers: admin })
|
||||
await app.request('/api/auth/device/approve', {
|
||||
method: 'POST',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userCode: code.user_code }),
|
||||
})
|
||||
const tokenRes = await app.request('/api/auth/device/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
device_code: code.device_code,
|
||||
client_id: 'zpan-cli',
|
||||
}),
|
||||
})
|
||||
const cliToken = (await tokenRes.json()) as { access_token: string }
|
||||
const downloaderHeaders = { Authorization: `Bearer ${cliToken.access_token}`, 'Content-Type': 'application/json' }
|
||||
const createDownloaderRes = await app.request('/api/downloads/downloaders', {
|
||||
method: 'POST',
|
||||
headers: downloaderHeaders,
|
||||
body: JSON.stringify({ name: 'object-error-downloader', heartbeat: downloaderHeartbeat }),
|
||||
})
|
||||
const downloader = (await createDownloaderRes.json()) as { token: string }
|
||||
await app.request('/api/downloads/downloaders/me/heartbeats', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${downloader.token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...downloaderHeartbeat, currentTasks: 0 }),
|
||||
})
|
||||
|
||||
const user = await adminHeaders(app)
|
||||
const createTaskRes = await app.request('/api/downloads/tasks', {
|
||||
method: 'POST',
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: { type: 'http', uri: 'https://example.com/file.txt' },
|
||||
targetFolder: opts.targetFolder,
|
||||
name: 'file.txt',
|
||||
}),
|
||||
})
|
||||
expect(createTaskRes.status).toBe(201)
|
||||
|
||||
const assignedRes = await app.request('/api/downloads/tasks?assignedTo=me', {
|
||||
headers: { Authorization: `Bearer ${downloader.token}` },
|
||||
})
|
||||
const assigned = (await assignedRes.json()) as {
|
||||
items: Array<{ status: { assignment?: { uploadToken?: string } } }>
|
||||
}
|
||||
const uploadToken = assigned.items[0]?.status.assignment?.uploadToken
|
||||
if (!uploadToken) throw new Error('upload_token_missing')
|
||||
const orgRows = await db.all<{ orgId: string }>(sql`SELECT org_id AS orgId FROM download_tasks LIMIT 1`)
|
||||
return { uploadToken, targetFolder: opts.targetFolder, orgId: orgRows[0].orgId }
|
||||
}
|
||||
|
||||
describe('Objects API — error branches', () => {
|
||||
it('returns 403 for a user-scoped API key with no active org on write', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const userId = await getUserIdByEmail(db, 'test@example.com')
|
||||
const key = await createUserApiKey(auth, userId)
|
||||
|
||||
const res = await app.request('/api/objects', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'denied.txt', type: 'text/plain', size: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
expect(body.error.status).toBe('PERMISSION_DENIED')
|
||||
})
|
||||
|
||||
it('returns 403 when listing an org the user cannot read via orgId override', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
await insertTeamOrg(db, 'team-foreign')
|
||||
|
||||
const res = await app.request('/api/objects?orgId=team-foreign', { headers })
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
expect(body.error.status).toBe('PERMISSION_DENIED')
|
||||
})
|
||||
|
||||
it('returns 404 when a file references a missing storage on GET', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const orgId = await getOrgId(db)
|
||||
// File with a non-empty object key but no matching storage row.
|
||||
await insertFile(db, orgId, { id: 'm-no-storage', name: 'orphan.txt' })
|
||||
|
||||
const res = await app.request('/api/objects/m-no-storage', { headers })
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Storage not found')
|
||||
})
|
||||
|
||||
it('returns 404 when copying a file whose storage is missing', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'm-copy-orphan', name: 'orphan.txt' })
|
||||
|
||||
const res = await app.request('/api/objects/m-copy-orphan/copies', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parent: '' }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Storage not found')
|
||||
})
|
||||
|
||||
it('returns 404 when transferring a missing object', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const userId = await getUserIdByEmail(db, 'test@example.com')
|
||||
await insertTeamOrg(db, 'team-dest')
|
||||
await insertMember(db, 'team-dest', userId, 'editor')
|
||||
|
||||
const res = await transferRequest(app, headers, 'does-not-exist', { targetOrgId: 'team-dest', mode: 'copy' })
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Not found')
|
||||
})
|
||||
|
||||
it('rejects a download-task-upload token that tries to trash an object', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const { uploadToken, orgId } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' })
|
||||
await insertFile(db, orgId, { id: 'm-task-trash', name: 'file.txt', parent: 'Remote' })
|
||||
|
||||
const res = await app.request('/api/objects/m-task-trash/status', {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'trashed' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Download task upload token can only confirm uploads')
|
||||
})
|
||||
|
||||
it('rejects a download-task-upload confirm outside the task target folder', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const { uploadToken, orgId } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' })
|
||||
// Draft sits outside the token's authorized folder, so the confirm guard denies.
|
||||
await insertFile(db, orgId, { id: 'm-task-outside', name: 'file.txt', parent: 'Elsewhere', status: 'draft' })
|
||||
|
||||
const res = await app.request('/api/objects/m-task-outside/status', {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'active' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
+67
-64
@@ -1,19 +1,22 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import type { Context } from 'hono'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import {
|
||||
copyObjectBodySchema,
|
||||
createMatterSchema,
|
||||
createObjectUploadSessionSchema,
|
||||
ErrorReason,
|
||||
objectStatusSchema,
|
||||
objectUploadSessionSchema,
|
||||
objectUploadStatusSchema,
|
||||
pageQuerySchema,
|
||||
pageSchema,
|
||||
patchMatterSchema,
|
||||
presignObjectUploadPartsResponseSchema,
|
||||
presignObjectUploadPartsSchema,
|
||||
transferMatterSchema,
|
||||
} from '../../shared/schemas'
|
||||
} from '@shared/schemas'
|
||||
import type { Context } from 'hono'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
@@ -37,7 +40,7 @@ import {
|
||||
updateObject,
|
||||
} from '../usecases/object'
|
||||
import type { Matter } from '../usecases/ports'
|
||||
import { errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
|
||||
// The wire shape of a file/folder — exactly what the API serializes. Timestamps
|
||||
// are strings here (the domain `Matter` carries them as `Date`); `toMatterDTO`
|
||||
@@ -88,14 +91,7 @@ function toMatterDTO(m: Matter): MatterDTO {
|
||||
}
|
||||
}
|
||||
|
||||
const objectPageSchema = z
|
||||
.object({
|
||||
items: z.array(matterSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi('ObjectPage')
|
||||
const objectPageSchema = pageSchema(matterSchema, 'ObjectPage')
|
||||
|
||||
// POST / returns the created object plus, for direct uploads, the presigned URL
|
||||
// to PUT the bytes to.
|
||||
@@ -108,23 +104,19 @@ const objectCreateResultSchema = matterSchema.extend({
|
||||
// download URL.
|
||||
const objectWithDownloadSchema = matterSchema.extend({ downloadUrl: z.string().optional() })
|
||||
|
||||
// A 402 carrying the credit-gated resource so clients can prompt a top-up.
|
||||
const insufficientCreditsSchema = z.object({
|
||||
error: z.string(),
|
||||
code: z.string(),
|
||||
resource: z.string(),
|
||||
})
|
||||
|
||||
// List endpoint reads query params ad-hoc; declared here for docs + RPC typing.
|
||||
// All optional so callers may send any subset.
|
||||
const listObjectsQuerySchema = z.object({
|
||||
// The non-pagination filters are optional so callers may send any subset; `page`
|
||||
// comes from the shared integer-coerced pagination schema. The file manager loads a
|
||||
// whole folder client-side (no UI paging, FILES_PAGE_SIZE=500), so this list
|
||||
// overrides the shared pageSize cap of 100 with a higher ceiling — the rest of the
|
||||
// API keeps the 100 default.
|
||||
const listObjectsQuerySchema = pageQuerySchema.extend({
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).default(20),
|
||||
parent: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.string().optional(),
|
||||
pageSize: z.string().optional(),
|
||||
orgId: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -163,7 +155,7 @@ const requireObjectWriteAccess = createMiddleware<Env>(async (c, next) => {
|
||||
return
|
||||
}
|
||||
if (!(await hasEditorAccess(c.get('deps'), { orgId: c.get('orgId'), userId: c.get('userId') }))) {
|
||||
return c.json({ error: c.get('userId') ? 'Forbidden' : 'Unauthorized' }, c.get('userId') ? 403 : 401)
|
||||
return c.get('userId') ? apiError(c, 403, 'Forbidden') : apiError(c, 401, 'Unauthorized')
|
||||
}
|
||||
await next()
|
||||
})
|
||||
@@ -196,7 +188,7 @@ const createObjectRoute = createRoute({
|
||||
400: errorResponse('No active organization'),
|
||||
403: errorResponse('Forbidden'),
|
||||
409: errorResponse('Name conflict'),
|
||||
500: errorResponse('Storage not configured'),
|
||||
503: errorResponse('No storage configured'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -278,7 +270,7 @@ const getObjectRoute = createRoute({
|
||||
responses: {
|
||||
200: jsonContent(objectWithDownloadSchema, 'Object'),
|
||||
400: errorResponse('No active organization'),
|
||||
402: jsonContent(insufficientCreditsSchema, 'Insufficient credits'),
|
||||
402: errorResponse('Insufficient credits'),
|
||||
404: errorResponse('Not found'),
|
||||
422: errorResponse('Traffic quota exceeded'),
|
||||
},
|
||||
@@ -387,39 +379,40 @@ app.use(async (c, next) => {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
return apiError(c, 401, 'Unauthorized')
|
||||
})
|
||||
|
||||
const objects = app
|
||||
.openapi(listRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
|
||||
const query = c.req.valid('query')
|
||||
const result = await listObjects(c.get('deps'), {
|
||||
orgId,
|
||||
userId: c.get('userId')!,
|
||||
orgOverride: c.req.query('orgId'),
|
||||
orgOverride: query.orgId,
|
||||
filters: {
|
||||
parent: c.req.query('path') ?? c.req.query('parent') ?? '',
|
||||
status: c.req.query('status') ?? 'active',
|
||||
typeFilter: c.req.query('type'),
|
||||
search: c.req.query('search'),
|
||||
page: Number(c.req.query('page') ?? '1'),
|
||||
pageSize: Number(c.req.query('pageSize') ?? '20'),
|
||||
parent: query.path ?? query.parent ?? '',
|
||||
status: query.status ?? 'active',
|
||||
typeFilter: query.type,
|
||||
search: query.search,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
},
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'Forbidden' }, 403)
|
||||
if (!result.ok) return apiError(c, 403, 'Forbidden')
|
||||
return c.json({ ...result.result, items: result.result.items.map(toMatterDTO) }, 200)
|
||||
})
|
||||
.openapi(createObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
|
||||
const result = await createObject(c.get('deps'), { orgId, actor: objectActor(c), input: c.req.valid('json') })
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'target_outside_authorization')
|
||||
return c.json({ error: 'Target folder is outside task authorization' }, 403)
|
||||
return c.json({ error: 'Storage not configured' }, 500)
|
||||
return apiError(c, 403, 'Target folder is outside task authorization')
|
||||
return apiError(c, 503, 'No storage configured', { reason: ErrorReason.NO_STORAGE_CONFIGURED })
|
||||
}
|
||||
if ('uploadUrl' in result)
|
||||
return c.json(
|
||||
@@ -474,7 +467,7 @@ const objects = app
|
||||
})
|
||||
.openapi(getObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
|
||||
const result = await getObject(c.get('deps'), {
|
||||
orgId,
|
||||
@@ -488,32 +481,38 @@ const objects = app
|
||||
}
|
||||
switch (result.reason) {
|
||||
case 'not_found':
|
||||
return c.json({ error: 'Not found' }, 404)
|
||||
return apiError(c, 404, 'Not found')
|
||||
case 'storage_not_found':
|
||||
return c.json({ error: 'Storage not found' }, 404)
|
||||
return apiError(c, 404, 'Storage not found')
|
||||
case 'quota_exceeded':
|
||||
return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
return apiError(c, 422, 'Traffic quota exceeded', {
|
||||
reason: ErrorReason.QUOTA_EXCEEDED,
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
})
|
||||
case 'insufficient_credits':
|
||||
return c.json({ error: 'insufficient_credits', code: 'insufficient_credits', resource: 'storage_egress' }, 402)
|
||||
return apiError(c, 402, 'Insufficient credits', {
|
||||
reason: ErrorReason.INSUFFICIENT_CREDITS,
|
||||
metadata: { resource: 'storage_egress' },
|
||||
})
|
||||
}
|
||||
})
|
||||
.openapi(patchObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await updateObject(c.get('deps'), {
|
||||
orgId,
|
||||
objectId: c.req.valid('param').id,
|
||||
actorId: actorId(c),
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'Not found' }, 404)
|
||||
if (!result.ok) return apiError(c, 404, 'Not found')
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
})
|
||||
// Lifecycle transitions: { status:'active' } confirms a draft or restores from
|
||||
// trash (server picks by current state); { status:'trashed' } soft-deletes.
|
||||
.openapi(objectStatusRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const objectId = c.req.valid('param').id
|
||||
const { status, onConflict } = c.req.valid('json')
|
||||
|
||||
@@ -521,7 +520,7 @@ const objects = app
|
||||
if (principal?.kind === 'download-task-upload') {
|
||||
// Upload tokens may only confirm their own draft.
|
||||
if (status !== 'active') {
|
||||
return c.json({ error: 'Download task upload token can only confirm uploads' }, 403)
|
||||
return apiError(c, 403, 'Download task upload token can only confirm uploads')
|
||||
}
|
||||
const authorized = await authorizeTaskUploadConfirm(c.get('deps'), {
|
||||
orgId,
|
||||
@@ -530,12 +529,12 @@ const objects = app
|
||||
downloaderId: principal.downloaderId,
|
||||
targetFolder: principal.targetFolder,
|
||||
})
|
||||
if (!authorized.ok) return c.json({ error: 'Forbidden' }, 403)
|
||||
if (!authorized.ok) return apiError(c, 403, 'Forbidden')
|
||||
}
|
||||
|
||||
if (status === 'trashed') {
|
||||
const result = await trashObject(c.get('deps'), { orgId, objectId, actorId: actorId(c) })
|
||||
if (!result.ok) return c.json({ error: 'Not found' }, 404)
|
||||
if (!result.ok) return apiError(c, 404, 'Not found')
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
}
|
||||
|
||||
@@ -544,15 +543,16 @@ const objects = app
|
||||
// global onError, which maps them to 409 / 422.
|
||||
const confirmed = await confirmObject(c.get('deps'), { orgId, objectId, actorId: actorId(c), onConflict })
|
||||
if (confirmed.ok) return c.json(toMatterDTO(confirmed.matter), 200)
|
||||
if (confirmed.reason === 'quota_exceeded') return c.json({ error: 'Quota exceeded' }, 422)
|
||||
if (confirmed.reason === 'quota_exceeded')
|
||||
return apiError(c, 422, 'Quota exceeded', { reason: ErrorReason.QUOTA_EXCEEDED, status: 'RESOURCE_EXHAUSTED' })
|
||||
|
||||
const restored = await restoreObject(c.get('deps'), { orgId, objectId, actorId: actorId(c), onConflict })
|
||||
if (!restored.ok) return c.json({ error: 'Not found' }, 404)
|
||||
if (!restored.ok) return apiError(c, 404, 'Not found')
|
||||
return c.json(toMatterDTO(restored.matter), 200)
|
||||
})
|
||||
.openapi(deleteObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const objectId = c.req.valid('param').id
|
||||
const result = await deleteObject(c.get('deps'), { orgId, objectId, userId: c.get('userId')! })
|
||||
if (result.ok) return c.json({ id: result.id, deleted: true as const, purged: result.purged }, 200)
|
||||
@@ -561,13 +561,13 @@ const objects = app
|
||||
// must be trashed before it can be permanently deleted.
|
||||
const cancelled = await cancelObject(c.get('deps'), { orgId, objectId, actorId: actorId(c) })
|
||||
if (cancelled.ok) return c.json({ id: cancelled.id, deleted: true as const, purged: false as const }, 200)
|
||||
return c.json({ error: 'Object must be trashed before permanent deletion' }, 409)
|
||||
return apiError(c, 409, 'Object must be trashed before permanent deletion')
|
||||
}
|
||||
return c.json({ error: 'Not found' }, 404)
|
||||
return apiError(c, 404, 'Not found')
|
||||
})
|
||||
.openapi(copyObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
|
||||
const body = c.req.valid('json')
|
||||
const result = await copyObject(c.get('deps'), {
|
||||
@@ -576,14 +576,14 @@ const objects = app
|
||||
input: { copyFrom: c.req.valid('param').id, parent: body.parent, onConflict: body.onConflict },
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'storage_not_found') return c.json({ error: 'Storage not found' }, 404)
|
||||
return c.json({ error: 'Not found' }, 404)
|
||||
if (result.reason === 'storage_not_found') return apiError(c, 404, 'Storage not found')
|
||||
return apiError(c, 404, 'Not found')
|
||||
}
|
||||
return c.json(toMatterDTO(result.matter), 201)
|
||||
})
|
||||
.openapi(transferObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
|
||||
const result = await transferObject(c.get('deps'), {
|
||||
orgId,
|
||||
@@ -594,13 +594,16 @@ const objects = app
|
||||
if (!result.ok) {
|
||||
switch (result.reason) {
|
||||
case 'same_org':
|
||||
return c.json({ error: 'Target must be a different space', code: 'SAME_ORG' }, 400)
|
||||
return apiError(c, 400, 'Target must be a different space', { reason: 'SAME_ORG' })
|
||||
case 'not_found':
|
||||
return c.json({ error: 'Not found' }, 404)
|
||||
return apiError(c, 404, 'Not found')
|
||||
case 'forbidden':
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
case 'quota_exceeded':
|
||||
return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 422)
|
||||
return apiError(c, 422, 'Quota exceeded', {
|
||||
reason: ErrorReason.QUOTA_EXCEEDED,
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
|
||||
+20
-1
@@ -1,5 +1,9 @@
|
||||
import type { z } from '@hono/zod-openapi'
|
||||
import { errorResponseSchema } from '@shared/schemas'
|
||||
import type { Context } from 'hono'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { buildErrorBody, type ErrorOptions } from '../lib/http-errors'
|
||||
import type { Env } from '../middleware/platform'
|
||||
|
||||
// Shared OpenAPI route helpers used by every resource router. Generic over the
|
||||
// schema so its precise type reaches `createRoute`: that types `c.req.valid(...)`
|
||||
@@ -17,6 +21,21 @@ export const jsonBody = <T extends z.ZodType>(schema: T) => ({
|
||||
body: { content: { 'application/json': { schema } }, required: true },
|
||||
})
|
||||
|
||||
// A route response carrying the shared `ErrorResponse` envelope. Errors thrown by
|
||||
// A route response carrying the shared AIP-193 `Error` envelope. Errors thrown by
|
||||
// usecases are converted centrally by `app.onError`; this just documents them.
|
||||
export const errorResponse = (description: string) => jsonContent(errorResponseSchema, description)
|
||||
|
||||
// The single way a handler returns an error inline. Builds the AIP-193 body and
|
||||
// stashes the reason + message for the access log, so every 4xx/5xx is observable.
|
||||
// `reason` defaults to the canonical status for the HTTP code (e.g. 403 →
|
||||
// PERMISSION_DENIED); pass `opts.reason`/`opts.metadata` for specific errors.
|
||||
export function apiError<S extends ContentfulStatusCode>(
|
||||
c: Context<Env>,
|
||||
status: S,
|
||||
message: string,
|
||||
opts: ErrorOptions = {},
|
||||
) {
|
||||
const body = buildErrorBody(status, message, opts)
|
||||
c.set('errorLog', { reason: body.error.details?.[0]?.reason ?? body.error.status, message })
|
||||
return c.json(body, status)
|
||||
}
|
||||
|
||||
+10
-8
@@ -1,8 +1,9 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { getUserQuota, listQuotaOverview } from '../usecases/quota'
|
||||
import { errorResponse, jsonContent } from './openapi'
|
||||
import { apiError, 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.
|
||||
@@ -41,9 +42,7 @@ const quotaOverviewItemSchema = effectiveQuotaSchema
|
||||
.extend({ id: z.string(), orgName: z.string(), orgType: z.string() })
|
||||
.openapi('QuotaOverviewItem')
|
||||
|
||||
const quotaOverviewSchema = z
|
||||
.object({ items: z.array(quotaOverviewItemSchema), total: z.number().int() })
|
||||
.openapi('QuotaOverview')
|
||||
const quotaOverviewSchema = pageSchema(quotaOverviewItemSchema, 'QuotaOverview')
|
||||
|
||||
const listQuotaOverviewRoute = createRoute({
|
||||
operationId: 'listQuotaOverview',
|
||||
@@ -70,13 +69,16 @@ const getMyQuotaRoute = createRoute({
|
||||
|
||||
// Quota overview across all orgs (personal + team), used by the admin dashboard.
|
||||
// Per-team entitlement management lives under /api/teams.
|
||||
const adminQuotas = new OpenAPIHono<Env>().openapi(listQuotaOverviewRoute, async (c) =>
|
||||
c.json(await listQuotaOverview(c.get('deps')), 200),
|
||||
)
|
||||
const adminQuotas = new OpenAPIHono<Env>().openapi(listQuotaOverviewRoute, async (c) => {
|
||||
// The overview returns every space in one shot rather than paging, so the page
|
||||
// metadata mirrors the full result.
|
||||
const { items, total } = await listQuotaOverview(c.get('deps'))
|
||||
return c.json({ items, total, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
|
||||
const userQuotas = new OpenAPIHono<Env>().openapi(getMyQuotaRoute, async (c) => {
|
||||
const quota = await getUserQuota(c.get('deps'), { userId: c.get('userId')!, orgId: c.get('orgId') ?? undefined })
|
||||
if (!quota) return c.json({ error: 'No organization found' }, 404)
|
||||
if (!quota) return apiError(c, 404, 'No organization found')
|
||||
return c.json(quota, 200)
|
||||
})
|
||||
|
||||
|
||||
@@ -163,7 +163,9 @@ describe('GET /r/:token (ds_ direct shares)', () => {
|
||||
|
||||
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
|
||||
expect(res.status).toBe(422)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Traffic quota exceeded' })
|
||||
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
|
||||
expect(body.error.message).toBe('Traffic quota exceeded')
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
|
||||
const shares = await db.all<{ downloads: number }>(sql`SELECT downloads FROM shares WHERE id = ${share.id}`)
|
||||
@@ -194,6 +196,47 @@ describe('GET /r/:token (ds_ direct shares)', () => {
|
||||
expect(rows[0].trafficUsed).toBe(1280)
|
||||
})
|
||||
|
||||
it('returns 410 with AIP-193 body when a direct share is expired', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFile(db, orgId, { id: 'ds-expired', name: 'expired.bin' })
|
||||
const share = await createShareRepo(db).create({
|
||||
matterId: 'ds-expired',
|
||||
orgId,
|
||||
creatorId,
|
||||
kind: 'direct',
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
})
|
||||
|
||||
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
|
||||
expect(res.status).toBe(410)
|
||||
const body = (await res.json()) as { error: { code: number; message: string; status: string } }
|
||||
expect(body.error.code).toBe(410)
|
||||
expect(body.error.message).toBe('Share has expired')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when a direct share references a missing storage', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
// Intentionally do NOT insert the storage row; the matter points at a
|
||||
// storage_id that does not exist.
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFile(db, orgId, { id: 'ds-no-storage', name: 'orphan.bin' })
|
||||
const share = await createShareRepo(db).create({ matterId: 'ds-no-storage', orgId, creatorId, kind: 'direct' })
|
||||
|
||||
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Storage not found')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('refunds traffic and download count when direct share signing fails [spec: redirect/ds-refund-on-failure]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
@@ -281,6 +324,55 @@ describe('GET /r/:token (ih_ image hosting)', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when an image hosting record references a missing storage', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
// No storage row inserted for this storage id.
|
||||
const orgId = await getOrgId(db)
|
||||
await insertImageHosting(db, orgId, {
|
||||
id: 'ih-no-storage',
|
||||
token: 'ih_nostorage',
|
||||
storageId: 'st-missing-storage',
|
||||
})
|
||||
|
||||
const res = await app.request('/r/ih_nostorage', { redirect: 'manual' })
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Storage not found')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
expect(S3Service.prototype.presignInline).not.toHaveBeenCalled()
|
||||
expect(await getAccessCount(db, 'ih-no-storage')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 402 insufficient credits when cloud egress reporting blocks the image redirect', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertImageHosting(db, orgId, { id: 'ih-credits', token: 'ih_credits' })
|
||||
|
||||
const redirectUsecase = await import('../usecases/redirect.js')
|
||||
vi.spyOn(redirectUsecase, 'resolveImageHostingDownload').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'insufficient_credits',
|
||||
})
|
||||
|
||||
const res = await app.request('/r/ih_credits', { redirect: 'manual' })
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as {
|
||||
error: {
|
||||
code: number
|
||||
message: string
|
||||
status: string
|
||||
details: Array<{ reason: string; metadata?: { resource?: string } }>
|
||||
}
|
||||
}
|
||||
expect(body.error.code).toBe(402)
|
||||
expect(body.error.message).toBe('Insufficient credits')
|
||||
expect(body.error.details[0].reason).toBe('INSUFFICIENT_CREDITS')
|
||||
expect(body.error.details[0].metadata?.resource).toBe('storage_egress')
|
||||
})
|
||||
|
||||
it('increments accessCount by 1 on successful redirect [spec: redirect/image-access-count]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
@@ -359,7 +451,9 @@ describe('GET /r/:token (ih_ image hosting)', () => {
|
||||
|
||||
const second = await app.request('/r/ih_quotarepeat', { redirect: 'manual' })
|
||||
expect(second.status).toBe(422)
|
||||
await expect(second.json()).resolves.toEqual({ error: 'Traffic quota exceeded' })
|
||||
const secondBody = (await second.json()) as { error: { message: string; details: Array<{ reason: string }> } }
|
||||
expect(secondBody.error.message).toBe('Traffic quota exceeded')
|
||||
expect(secondBody.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
expect(S3Service.prototype.presignInline).toHaveBeenCalledTimes(1)
|
||||
expect(await getAccessCount(db, 'ih-quota-repeat')).toBe(1)
|
||||
})
|
||||
@@ -532,7 +626,9 @@ describe('GET /r/:token — two-org isolation', () => {
|
||||
|
||||
const res = await app.request('/r/ih_quotatest', { redirect: 'manual' })
|
||||
expect(res.status).toBe(422)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Traffic quota exceeded' })
|
||||
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
|
||||
expect(body.error.message).toBe('Traffic quota exceeded')
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
expect(S3Service.prototype.presignInline).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+23
-12
@@ -1,6 +1,7 @@
|
||||
import type { Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { ErrorReason } from '../../shared/schemas'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
type DirectShareOutcome,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
resolveDirectShareDownload,
|
||||
resolveImageHostingDownload,
|
||||
} from '../usecases/redirect'
|
||||
import { apiError } from './openapi'
|
||||
|
||||
// Strip optional file extension from token (e.g. "ih_aB3xK9.png" → "ih_aB3xK9")
|
||||
function stripExtension(token: string): string {
|
||||
@@ -24,7 +26,10 @@ function presignedRedirect(c: Context<Env>, url: string): Response {
|
||||
}
|
||||
|
||||
function insufficientCredits(c: Context<Env>): Response {
|
||||
return c.json({ error: 'insufficient_credits', code: 'insufficient_credits', resource: 'storage_egress' }, 402)
|
||||
return apiError(c, 402, 'Insufficient credits', {
|
||||
reason: ErrorReason.INSUFFICIENT_CREDITS,
|
||||
metadata: { resource: 'storage_egress' },
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDirectShare(c: Context<Env>, token: string): Promise<Response> {
|
||||
@@ -35,17 +40,20 @@ async function handleDirectShare(c: Context<Env>, token: string): Promise<Respon
|
||||
if (outcome.ok) return presignedRedirect(c, outcome.url)
|
||||
switch (outcome.reason) {
|
||||
case 'matter_trashed':
|
||||
return c.json({ error: 'File no longer available' }, 410)
|
||||
return apiError(c, 410, 'File no longer available')
|
||||
case 'not_found':
|
||||
return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
return apiError(c, 404, 'Share not found or revoked')
|
||||
case 'expired':
|
||||
return c.json({ error: 'Share has expired' }, 410)
|
||||
return apiError(c, 410, 'Share has expired')
|
||||
case 'limit_exceeded':
|
||||
return c.json({ error: 'Download limit exceeded' }, 410)
|
||||
return apiError(c, 410, 'Download limit exceeded')
|
||||
case 'storage_not_found':
|
||||
return c.json({ error: 'Storage not found' }, 404)
|
||||
return apiError(c, 404, 'Storage not found')
|
||||
case 'quota_exceeded':
|
||||
return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
return apiError(c, 422, 'Traffic quota exceeded', {
|
||||
reason: ErrorReason.QUOTA_EXCEEDED,
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
})
|
||||
case 'insufficient_credits':
|
||||
return insufficientCredits(c)
|
||||
}
|
||||
@@ -61,13 +69,16 @@ async function handleImageHosting(c: Context<Env>, token: string): Promise<Respo
|
||||
if (outcome.ok) return presignedRedirect(c, outcome.url)
|
||||
switch (outcome.reason) {
|
||||
case 'not_found':
|
||||
return c.json({ error: 'Not found' }, 404)
|
||||
return apiError(c, 404, 'Not found')
|
||||
case 'forbidden_referer':
|
||||
return c.json({ error: 'forbidden referer' }, 403)
|
||||
return apiError(c, 403, 'forbidden referer')
|
||||
case 'storage_not_found':
|
||||
return c.json({ error: 'Storage not found' }, 404)
|
||||
return apiError(c, 404, 'Storage not found')
|
||||
case 'quota_exceeded':
|
||||
return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
return apiError(c, 422, 'Traffic quota exceeded', {
|
||||
reason: ErrorReason.QUOTA_EXCEEDED,
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
})
|
||||
case 'insufficient_credits':
|
||||
return insufficientCredits(c)
|
||||
}
|
||||
@@ -80,7 +91,7 @@ const app = new Hono<Env>().get('/:token', async (c) => {
|
||||
if (token.startsWith('ds_')) return handleDirectShare(c, token)
|
||||
if (token.startsWith('ih_')) return handleImageHosting(c, token)
|
||||
|
||||
return c.json({ error: 'Not found' }, 404)
|
||||
return apiError(c, 404, 'Not found')
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -193,8 +193,8 @@ describe('POST /api/shares', () => {
|
||||
const res = await createShare(app, headers, { matterId: 'fo1', kind: 'direct' })
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('DIRECT_NO_FOLDER')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('DIRECT_NO_FOLDER')
|
||||
})
|
||||
|
||||
it('returns 400 with DIRECT_NO_PASSWORD when creating direct share with password [spec: shares/direct-no-password]', async () => {
|
||||
@@ -207,8 +207,8 @@ describe('POST /api/shares', () => {
|
||||
const res = await createShare(app, headers, { matterId: 'f5', kind: 'direct', password: 'secret' })
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('DIRECT_NO_PASSWORD')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('DIRECT_NO_PASSWORD')
|
||||
})
|
||||
|
||||
it('returns 404 when matterId does not belong to current org [spec: shares/create-cross-org]', async () => {
|
||||
@@ -218,8 +218,8 @@ describe('POST /api/shares', () => {
|
||||
const res = await createShare(app, headers, { matterId: 'nonexistent-matter', kind: 'landing' })
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('MATTER_NOT_FOUND')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('MATTER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('sets expiresAt when provided in request [spec: shares/create-expiry]', async () => {
|
||||
@@ -272,8 +272,8 @@ describe('POST /api/shares', () => {
|
||||
recipients: [{ recipientEmail: 'someone@example.com' }],
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('DIRECT_NO_RECIPIENTS')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('DIRECT_NO_RECIPIENTS')
|
||||
})
|
||||
|
||||
it('returns 500 when createShare throws an unexpected error', async () => {
|
||||
@@ -588,8 +588,8 @@ describe('POST /api/shares/:token/objects', () => {
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('DIRECT_SAVE_FORBIDDEN')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('DIRECT_SAVE_FORBIDDEN')
|
||||
})
|
||||
|
||||
it('returns 410 when the shared matter has been trashed [spec: shares/save-trashed-gone]', async () => {
|
||||
@@ -661,8 +661,8 @@ describe('POST /api/shares/:token/objects', () => {
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.code).toBe('QUOTA_EXCEEDED')
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
|
||||
it('returns 403 when targetOrgId is not a personal org and user has no member role [spec: shares/save-target-permission]', async () => {
|
||||
@@ -1295,6 +1295,41 @@ describe('Public share routes', () => {
|
||||
body: JSON.stringify({ password: 'wrongpassword' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Invalid password')
|
||||
expect(body.error.status).toBe('PERMISSION_DENIED')
|
||||
})
|
||||
|
||||
it('returns 404 when verifying a password for an unknown token', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/shares/no-such-token/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'whatever' }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Share not found or revoked')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 404 when verifying a password for a direct (non-landing) share', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFile(db, orgId, { id: 'vf3', name: 'direct-verify.bin' })
|
||||
const share = await createShareRepo(db).create({ matterId: 'vf3', orgId, creatorId, kind: 'direct' })
|
||||
|
||||
const res = await app.request(`/api/shares/${share.token}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'whatever' }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Share not found or revoked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1433,7 +1468,9 @@ describe('Public share routes', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(422)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Traffic quota exceeded' })
|
||||
const quotaBody = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
|
||||
expect(quotaBody.error.message).toBe('Traffic quota exceeded')
|
||||
expect(quotaBody.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
|
||||
const shareRows = await db.all<{ downloads: number }>(sql`SELECT downloads FROM shares WHERE id = ${share.id}`)
|
||||
@@ -1550,6 +1587,68 @@ describe('Public share routes', () => {
|
||||
const rootRef = await fetchRootRef(app, share.token)
|
||||
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
|
||||
expect(res.status).toBe(410)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Share has expired')
|
||||
})
|
||||
|
||||
it('returns 410 with AIP-193 body when the shared matter is trashed', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFile(db, orgId, { id: 'dl-trash', name: 'gone.txt' })
|
||||
// The matter is reachable (status active) when the share is created, then
|
||||
// gets trashed — resolveByToken returns matter_trashed for the download.
|
||||
const share = await createShareRepo(db).create({ matterId: 'dl-trash', orgId, creatorId, kind: 'landing' })
|
||||
const rootRef = await fetchRootRef(app, share.token)
|
||||
await db.run(sql`UPDATE matters SET status = 'trashed' WHERE id = 'dl-trash'`)
|
||||
|
||||
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
|
||||
expect(res.status).toBe(410)
|
||||
const body = (await res.json()) as { error: { code: number; message: string; status: string } }
|
||||
expect(body.error.code).toBe(410)
|
||||
expect(body.error.message).toBe('File no longer available')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 400 when downloading a folder share root ref directly', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFolder(db, orgId, { id: 'dl-folder', name: 'A Folder' })
|
||||
const share = await createShareRepo(db).create({ matterId: 'dl-folder', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
const rootRef = await fetchRootRef(app, share.token)
|
||||
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Cannot download a folder directly')
|
||||
expect(body.error.status).toBe('INVALID_ARGUMENT')
|
||||
})
|
||||
|
||||
it('returns 404 when the shared file references a missing storage', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
// No storage row inserted — the matter points at a storage_id that does
|
||||
// not exist, so storage lookup fails after the access gates pass.
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
||||
VALUES ('dl-no-storage', ${orgId}, 'dl-no-storage-alias', 'orphan.txt', 'text/plain', 1024, 0, '', 'some/key.txt', 'st-missing', 'active', ${now}, ${now})
|
||||
`)
|
||||
const share = await createShareRepo(db).create({ matterId: 'dl-no-storage', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
const rootRef = await fetchRootRef(app, share.token)
|
||||
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Storage not found')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1589,6 +1688,31 @@ describe('Public share routes', () => {
|
||||
|
||||
const res = await app.request(`/api/shares/${share.token}/objects`)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Not a folder share')
|
||||
})
|
||||
|
||||
it('returns 410 with AIP-193 body when listing objects of an expired folder share', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const creatorId = await getUserId(db)
|
||||
await insertFolder(db, orgId, { id: 'ch-expired', name: 'Expired Folder' })
|
||||
const share = await createShareRepo(db).create({
|
||||
matterId: 'ch-expired',
|
||||
orgId,
|
||||
creatorId,
|
||||
kind: 'landing',
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
})
|
||||
|
||||
const res = await app.request(`/api/shares/${share.token}/objects`)
|
||||
expect(res.status).toBe(410)
|
||||
const body = (await res.json()) as { error: { code: number; message: string; status: string } }
|
||||
expect(body.error.code).toBe(410)
|
||||
expect(body.error.message).toBe('Share has expired')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns items and breadcrumb for folder share', async () => {
|
||||
@@ -1674,8 +1798,8 @@ describe('Public share routes', () => {
|
||||
|
||||
const res = await app.request(`/api/shares/${share.token}/objects?parent=../etc`)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe('Invalid path')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Invalid path')
|
||||
})
|
||||
|
||||
it('respects explicit page and pageSize query params', async () => {
|
||||
|
||||
+43
-47
@@ -2,6 +2,7 @@ import { createRoute, 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'
|
||||
import { ErrorReason, pageSchema } from '../../shared/schemas'
|
||||
import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema } from '../../shared/schemas/share'
|
||||
import { requireAuth, requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
verifySharePassword,
|
||||
viewShare,
|
||||
} from '../usecases/share'
|
||||
import { errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
import { cookieName, decodeChildRef, readUserId, viewCookieName } from './share-utils'
|
||||
|
||||
function shareUrls(kind: string, token: string): { landing?: string; direct?: string } {
|
||||
@@ -115,14 +116,7 @@ function toShareListItemDTO(s: ShareListItem): z.infer<typeof shareListItemSchem
|
||||
return { ...s, expiresAt: s.expiresAt ? s.expiresAt.toISOString() : null, createdAt: s.createdAt.toISOString() }
|
||||
}
|
||||
|
||||
const shareListSchema = z
|
||||
.object({
|
||||
items: z.array(shareListItemSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi('ShareList')
|
||||
const shareListSchema = pageSchema(shareListItemSchema, 'ShareList')
|
||||
|
||||
const shareObjectsSchema = z
|
||||
.object({
|
||||
@@ -257,25 +251,31 @@ pub.get('/:token/objects/:ref', async (c) => {
|
||||
}
|
||||
switch (out.reason) {
|
||||
case 'matter_trashed':
|
||||
return c.json({ error: 'File no longer available' }, 410)
|
||||
return apiError(c, 410, 'File no longer available')
|
||||
case 'not_found':
|
||||
return c.json({ error: 'File not found or not accessible' }, 404)
|
||||
return apiError(c, 404, 'File not found or not accessible')
|
||||
case 'invalid_ref':
|
||||
return c.json({ error: 'Invalid reference' }, 400)
|
||||
return apiError(c, 400, 'Invalid reference')
|
||||
case 'password_required':
|
||||
return c.json({ error: 'Password required' }, 401)
|
||||
return apiError(c, 401, 'Password required')
|
||||
case 'expired':
|
||||
return c.json({ error: 'Share has expired' }, 410)
|
||||
return apiError(c, 410, 'Share has expired')
|
||||
case 'folder':
|
||||
return c.json({ error: 'Cannot download a folder directly' }, 400)
|
||||
return apiError(c, 400, 'Cannot download a folder directly')
|
||||
case 'limit_exceeded':
|
||||
return c.json({ error: 'Download limit exceeded' }, 410)
|
||||
return apiError(c, 410, 'Download limit exceeded')
|
||||
case 'storage_not_found':
|
||||
return c.json({ error: 'Storage not found' }, 404)
|
||||
return apiError(c, 404, 'Storage not found')
|
||||
case 'quota_exceeded':
|
||||
return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
return apiError(c, 422, 'Traffic quota exceeded', {
|
||||
reason: ErrorReason.QUOTA_EXCEEDED,
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
})
|
||||
case 'insufficient_credits':
|
||||
return c.json({ error: 'insufficient_credits', code: 'insufficient_credits', resource: 'storage_egress' }, 402)
|
||||
return apiError(c, 402, 'Insufficient credits', {
|
||||
reason: ErrorReason.INSUFFICIENT_CREDITS,
|
||||
metadata: { resource: 'storage_egress' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -300,8 +300,8 @@ export const publicShares = pub
|
||||
}
|
||||
return c.json(toShareViewDTO(out.dto), 200)
|
||||
}
|
||||
if (out.reason === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410)
|
||||
return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
if (out.reason === 'matter_trashed') return apiError(c, 410, 'File no longer available')
|
||||
return apiError(c, 404, 'Share not found or revoked')
|
||||
})
|
||||
.openapi(verifyShareRoute, async (c) => {
|
||||
const token = c.req.valid('param').token
|
||||
@@ -316,8 +316,8 @@ export const publicShares = pub
|
||||
})
|
||||
return c.json({ ok: true as const }, 200)
|
||||
}
|
||||
if (out.reason === 'invalid_password') return c.json({ error: 'Invalid password' }, 403)
|
||||
return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
if (out.reason === 'invalid_password') return apiError(c, 403, 'Invalid password')
|
||||
return apiError(c, 404, 'Share not found or revoked')
|
||||
})
|
||||
.openapi(listShareObjectsRoute, async (c) => {
|
||||
const token = c.req.valid('param').token
|
||||
@@ -339,17 +339,17 @@ export const publicShares = pub
|
||||
if (out.ok) return c.json(out.result, 200)
|
||||
switch (out.reason) {
|
||||
case 'matter_trashed':
|
||||
return c.json({ error: 'File no longer available' }, 410)
|
||||
return apiError(c, 410, 'File no longer available')
|
||||
case 'not_found':
|
||||
return c.json({ error: 'Share not found or revoked' }, 404)
|
||||
return apiError(c, 404, 'Share not found or revoked')
|
||||
case 'not_a_folder':
|
||||
return c.json({ error: 'Not a folder share' }, 400)
|
||||
return apiError(c, 400, 'Not a folder share')
|
||||
case 'password_required':
|
||||
return c.json({ error: 'Password required' }, 401)
|
||||
return apiError(c, 401, 'Password required')
|
||||
case 'expired':
|
||||
return c.json({ error: 'Share has expired' }, 410)
|
||||
return apiError(c, 410, 'Share has expired')
|
||||
case 'invalid_path':
|
||||
return c.json({ error: 'Invalid path' }, 400)
|
||||
return apiError(c, 400, 'Invalid path')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -440,13 +440,13 @@ export const authedShares = authedApp
|
||||
}
|
||||
switch (out.reason) {
|
||||
case 'MATTER_NOT_FOUND':
|
||||
return c.json({ error: 'Matter not found', code: 'MATTER_NOT_FOUND' }, 404)
|
||||
return apiError(c, 404, 'Matter not found', { reason: 'MATTER_NOT_FOUND' })
|
||||
case 'DIRECT_NO_FOLDER':
|
||||
return c.json({ error: 'Direct shares cannot be folders', code: 'DIRECT_NO_FOLDER' }, 400)
|
||||
return apiError(c, 400, 'Direct shares cannot be folders', { reason: 'DIRECT_NO_FOLDER' })
|
||||
case 'DIRECT_NO_PASSWORD':
|
||||
return c.json({ error: 'Direct shares cannot have a password', code: 'DIRECT_NO_PASSWORD' }, 400)
|
||||
return apiError(c, 400, 'Direct shares cannot have a password', { reason: 'DIRECT_NO_PASSWORD' })
|
||||
case 'DIRECT_NO_RECIPIENTS':
|
||||
return c.json({ error: 'Direct shares cannot have recipients', code: 'DIRECT_NO_RECIPIENTS' }, 400)
|
||||
return apiError(c, 400, 'Direct shares cannot have recipients', { reason: 'DIRECT_NO_RECIPIENTS' })
|
||||
}
|
||||
})
|
||||
.openapi(revokeShareRoute, async (c) => {
|
||||
@@ -456,8 +456,8 @@ export const authedShares = authedApp
|
||||
orgId: c.get('orgId')!,
|
||||
})
|
||||
if (out.ok) return c.body(null, 204)
|
||||
if (out.reason === 'forbidden') return c.json({ error: 'Forbidden' }, 403)
|
||||
return c.json({ error: 'Not found' }, 404)
|
||||
if (out.reason === 'forbidden') return apiError(c, 403, 'Forbidden')
|
||||
return apiError(c, 404, 'Not found')
|
||||
})
|
||||
.openapi(saveShareRoute, async (c) => {
|
||||
const token = c.req.valid('param').token
|
||||
@@ -472,22 +472,18 @@ export const authedShares = authedApp
|
||||
if (out.ok) return c.json({ saved: out.result.saved.map(toSavedMatterDTO), skipped: out.result.skipped }, 201)
|
||||
switch (out.reason) {
|
||||
case 'matter_trashed':
|
||||
return c.json({ error: 'Share target has been deleted' }, 410)
|
||||
return apiError(c, 410, 'Share target has been deleted')
|
||||
case 'not_found':
|
||||
return c.json({ error: 'Share not found' }, 404)
|
||||
return apiError(c, 404, 'Share not found')
|
||||
case 'direct_forbidden':
|
||||
return c.json(
|
||||
{
|
||||
error: 'Direct link shares cannot be saved. Ask the sender for a landing share.',
|
||||
code: 'DIRECT_SAVE_FORBIDDEN',
|
||||
},
|
||||
400,
|
||||
)
|
||||
return apiError(c, 400, 'Direct link shares cannot be saved. Ask the sender for a landing share.', {
|
||||
reason: 'DIRECT_SAVE_FORBIDDEN',
|
||||
})
|
||||
case 'password_required':
|
||||
return c.json({ error: 'Authentication required for password-protected share' }, 401)
|
||||
return apiError(c, 401, 'Authentication required for password-protected share')
|
||||
case 'forbidden':
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
case 'quota_exceeded':
|
||||
return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 400)
|
||||
return apiError(c, 400, 'Quota exceeded', { reason: ErrorReason.QUOTA_EXCEEDED, status: 'RESOURCE_EXHAUSTED' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,8 +44,9 @@ describe('Admin Announcements API', () => {
|
||||
|
||||
const res = await app.request('/api/site/announcements?scope=all', { headers })
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as { feature: string }
|
||||
expect(body.feature).toBe('site_announcements')
|
||||
const body = (await res.json()) as { error: { details: { reason: string; metadata?: { feature?: string } }[] } }
|
||||
expect(body.error.details[0]?.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0]?.metadata?.feature).toBe('site_announcements')
|
||||
})
|
||||
|
||||
it('creates, lists, updates, and deletes an announcement [spec: announcements/crud]', async () => {
|
||||
@@ -97,8 +98,9 @@ describe('User Announcements API', () => {
|
||||
|
||||
const res = await app.request('/api/site/announcements', { headers })
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as { feature: string }
|
||||
expect(body.feature).toBe('site_announcements')
|
||||
const body = (await res.json()) as { error: { details: { reason: string; metadata?: { feature?: string } }[] } }
|
||||
expect(body.error.details[0]?.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0]?.metadata?.feature).toBe('site_announcements')
|
||||
})
|
||||
|
||||
it('returns active announcements [spec: announcements/user-active]', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { announcementInputSchema, listAnnouncementsQuerySchema } from '@shared/schemas'
|
||||
import { announcementInputSchema, announcementStatusSchema, pageQuerySchema, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin, requireAuth } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { requireFeature } from '../../middleware/require-feature'
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
listUserAnnouncements,
|
||||
updateAnnouncement,
|
||||
} from '../../usecases/site/announcement'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
const announcementSchema = z
|
||||
.object({
|
||||
@@ -41,21 +41,14 @@ function toAnnouncementDTO(a: AnnouncementRecord): AnnouncementDTO {
|
||||
}
|
||||
}
|
||||
|
||||
const announcementListSchema = z
|
||||
.object({
|
||||
items: z.array(announcementSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi('AnnouncementList')
|
||||
const announcementListSchema = pageSchema(announcementSchema, 'AnnouncementList')
|
||||
|
||||
function pagination(query: { page?: string; pageSize?: string }) {
|
||||
return {
|
||||
page: Math.max(1, Number(query.page ?? '1')),
|
||||
pageSize: Math.min(100, Math.max(1, Number(query.pageSize ?? '20'))),
|
||||
}
|
||||
}
|
||||
// `active` = the caller's live feed (any authed user); `all` = full management
|
||||
// list (admin only). Absent = live feed.
|
||||
const listAnnouncementsQuerySchema = pageQuerySchema.extend({
|
||||
scope: z.enum(['active', 'all']).optional(),
|
||||
status: announcementStatusSchema.optional(),
|
||||
})
|
||||
|
||||
const listRoute = createRoute({
|
||||
operationId: 'listAnnouncements',
|
||||
@@ -133,15 +126,17 @@ app.use(requireFeature('site_announcements'))
|
||||
export const announcements = app
|
||||
.openapi(listRoute, async (c) => {
|
||||
const query = c.req.valid('query')
|
||||
const { page, pageSize } = query
|
||||
const wantsManagement = query.scope === 'all' || query.status !== undefined
|
||||
if (wantsManagement) {
|
||||
if (c.get('userRole') !== 'admin') return c.json({ error: 'Forbidden' }, 403)
|
||||
const result = await listAdminAnnouncements(c.get('deps'), { status: query.status, ...pagination(query) })
|
||||
if (c.get('userRole') !== 'admin') return apiError(c, 403, 'Forbidden')
|
||||
const result = await listAdminAnnouncements(c.get('deps'), { status: query.status, page, pageSize })
|
||||
return c.json({ ...result, items: result.items.map(toAnnouncementDTO) }, 200)
|
||||
}
|
||||
const result = await listUserAnnouncements(c.get('deps'), {
|
||||
activeOnly: query.scope === 'active',
|
||||
...pagination(query),
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
return c.json({ ...result, items: result.items.map(toAnnouncementDTO) }, 200)
|
||||
})
|
||||
@@ -150,17 +145,17 @@ export const announcements = app
|
||||
)
|
||||
.openapi(getAnnouncementRoute, async (c) => {
|
||||
const announcement = await getAnnouncement(c.get('deps'), c.req.valid('param').id)
|
||||
if (!announcement) return c.json({ error: 'Announcement not found' }, 404)
|
||||
if (!announcement) return apiError(c, 404, 'Announcement not found')
|
||||
return c.json(toAnnouncementDTO(announcement), 200)
|
||||
})
|
||||
.openapi(updateAnnouncementRoute, async (c) => {
|
||||
const announcement = await updateAnnouncement(c.get('deps'), c.req.valid('param').id, c.req.valid('json'))
|
||||
if (!announcement) return c.json({ error: 'Announcement not found' }, 404)
|
||||
if (!announcement) return apiError(c, 404, 'Announcement not found')
|
||||
return c.json(toAnnouncementDTO(announcement), 200)
|
||||
})
|
||||
.openapi(deleteAnnouncementRoute, async (c) => {
|
||||
const id = c.req.valid('param').id
|
||||
const deleted = await deleteAnnouncement(c.get('deps'), id)
|
||||
if (!deleted) return c.json({ error: 'Announcement not found' }, 404)
|
||||
if (!deleted) return apiError(c, 404, 'Announcement not found')
|
||||
return c.json({ id, deleted: true as const }, 200)
|
||||
})
|
||||
|
||||
@@ -22,8 +22,9 @@ describe('GET /api/site/audit-events — auth guards', () => {
|
||||
// No Pro license seeded — feature gate should block
|
||||
const res = await app.request('/api/site/audit-events', { headers })
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.feature).toBe('audit_log')
|
||||
const body = (await res.json()) as { error: { details: { reason: string; metadata?: { feature?: string } }[] } }
|
||||
expect(body.error.details[0]?.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0]?.metadata?.feature).toBe('audit_log')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+15
-17
@@ -1,5 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { listAdminAuditQuerySchema } from '@shared/schemas'
|
||||
import { pageQuerySchema, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { requireFeature } from '../../middleware/require-feature'
|
||||
@@ -29,14 +29,14 @@ function toAuditEventDTO(e: AdminAuditEventWithOrg): AuditEventDTO {
|
||||
return { ...e, createdAt: e.createdAt.toISOString() }
|
||||
}
|
||||
|
||||
const auditPageSchema = z
|
||||
.object({
|
||||
items: z.array(auditEventSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi('AuditEventPage')
|
||||
const auditPageSchema = pageSchema(auditEventSchema, 'AuditEventPage')
|
||||
|
||||
const listAuditQuerySchema = pageQuerySchema.extend({
|
||||
orgId: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
targetType: z.string().optional(),
|
||||
})
|
||||
|
||||
const listRoute = createRoute({
|
||||
operationId: 'listAuditEvents',
|
||||
@@ -45,21 +45,19 @@ const listRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
middleware: [requireAdmin, requireFeature('audit_log')] as const,
|
||||
request: { query: listAdminAuditQuerySchema },
|
||||
request: { query: listAuditQuerySchema },
|
||||
responses: { 200: jsonContent(auditPageSchema, 'Audit events') },
|
||||
})
|
||||
|
||||
export const adminAudit = new OpenAPIHono<Env>().openapi(listRoute, async (c) => {
|
||||
const query = c.req.valid('query')
|
||||
const page = Math.max(1, Number(query.page ?? '1'))
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? '20')))
|
||||
const { page, pageSize, orgId, userId, action, targetType } = c.req.valid('query')
|
||||
const result = await listAuditEvents(c.get('deps'), {
|
||||
page,
|
||||
pageSize,
|
||||
orgId: query.orgId,
|
||||
userId: query.userId,
|
||||
action: query.action,
|
||||
targetType: query.targetType,
|
||||
orgId,
|
||||
userId,
|
||||
action,
|
||||
targetType,
|
||||
})
|
||||
return c.json({ ...result, items: result.items.map(toAuditEventDTO) }, 200)
|
||||
})
|
||||
|
||||
@@ -216,9 +216,13 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
|
||||
const second = await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id' })
|
||||
expect(second.status).toBe(402)
|
||||
const body = (await second.json()) as Record<string, unknown>
|
||||
expect(body.feature).toBe('social_login_unlimited')
|
||||
expect(body.limit).toBe(1)
|
||||
const body = (await second.json()) as {
|
||||
error: { message: string; details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.message).toBe('Feature not available')
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('social_login_unlimited')
|
||||
expect(body.error.details[0].metadata.limit).toBe('1')
|
||||
})
|
||||
|
||||
it('allows additional providers with the social_login_unlimited entitlement [spec: auth-providers/unlimited-entitlement]', async () => {
|
||||
@@ -285,8 +289,8 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
|
||||
const res = await putProvider(app, admin, 'not-a-real-provider', githubConfig)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toMatch(/Unknown builtin provider/)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toMatch(/Unknown builtin provider/)
|
||||
})
|
||||
|
||||
it('returns 400 for OIDC provider missing discoveryUrl [spec: auth-providers/oidc-missing-discovery]', async () => {
|
||||
@@ -296,8 +300,8 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
const { discoveryUrl: _, ...oidcWithoutDiscovery } = oidcConfig
|
||||
const res = await putProvider(app, admin, 'my-oidc', oidcWithoutDiscovery)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toMatch(/discoveryUrl is required/)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toMatch(/discoveryUrl is required/)
|
||||
})
|
||||
|
||||
it('returns 400 when clientId is missing', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { featureGateErrorSchema } from '@shared/schemas'
|
||||
import { ErrorReason, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type SocialLoginFeatureBlock,
|
||||
upsertAuthProvider,
|
||||
} from '../../usecases/site/auth-provider'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
const maskedProviderConfigSchema = z
|
||||
.object({
|
||||
@@ -34,16 +34,18 @@ const publicProviderSchema = z
|
||||
|
||||
// GET / returns the admin config list (with masked secrets) to admins, or the
|
||||
// public display list to anonymous/login callers — hence the union.
|
||||
const authProviderListSchema = z
|
||||
.object({ items: z.array(z.union([maskedProviderConfigSchema, publicProviderSchema])) })
|
||||
.openapi('AuthProviderList')
|
||||
const authProviderListSchema = pageSchema(
|
||||
z.union([maskedProviderConfigSchema, publicProviderSchema]),
|
||||
'AuthProviderList',
|
||||
)
|
||||
|
||||
const invalidProviderId = { error: 'Provider ID must contain only lowercase letters, numbers, and hyphens' }
|
||||
const invalidProviderIdMessage = 'Provider ID must contain only lowercase letters, numbers, and hyphens'
|
||||
|
||||
const featureNotAvailable = (block: SocialLoginFeatureBlock) => ({
|
||||
error: 'feature_not_available',
|
||||
...block,
|
||||
upgrade_url: '/settings/billing',
|
||||
const featureBlockMetadata = (block: SocialLoginFeatureBlock): Record<string, string> => ({
|
||||
feature: block.feature,
|
||||
currentCount: String(block.currentCount),
|
||||
limit: String(block.limit),
|
||||
upgradeUrl: '/settings/billing',
|
||||
})
|
||||
|
||||
const upsertSchema = z.object({
|
||||
@@ -75,7 +77,7 @@ const upsertRoute = createRoute({
|
||||
responses: {
|
||||
200: jsonContent(maskedProviderConfigSchema, 'Upserted auth provider'),
|
||||
400: errorResponse('Invalid provider'),
|
||||
402: jsonContent(featureGateErrorSchema, 'Feature not available'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -96,24 +98,28 @@ const deleteProviderRoute = createRoute({
|
||||
// One auth-providers resource. GET / serves the enabled list without secrets to
|
||||
// anonymous/login callers, and the full config to admins; writes are admin-only.
|
||||
export const authProviders = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, async (c) =>
|
||||
c.get('userRole') === 'admin'
|
||||
? c.json(await listAuthProviders(c.get('deps')), 200)
|
||||
: c.json(await listPublicAuthProviders(c.get('deps')), 200),
|
||||
)
|
||||
.openapi(listRoute, async (c) => {
|
||||
const { items } =
|
||||
c.get('userRole') === 'admin'
|
||||
? await listAuthProviders(c.get('deps'))
|
||||
: await listPublicAuthProviders(c.get('deps'))
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(upsertRoute, async (c) => {
|
||||
const result = await upsertAuthProvider(c.get('deps'), c.req.valid('param').providerId, c.req.valid('json'))
|
||||
if (result.ok) return c.json(result.config, 200)
|
||||
if (result.reason === 'invalid_id') return c.json(invalidProviderId, 400)
|
||||
if (result.reason === 'invalid_id') return apiError(c, 400, invalidProviderIdMessage)
|
||||
if (result.reason === 'unknown_builtin')
|
||||
return c.json({ error: `Unknown builtin provider: ${c.req.valid('param').providerId}` }, 400)
|
||||
if (result.reason === 'missing_discovery')
|
||||
return c.json({ error: 'discoveryUrl is required for OIDC providers' }, 400)
|
||||
return c.json(featureNotAvailable(result.block), 402)
|
||||
return apiError(c, 400, `Unknown builtin provider: ${c.req.valid('param').providerId}`)
|
||||
if (result.reason === 'missing_discovery') return apiError(c, 400, 'discoveryUrl is required for OIDC providers')
|
||||
return apiError(c, 402, 'Feature not available', {
|
||||
reason: ErrorReason.FEATURE_NOT_AVAILABLE,
|
||||
metadata: featureBlockMetadata(result.block),
|
||||
})
|
||||
})
|
||||
.openapi(deleteProviderRoute, async (c) => {
|
||||
const providerId = c.req.valid('param').providerId
|
||||
const result = await deleteAuthProvider(c.get('deps'), providerId)
|
||||
if (!result.ok) return c.json(invalidProviderId, 400)
|
||||
if (!result.ok) return apiError(c, 400, invalidProviderIdMessage)
|
||||
return c.json({ providerId, deleted: true as const }, 200)
|
||||
})
|
||||
|
||||
@@ -138,8 +138,9 @@ describe('PUT /api/site/branding', () => {
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/site/branding', { method: 'PUT', headers })
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as { feature: string }
|
||||
expect(body.feature).toBe('white_label')
|
||||
const body = (await res.json()) as { error: { details: { reason: string; metadata?: { feature?: string } }[] } }
|
||||
expect(body.error.details[0]?.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0]?.metadata?.feature).toBe('white_label')
|
||||
})
|
||||
|
||||
it('returns 415 when body is not multipart [spec: branding/multipart-required]', async () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { ErrorReason } from '@shared/schemas'
|
||||
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 { applyBrandingUpdate, readBranding, resetBranding, type ThemeUpdate } from '../../usecases/site/branding'
|
||||
import { errorResponse, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonContent } from '../openapi'
|
||||
|
||||
const brandingThemeValuesSchema = z.object({
|
||||
primary_color: z.string(),
|
||||
@@ -140,16 +141,16 @@ export const publicBranding = new OpenAPIHono<Env>().openapi(readRoute, async (c
|
||||
export const brandingAdmin = new OpenAPIHono<Env>()
|
||||
.openapi(updateRoute, async (c) => {
|
||||
if (!c.req.header('content-type')?.includes('multipart/form-data')) {
|
||||
return c.json({ error: 'Expected multipart/form-data' }, 415)
|
||||
return apiError(c, 415, 'Expected multipart/form-data', { reason: ErrorReason.UNSUPPORTED_MEDIA_TYPE })
|
||||
}
|
||||
const form = await c.req.formData()
|
||||
|
||||
const themeUpdate = parseThemeUpdate(form)
|
||||
if (!themeUpdate.ok) return c.json({ error: themeUpdate.error }, 422)
|
||||
if (!themeUpdate.ok) return apiError(c, 422, themeUpdate.error)
|
||||
|
||||
const wordmarkRaw = form.get('wordmark_text')
|
||||
if (typeof wordmarkRaw === 'string' && wordmarkRaw.length > 24) {
|
||||
return c.json({ error: 'wordmark_text must be 24 characters or fewer' }, 422)
|
||||
return apiError(c, 422, 'wordmark_text must be 24 characters or fewer')
|
||||
}
|
||||
|
||||
const hidePoweredByRaw = form.get('hide_powered_by')
|
||||
@@ -165,13 +166,17 @@ export const brandingAdmin = new OpenAPIHono<Env>()
|
||||
hidePoweredBy: hidePoweredByRaw !== null ? hidePoweredByRaw === 'true' || hidePoweredByRaw === '1' : null,
|
||||
theme: themeUpdate.values,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.error }, result.status)
|
||||
if (!result.ok) {
|
||||
if (result.status === 503) return apiError(c, 503, result.error, { reason: ErrorReason.NO_STORAGE_CONFIGURED })
|
||||
if (result.status === 413) return apiError(c, 413, result.error, { reason: ErrorReason.PAYLOAD_TOO_LARGE })
|
||||
return apiError(c, 400, result.error)
|
||||
}
|
||||
return c.json(result.config, 200)
|
||||
})
|
||||
.openapi(resetRoute, async (c) => {
|
||||
const rawField = c.req.valid('param').field
|
||||
if (!VALID_RESET_FIELDS.has(rawField as BrandingField)) {
|
||||
return c.json({ error: `Invalid field. Valid fields: ${[...VALID_RESET_FIELDS].join(', ')}` }, 400)
|
||||
return apiError(c, 400, `Invalid field. Valid fields: ${[...VALID_RESET_FIELDS].join(', ')}`)
|
||||
}
|
||||
await resetBranding(c.get('deps'), {
|
||||
userId: c.get('userId')!,
|
||||
|
||||
@@ -421,9 +421,8 @@ describe('Admin Email Config API — POST /test', () => {
|
||||
body: JSON.stringify({ to: 'recipient@example.com' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(typeof body.error).toBe('string')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(typeof body.error.message).toBe('string')
|
||||
})
|
||||
|
||||
it('returns 400 when no email config is set [spec: email-config/test-no-config]', async () => {
|
||||
@@ -436,9 +435,8 @@ describe('Admin Email Config API — POST /test', () => {
|
||||
body: JSON.stringify({ to: 'recipient@example.com' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(String(body.error)).toContain('Email is disabled')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('Email is disabled')
|
||||
})
|
||||
|
||||
it('returns 400 when email is disabled even if provider config exists', async () => {
|
||||
@@ -463,9 +461,8 @@ describe('Admin Email Config API — POST /test', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(String(body.error)).toContain('Email is disabled')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toContain('Email is disabled')
|
||||
})
|
||||
|
||||
it('returns 400 for invalid to email', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { getEmailConfig, saveEmailConfig, sendTestEmail } from '../../usecases/site/email-config'
|
||||
import { jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonContent } from '../openapi'
|
||||
|
||||
const smtpConfigSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -74,7 +74,7 @@ const testRoute = createRoute({
|
||||
request: { body: { content: { 'application/json': { schema: testEmailSchema } }, required: true } },
|
||||
responses: {
|
||||
200: jsonContent(successSchema, 'Sent'),
|
||||
400: jsonContent(z.object({ success: z.boolean(), error: z.string() }), 'Send failed'),
|
||||
400: errorResponse('Send failed'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -89,7 +89,7 @@ const emailConfig = app
|
||||
.openapi(testRoute, async (c) => {
|
||||
const result = await sendTestEmail(c.get('deps'), c.get('platform'), c.req.valid('json').to)
|
||||
if (result.ok) return c.json({ success: true }, 200)
|
||||
return c.json({ success: false, error: result.message }, 400)
|
||||
return apiError(c, 400, result.message)
|
||||
})
|
||||
|
||||
export default emailConfig
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as authSchema from '../../db/auth-schema.js'
|
||||
import { systemOptions } from '../../db/schema.js'
|
||||
import { siteInvitations, systemOptions } from '../../db/schema.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
|
||||
|
||||
function stubEmailProvider() {
|
||||
@@ -160,6 +160,143 @@ describe('Admin Site Invitations API', () => {
|
||||
})
|
||||
|
||||
expect(duplicateRes.status).toBe(409)
|
||||
const body = (await duplicateRes.json()) as {
|
||||
error: { code: number; message: string; status: string; details: Array<{ reason: string }> }
|
||||
}
|
||||
expect(body.error.code).toBe(409)
|
||||
expect(body.error.message).toContain('pending invitation already exists')
|
||||
expect(body.error.status).toBe('ABORTED')
|
||||
expect(body.error.details[0].reason).toBe('ABORTED')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── resend/revoke state-machine guards ──────────────────────────────────────
|
||||
|
||||
describe('Admin Site Invitations API — resend/revoke guards', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function seedEmailOptions(ctx: Awaited<ReturnType<typeof createTestApp>>) {
|
||||
await ctx.db.insert(systemOptions).values([
|
||||
{ key: 'email_enabled', value: 'true' },
|
||||
{ key: 'email_provider', value: 'http' },
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
{ key: 'email_http_url', value: 'https://mail.example.com/send' },
|
||||
{ key: 'email_http_api_key', value: 'test-api-key' },
|
||||
{ key: 'site_name', value: 'ZPan Test' },
|
||||
])
|
||||
}
|
||||
|
||||
async function createInvitation(ctx: Awaited<ReturnType<typeof createTestApp>>, email: string): Promise<string> {
|
||||
const headers = await adminHeaders(ctx.app)
|
||||
const res = await ctx.app.request('/api/site/invitations', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
return ((await res.json()) as { id: string }).id
|
||||
}
|
||||
|
||||
it('resend returns 404 for an unknown invitation id', async () => {
|
||||
const ctx = await createTestApp()
|
||||
stubEmailProvider()
|
||||
await seedEmailOptions(ctx)
|
||||
const headers = await adminHeaders(ctx.app)
|
||||
|
||||
const res = await ctx.app.request('/api/site/invitations/does-not-exist/deliveries', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Invitation not found')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('resend returns 400 when the invitation was already accepted', async () => {
|
||||
const ctx = await createTestApp()
|
||||
stubEmailProvider()
|
||||
await seedEmailOptions(ctx)
|
||||
const id = await createInvitation(ctx, 'accepted-resend@example.com')
|
||||
await ctx.db
|
||||
.update(siteInvitations)
|
||||
.set({ acceptedBy: 'someone', acceptedAt: new Date() })
|
||||
.where(eq(siteInvitations.id, id))
|
||||
const headers = await adminHeaders(ctx.app)
|
||||
|
||||
const res = await ctx.app.request(`/api/site/invitations/${id}/deliveries`, { method: 'POST', headers })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Invitation has already been used')
|
||||
})
|
||||
|
||||
it('resend returns 400 when the invitation was revoked', async () => {
|
||||
const ctx = await createTestApp()
|
||||
stubEmailProvider()
|
||||
await seedEmailOptions(ctx)
|
||||
const id = await createInvitation(ctx, 'revoked-resend@example.com')
|
||||
await ctx.db
|
||||
.update(siteInvitations)
|
||||
.set({ revokedBy: 'someone', revokedAt: new Date() })
|
||||
.where(eq(siteInvitations.id, id))
|
||||
const headers = await adminHeaders(ctx.app)
|
||||
|
||||
const res = await ctx.app.request(`/api/site/invitations/${id}/deliveries`, { method: 'POST', headers })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Invitation has been revoked')
|
||||
})
|
||||
|
||||
it('revoke returns 404 for an unknown invitation id', async () => {
|
||||
const ctx = await createTestApp()
|
||||
stubEmailProvider()
|
||||
await seedEmailOptions(ctx)
|
||||
const headers = await adminHeaders(ctx.app)
|
||||
|
||||
const res = await ctx.app.request('/api/site/invitations/does-not-exist', { method: 'DELETE', headers })
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Invitation not found')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('revoke returns 400 when the invitation was already accepted', async () => {
|
||||
const ctx = await createTestApp()
|
||||
stubEmailProvider()
|
||||
await seedEmailOptions(ctx)
|
||||
const id = await createInvitation(ctx, 'accepted-revoke@example.com')
|
||||
await ctx.db
|
||||
.update(siteInvitations)
|
||||
.set({ acceptedBy: 'someone', acceptedAt: new Date() })
|
||||
.where(eq(siteInvitations.id, id))
|
||||
const headers = await adminHeaders(ctx.app)
|
||||
|
||||
const res = await ctx.app.request(`/api/site/invitations/${id}`, { method: 'DELETE', headers })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Invitation has already been used')
|
||||
})
|
||||
|
||||
it('revoke returns 400 when the invitation was already revoked', async () => {
|
||||
const ctx = await createTestApp()
|
||||
stubEmailProvider()
|
||||
await seedEmailOptions(ctx)
|
||||
const id = await createInvitation(ctx, 'double-revoke@example.com')
|
||||
const headers = await adminHeaders(ctx.app)
|
||||
|
||||
const first = await ctx.app.request(`/api/site/invitations/${id}`, { method: 'DELETE', headers })
|
||||
expect(first.status).toBe(200)
|
||||
|
||||
const second = await ctx.app.request(`/api/site/invitations/${id}`, { method: 'DELETE', headers })
|
||||
expect(second.status).toBe(400)
|
||||
const body = (await second.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Invitation has already been revoked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -195,4 +332,14 @@ describe('Public Site Invitations API', () => {
|
||||
expect(body.email).toBe('invitee@example.com')
|
||||
expect(body.token).toBe(invitation.token)
|
||||
})
|
||||
|
||||
it('returns 404 for an unknown invitation token', async () => {
|
||||
const ctx = await createTestApp()
|
||||
const res = await ctx.app.request('/api/site/invitations/no-such-token')
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { code: number; message: string; status: string } }
|
||||
expect(body.error.code).toBe(404)
|
||||
expect(body.error.message).toBe('Invitation not found')
|
||||
expect(body.error.status).toBe('NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { pageQuerySchema, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import {
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
resendSiteInvitation,
|
||||
revokeSiteInvitation,
|
||||
} from '../../usecases/site/invitation'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
// SiteInvitation is already wire-shaped (ISO string timestamps) — no DTO mapper.
|
||||
const siteInvitationSchema = z
|
||||
@@ -29,14 +30,7 @@ const siteInvitationSchema = z
|
||||
})
|
||||
.openapi('SiteInvitation')
|
||||
|
||||
const siteInvitationListSchema = z
|
||||
.object({ items: z.array(siteInvitationSchema), total: z.number().int() })
|
||||
.openapi('SiteInvitationList')
|
||||
|
||||
const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
const siteInvitationListSchema = pageSchema(siteInvitationSchema, 'SiteInvitationList')
|
||||
|
||||
const createSchema = z.object({ email: z.string().email() })
|
||||
|
||||
@@ -47,7 +41,7 @@ const listRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { query: paginationSchema },
|
||||
request: { query: pageQuerySchema },
|
||||
responses: { 200: jsonContent(siteInvitationListSchema, 'Invitations') },
|
||||
})
|
||||
|
||||
@@ -113,18 +107,19 @@ const getByTokenRoute = createRoute({
|
||||
export const adminSiteInvitations = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, async (c) => {
|
||||
const { page, pageSize } = c.req.valid('query')
|
||||
return c.json(await listSiteInvitations(c.get('deps'), page, pageSize), 200)
|
||||
const result = await listSiteInvitations(c.get('deps'), page, pageSize)
|
||||
return c.json({ ...result, page, pageSize }, 200)
|
||||
})
|
||||
.openapi(createRouteDoc, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!userId) return apiError(c, 401, 'Unauthorized')
|
||||
const result = await createSiteInvitation(c.get('deps'), c.get('platform'), {
|
||||
userId,
|
||||
orgId: c.get('orgId')!,
|
||||
email: c.req.valid('json').email,
|
||||
requestUrl: c.req.url,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.message }, 409)
|
||||
if (!result.ok) return apiError(c, 409, result.message)
|
||||
return c.json(result.invitation, 201)
|
||||
})
|
||||
.openapi(resendRoute, async (c) => {
|
||||
@@ -133,23 +128,23 @@ export const adminSiteInvitations = new OpenAPIHono<Env>()
|
||||
requestUrl: c.req.url,
|
||||
})
|
||||
if (result.ok) return c.json(result.invitation, 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'Invitation not found' }, 404)
|
||||
if (result.reason === 'already_accepted') return c.json({ error: 'Invitation has already been used' }, 400)
|
||||
return c.json({ error: 'Invitation has been revoked' }, 400)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Invitation not found')
|
||||
if (result.reason === 'already_accepted') return apiError(c, 400, 'Invitation has already been used')
|
||||
return apiError(c, 400, 'Invitation has been revoked')
|
||||
})
|
||||
.openapi(revokeRoute, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!userId) return apiError(c, 401, 'Unauthorized')
|
||||
const id = c.req.valid('param').id
|
||||
const result = await revokeSiteInvitation(c.get('deps'), { userId, orgId: c.get('orgId')!, id })
|
||||
if (result.ok) return c.json({ id, revoked: true as const }, 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'Invitation not found' }, 404)
|
||||
if (result.reason === 'already_accepted') return c.json({ error: 'Invitation has already been used' }, 400)
|
||||
return c.json({ error: 'Invitation has already been revoked' }, 400)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Invitation not found')
|
||||
if (result.reason === 'already_accepted') return apiError(c, 400, 'Invitation has already been used')
|
||||
return apiError(c, 400, 'Invitation has already been revoked')
|
||||
})
|
||||
|
||||
export const publicSiteInvitations = new OpenAPIHono<Env>().openapi(getByTokenRoute, async (c) => {
|
||||
const invitation = await getSiteInvitationByToken(c.get('deps'), c.req.valid('param').token)
|
||||
if (!invitation) return c.json({ error: 'Invitation not found' }, 404)
|
||||
if (!invitation) return apiError(c, 404, 'Invitation not found')
|
||||
return c.json(invitation, 200)
|
||||
})
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('Admin Invite Codes API — GET /', () => {
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/site/invite-codes', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body).toEqual({ items: [], total: 0 })
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
|
||||
expect(body).toEqual({ items: [], total: 0, page: 1, pageSize: 20 })
|
||||
})
|
||||
|
||||
it('returns created codes with correct total [spec: invite-codes/list]', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { pageQuerySchema, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import type { InviteCodeRecord } from '../../usecases/ports'
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
listInviteCodes,
|
||||
validateInviteCode,
|
||||
} from '../../usecases/site/invite-code'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
const inviteCodeSchema = z
|
||||
.object({
|
||||
@@ -33,9 +34,7 @@ function toInviteCodeDTO(r: InviteCodeRecord): InviteCodeDTO {
|
||||
}
|
||||
}
|
||||
|
||||
const inviteCodeListSchema = z
|
||||
.object({ items: z.array(inviteCodeSchema), total: z.number().int() })
|
||||
.openapi('InviteCodeList')
|
||||
const inviteCodeListSchema = pageSchema(inviteCodeSchema, 'InviteCodeList')
|
||||
|
||||
const generateSchema = z.object({
|
||||
count: z.number().int().min(1).max(100),
|
||||
@@ -49,11 +48,6 @@ const validateSchema = z.object({
|
||||
.regex(/^[0-9A-Z]{8}$/),
|
||||
})
|
||||
|
||||
const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
const listRoute = createRoute({
|
||||
operationId: 'listInviteCodes',
|
||||
summary: 'List invite codes',
|
||||
@@ -61,7 +55,7 @@ const listRoute = createRoute({
|
||||
method: 'get',
|
||||
path: '/',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { query: paginationSchema },
|
||||
request: { query: pageQuerySchema },
|
||||
responses: { 200: jsonContent(inviteCodeListSchema, 'Invite codes') },
|
||||
})
|
||||
|
||||
@@ -110,11 +104,11 @@ export const adminInviteCodes = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, async (c) => {
|
||||
const { page, pageSize } = c.req.valid('query')
|
||||
const result = await listInviteCodes(c.get('deps'), { page, pageSize })
|
||||
return c.json({ items: result.items.map(toInviteCodeDTO), total: result.total }, 200)
|
||||
return c.json({ items: result.items.map(toInviteCodeDTO), total: result.total, page, pageSize }, 200)
|
||||
})
|
||||
.openapi(generateRoute, async (c) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!userId) return apiError(c, 401, 'Unauthorized')
|
||||
const { count, expiresInDays } = c.req.valid('json')
|
||||
const result = await generateInviteCodes(c.get('deps'), { userId, orgId: c.get('orgId')!, count, expiresInDays })
|
||||
return c.json({ codes: result.codes.map(toInviteCodeDTO) }, 201)
|
||||
@@ -123,8 +117,8 @@ export const adminInviteCodes = new OpenAPIHono<Env>()
|
||||
const id = c.req.valid('param').id
|
||||
const result = await deleteInviteCode(c.get('deps'), { userId: c.get('userId')!, orgId: c.get('orgId')!, id })
|
||||
if (result.ok) return c.json({ id, deleted: true as const }, 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'Invite code not found' }, 404)
|
||||
return c.json({ error: 'Cannot delete a used invite code' }, 400)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Invite code not found')
|
||||
return apiError(c, 400, 'Cannot delete a used invite code')
|
||||
})
|
||||
|
||||
export const publicInviteCodes = new OpenAPIHono<Env>().openapi(validateRoute, async (c) => {
|
||||
|
||||
@@ -276,8 +276,10 @@ describe('GET /api/site/licensing/pairings/:code', () => {
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: 'invalid_certificate',
|
||||
reason: 'incomplete_response',
|
||||
error: {
|
||||
message: 'Invalid certificate',
|
||||
details: [{ reason: 'INVALID_CERTIFICATE', metadata: { certificateReason: 'incomplete_response' } }],
|
||||
},
|
||||
})
|
||||
const state = await createLicenseBindingRepo(db).loadLicenseState()
|
||||
expect(state.status).toBe('disconnected')
|
||||
@@ -311,8 +313,10 @@ describe('GET /api/site/licensing/pairings/:code', () => {
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: 'invalid_certificate',
|
||||
reason: 'signature',
|
||||
error: {
|
||||
message: 'Invalid certificate',
|
||||
details: [{ reason: 'INVALID_CERTIFICATE', metadata: { certificateReason: 'signature' } }],
|
||||
},
|
||||
})
|
||||
// ZPan stored nothing; the cloud binding was released.
|
||||
const state = await createLicenseBindingRepo(db).loadLicenseState()
|
||||
|
||||
@@ -96,8 +96,8 @@ describe('POST /api/site/licensing/refresh-cron', () => {
|
||||
const res = await app.request('/api/site/licensing/refresh-cron?secret=anything', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 401 when secret param does not match REFRESH_CRON_SECRET', async () => {
|
||||
@@ -106,8 +106,8 @@ describe('POST /api/site/licensing/refresh-cron', () => {
|
||||
const res = await app.request('/api/site/licensing/refresh-cron?secret=wrong-secret', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 401 when secret query param is missing', async () => {
|
||||
@@ -211,6 +211,6 @@ describe('POST /api/site/licensing/refresh-cron', () => {
|
||||
const res = await app.request('/api/site/licensing/traffic-sync-runs?secret=wrong-secret', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Unauthorized' })
|
||||
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Unauthorized' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '../../usecases/site/licensing'
|
||||
import { getSitePublicOrigin } from '../../usecases/site/public-origin'
|
||||
import { syncPendingCloudTrafficReports } from '../../usecases/store/traffic-metering'
|
||||
import { jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonContent } from '../openapi'
|
||||
|
||||
function getCloudBaseUrl(c: Context<Env>): string {
|
||||
return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
@@ -114,10 +114,7 @@ const pollPairingRoute = createRoute({
|
||||
request: { params: z.object({ code: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(pairingStatusSchema, 'Pairing status'),
|
||||
502: jsonContent(
|
||||
z.object({ error: z.string(), reason: z.string(), cloud_unbind_error: z.string().nullable() }),
|
||||
'Cloud error',
|
||||
),
|
||||
502: errorResponse('Cloud error'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -150,7 +147,7 @@ const publicApp = new OpenAPIHono<Env>()
|
||||
// Cron-secret-authorized sync endpoints — called by external schedulers, not SDK
|
||||
// users. Kept as plain routes, excluded from the OpenAPI document.
|
||||
publicApp.post('/refresh-cron', async (c) => {
|
||||
if (!isAuthorizedCronRequest(c)) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!isAuthorizedCronRequest(c)) return apiError(c, 401, 'Unauthorized')
|
||||
const cloudBaseUrl = getCloudBaseUrl(c)
|
||||
const origin = await getInstanceOrigin(c)
|
||||
const instance = origin
|
||||
@@ -160,7 +157,7 @@ publicApp.post('/refresh-cron', async (c) => {
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
publicApp.post('/traffic-sync-runs', async (c) => {
|
||||
if (!isAuthorizedCronRequest(c)) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!isAuthorizedCronRequest(c)) return apiError(c, 401, 'Unauthorized')
|
||||
const cloudBaseUrl = getCloudBaseUrl(c)
|
||||
const [traffic, remoteDownload] = await Promise.all([
|
||||
syncPendingCloudTrafficReports(c.get('deps'), { cloudBaseUrl }),
|
||||
@@ -199,10 +196,13 @@ export const licensingAdmin = adminApp
|
||||
orgId: c.get('orgId')!,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return c.json(
|
||||
{ error: 'invalid_certificate', reason: result.reason, cloud_unbind_error: result.cloudUnbindError },
|
||||
502,
|
||||
)
|
||||
return apiError(c, 502, 'Invalid certificate', {
|
||||
reason: 'INVALID_CERTIFICATE',
|
||||
metadata: {
|
||||
certificateReason: result.reason,
|
||||
...(result.cloudUnbindError ? { cloudUnbindError: result.cloudUnbindError } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (result.status === 'approved') {
|
||||
return c.json({ status: 'approved', edition: result.edition, cloud_store_id: result.cloudStoreId }, 200)
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('[CF] Admin Storages API', () => {
|
||||
const res = await app.request('/api/site/storages', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body).toEqual({ items: [], total: 0 })
|
||||
expect(body).toEqual({ items: [], total: 0, page: 1, pageSize: 0 })
|
||||
})
|
||||
|
||||
it('POST /api/site/storages creates a storage', async () => {
|
||||
@@ -72,9 +72,12 @@ describe('[CF] Admin Storages API', () => {
|
||||
}),
|
||||
})
|
||||
if (res.status === 402) {
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.feature).toBe('storages_unlimited')
|
||||
expect(body.limit).toBe(FREE_STORAGE_LIMIT)
|
||||
const body = (await res.json()) as {
|
||||
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('storages_unlimited')
|
||||
expect(body.error.details[0].metadata.limit).toBe(String(FREE_STORAGE_LIMIT))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,9 +102,12 @@ describe('[CF] Admin Storages API', () => {
|
||||
}),
|
||||
})
|
||||
if (res.status === 402) {
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.feature).toBe('storages_unlimited')
|
||||
expect(body.limit).toBe(FREE_STORAGE_LIMIT)
|
||||
const body = (await res.json()) as {
|
||||
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('storages_unlimited')
|
||||
expect(body.error.details[0].metadata.limit).toBe(String(FREE_STORAGE_LIMIT))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('Admin Storages API', () => {
|
||||
const res = await app.request('/api/site/storages', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body).toEqual({ items: [], total: 0 })
|
||||
expect(body).toEqual({ items: [], total: 0, page: 1, pageSize: 0 })
|
||||
})
|
||||
|
||||
it('POST / creates a storage [spec: storages/create]', async () => {
|
||||
@@ -85,10 +85,13 @@ describe('Admin Storages API', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('feature_not_available')
|
||||
expect(body.feature).toBe('storages_unlimited')
|
||||
expect(body.limit).toBe(FREE_STORAGE_LIMIT)
|
||||
const body = (await res.json()) as {
|
||||
error: { message: string; details: Array<{ reason: string; metadata: Record<string, string> }> }
|
||||
}
|
||||
expect(body.error.message).toBe('Feature not available')
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('storages_unlimited')
|
||||
expect(body.error.details[0].metadata.limit).toBe(String(FREE_STORAGE_LIMIT))
|
||||
})
|
||||
|
||||
it('GET / lists created storages [spec: storages/list]', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { createStorageSchema, featureGateErrorSchema, updateStorageSchema } from '@shared/schemas'
|
||||
import { createStorageSchema, ErrorReason, pageSchema, updateStorageSchema } from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import type { StorageRecord } from '../../usecases/ports'
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type StorageFeatureBlock,
|
||||
updateStorage,
|
||||
} from '../../usecases/site/storage'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, 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.
|
||||
@@ -45,9 +45,13 @@ function toStorageDTO(s: StorageRecord): StorageDTO {
|
||||
return { ...s, createdAt: s.createdAt.toISOString(), updatedAt: s.updatedAt.toISOString() }
|
||||
}
|
||||
|
||||
const storageListSchema = z.object({ items: z.array(storageSchema), total: z.number().int() }).openapi('StorageList')
|
||||
const storageListSchema = pageSchema(storageSchema, 'StorageList')
|
||||
|
||||
const featureNotAvailable = (block: StorageFeatureBlock) => ({ error: 'feature_not_available', ...block })
|
||||
const featureBlockMetadata = (block: StorageFeatureBlock): Record<string, string> => ({
|
||||
feature: block.feature,
|
||||
...('currentCount' in block ? { currentCount: String(block.currentCount) } : {}),
|
||||
...('limit' in block ? { limit: String(block.limit) } : {}),
|
||||
})
|
||||
|
||||
const listRoute = createRoute({
|
||||
operationId: 'listStorages',
|
||||
@@ -69,7 +73,7 @@ const createStorageRoute = createRoute({
|
||||
request: jsonBody(createStorageSchema),
|
||||
responses: {
|
||||
201: jsonContent(storageSchema, 'Created storage'),
|
||||
402: jsonContent(featureGateErrorSchema, 'Feature not available'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -97,7 +101,7 @@ const updateStorageRoute = createRoute({
|
||||
request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageSchema) },
|
||||
responses: {
|
||||
200: jsonContent(storageSchema, 'Updated storage'),
|
||||
402: jsonContent(featureGateErrorSchema, 'Feature not available'),
|
||||
402: errorResponse('Feature not available'),
|
||||
404: errorResponse('Storage not found'),
|
||||
},
|
||||
})
|
||||
@@ -120,7 +124,8 @@ const deleteStorageRoute = createRoute({
|
||||
const storages = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, async (c) => {
|
||||
const result = await listStorages(c.get('deps'))
|
||||
return c.json({ items: result.items.map(toStorageDTO), total: result.total }, 200)
|
||||
const items = result.items.map(toStorageDTO)
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(createStorageRoute, async (c) => {
|
||||
const result = await createStorage(c.get('deps'), {
|
||||
@@ -128,12 +133,16 @@ const storages = new OpenAPIHono<Env>()
|
||||
orgId: c.get('orgId')!,
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) return c.json(featureNotAvailable(result.block), 402)
|
||||
if (!result.ok)
|
||||
return apiError(c, 402, 'Feature not available', {
|
||||
reason: ErrorReason.FEATURE_NOT_AVAILABLE,
|
||||
metadata: featureBlockMetadata(result.block),
|
||||
})
|
||||
return c.json(toStorageDTO(result.storage), 201)
|
||||
})
|
||||
.openapi(getStorageRoute, async (c) => {
|
||||
const storage = await getStorage(c.get('deps'), c.req.valid('param').id)
|
||||
if (!storage) return c.json({ error: 'Storage not found' }, 404)
|
||||
if (!storage) return apiError(c, 404, 'Storage not found')
|
||||
return c.json(toStorageDTO(storage), 200)
|
||||
})
|
||||
.openapi(updateStorageRoute, async (c) => {
|
||||
@@ -144,15 +153,18 @@ const storages = new OpenAPIHono<Env>()
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (result.ok) return c.json(toStorageDTO(result.storage), 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'Storage not found' }, 404)
|
||||
return c.json(featureNotAvailable(result.block), 402)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Storage not found')
|
||||
return apiError(c, 402, 'Feature not available', {
|
||||
reason: ErrorReason.FEATURE_NOT_AVAILABLE,
|
||||
metadata: featureBlockMetadata(result.block),
|
||||
})
|
||||
})
|
||||
.openapi(deleteStorageRoute, async (c) => {
|
||||
const id = c.req.valid('param').id
|
||||
const result = await deleteStorage(c.get('deps'), { userId: c.get('userId')!, orgId: c.get('orgId')!, id })
|
||||
if (result.ok) return c.json({ id, deleted: true as const }, 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'Storage not found' }, 404)
|
||||
return c.json({ error: 'Storage is referenced by existing files' }, 409)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Storage not found')
|
||||
return apiError(c, 409, 'Storage is referenced by existing files')
|
||||
})
|
||||
|
||||
export default storages
|
||||
|
||||
@@ -28,12 +28,14 @@ describe('System API captcha options', () => {
|
||||
|
||||
const noKeys = await putOption(app, admin, CAPTCHA_ENABLED_KEY, { value: 'true' })
|
||||
expect(noKeys.status).toBe(400)
|
||||
await expect(noKeys.json()).resolves.toEqual({ error: 'Captcha site key is required before enabling captcha' })
|
||||
const noKeysBody = (await noKeys.json()) as { error: { message: string } }
|
||||
expect(noKeysBody.error.message).toBe('Captcha site key is required before enabling captcha')
|
||||
|
||||
await putOption(app, admin, CAPTCHA_SITE_KEY_KEY, { value: 'site-key' })
|
||||
const noSecret = await putOption(app, admin, CAPTCHA_ENABLED_KEY, { value: 'true' })
|
||||
expect(noSecret.status).toBe(400)
|
||||
await expect(noSecret.json()).resolves.toEqual({ error: 'Captcha secret key is required before enabling captcha' })
|
||||
const noSecretBody = (await noSecret.json()) as { error: { message: string } }
|
||||
expect(noSecretBody.error.message).toBe('Captcha secret key is required before enabling captcha')
|
||||
|
||||
await putOption(app, admin, CAPTCHA_SECRET_OPTION_KEY, { value: 'secret-key' })
|
||||
await putOption(app, admin, CAPTCHA_PROVIDER_KEY, { value: 'captchafox' })
|
||||
@@ -72,10 +74,12 @@ describe('System API captcha options', () => {
|
||||
|
||||
const provider = await putOption(app, admin, CAPTCHA_PROVIDER_KEY, { value: 'unknown' })
|
||||
expect(provider.status).toBe(400)
|
||||
await expect(provider.json()).resolves.toEqual({ error: 'Captcha provider is invalid' })
|
||||
const providerBody = (await provider.json()) as { error: { message: string } }
|
||||
expect(providerBody.error.message).toBe('Captcha provider is invalid')
|
||||
|
||||
const minScore = await putOption(app, admin, CAPTCHA_MIN_SCORE_KEY, { value: '1.5' })
|
||||
expect(minScore.status).toBe(400)
|
||||
await expect(minScore.json()).resolves.toEqual({ error: 'Captcha minimum score must be between 0 and 1' })
|
||||
const minScoreBody = (await minScore.json()) as { error: { message: string } }
|
||||
expect(minScoreBody.error.message).toBe('Captcha minimum score must be between 0 and 1')
|
||||
})
|
||||
})
|
||||
|
||||
+15
-16
@@ -1,5 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { featureGateErrorSchema } from '@shared/schemas'
|
||||
import { ErrorReason, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { runtimeInfo } from '../../usecases/site/instance-info'
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveInstanceInfo,
|
||||
setSystemOption,
|
||||
} from '../../usecases/site/system'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
|
||||
const instanceInfoSchema = z
|
||||
.object({
|
||||
@@ -50,9 +50,7 @@ const changelogSchema = z
|
||||
|
||||
const systemOptionSchema = z.object({ key: z.string(), value: z.string(), public: z.boolean() }).openapi('SystemOption')
|
||||
|
||||
const systemOptionListSchema = z
|
||||
.object({ items: z.array(systemOptionSchema), total: z.number().int() })
|
||||
.openapi('SystemOptionList')
|
||||
const systemOptionListSchema = pageSchema(systemOptionSchema, 'SystemOptionList')
|
||||
|
||||
const setOptionSchema = z.object({ value: z.string(), public: z.boolean().optional() })
|
||||
|
||||
@@ -112,7 +110,7 @@ const setOptionRoute = createRoute({
|
||||
200: jsonContent(systemOptionSchema, 'Updated option'),
|
||||
201: jsonContent(systemOptionSchema, 'Created option'),
|
||||
400: errorResponse('Invalid option'),
|
||||
402: jsonContent(featureGateErrorSchema, 'Feature not available'),
|
||||
402: errorResponse('Feature not available'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -138,17 +136,18 @@ const system = new OpenAPIHono<Env>()
|
||||
.openapi(changelogRoute, async (c) =>
|
||||
c.json(await getChangelog(c.get('deps'), { now: Date.now(), force: c.req.valid('query').refresh === 'true' }), 200),
|
||||
)
|
||||
.openapi(listOptionsRoute, async (c) =>
|
||||
c.json(await listSystemOptions(c.get('deps'), { isAdmin: c.get('userRole') === 'admin' }), 200),
|
||||
)
|
||||
.openapi(listOptionsRoute, async (c) => {
|
||||
const { items } = await listSystemOptions(c.get('deps'), { isAdmin: c.get('userRole') === 'admin' })
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(getOptionRoute, async (c) => {
|
||||
const result = await getSystemOption(c.get('deps'), {
|
||||
key: c.req.valid('param').key,
|
||||
isAdmin: c.get('userRole') === 'admin',
|
||||
})
|
||||
if (result.ok) return c.json(result.option, 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'Option not found' }, 404)
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Option not found')
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
})
|
||||
.openapi(setOptionRoute, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
@@ -161,11 +160,11 @@ const system = new OpenAPIHono<Env>()
|
||||
})
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'feature_blocked')
|
||||
return c.json(
|
||||
{ error: 'feature_not_available', feature: result.feature, upgrade_url: '/settings/billing' },
|
||||
402,
|
||||
)
|
||||
return c.json({ error: result.message }, 400)
|
||||
return apiError(c, 402, 'Feature not available', {
|
||||
reason: ErrorReason.FEATURE_NOT_AVAILABLE,
|
||||
metadata: { feature: result.feature, upgradeUrl: '/settings/billing' },
|
||||
})
|
||||
return apiError(c, 400, result.message)
|
||||
}
|
||||
return result.created ? c.json(result.option, 201) : c.json(result.option, 200)
|
||||
})
|
||||
|
||||
@@ -751,7 +751,9 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(checkout.status).toBe(409)
|
||||
await expect(checkout.json()).resolves.toEqual({ error: 'workspace_plan_exists' })
|
||||
await expect(checkout.json()).resolves.toMatchObject({
|
||||
error: { message: 'Workspace plan already exists', details: [{ reason: 'WORKSPACE_PLAN_EXISTS' }] },
|
||||
})
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -921,7 +923,7 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(checkout.status).toBe(502)
|
||||
await expect(checkout.json()).resolves.toEqual({ error: 'invalid_cloud_response' })
|
||||
await expect(checkout.json()).resolves.toMatchObject({ error: { code: 502, message: 'invalid_cloud_response' } })
|
||||
const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]>
|
||||
expect(calls.some(([url, init]) => init.method === 'POST' && String(url).endsWith('/orders'))).toBe(false)
|
||||
})
|
||||
@@ -1077,9 +1079,9 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(payment.status).toBe(403)
|
||||
await expect(payment.json()).resolves.toEqual({ error: 'Forbidden' })
|
||||
await expect(payment.json()).resolves.toMatchObject({ error: { message: 'Forbidden' } })
|
||||
expect(canceled.status).toBe(403)
|
||||
await expect(canceled.json()).resolves.toEqual({ error: 'Forbidden' })
|
||||
await expect(canceled.json()).resolves.toMatchObject({ error: { message: 'Forbidden' } })
|
||||
const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]>
|
||||
expect(calls.some(([url]) => String(url).includes('/orders/order-other-org/payments'))).toBe(false)
|
||||
expect(
|
||||
@@ -1103,14 +1105,19 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
const orders = await app.request('/api/store/orders', { headers })
|
||||
|
||||
const expectFeatureGate = async (res: Response) => {
|
||||
const body = (await res.json()) as { error: { details: { reason: string; metadata?: { feature?: string } }[] } }
|
||||
expect(body.error.details[0]?.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0]?.metadata?.feature).toBe('quota_store')
|
||||
}
|
||||
expect(packages.status).toBe(402)
|
||||
await expect(packages.json()).resolves.toMatchObject({ error: 'feature_not_available', feature: 'quota_store' })
|
||||
await expectFeatureGate(packages)
|
||||
expect(targets.status).toBe(402)
|
||||
await expect(targets.json()).resolves.toMatchObject({ error: 'feature_not_available', feature: 'quota_store' })
|
||||
await expectFeatureGate(targets)
|
||||
expect(checkout.status).toBe(402)
|
||||
await expect(checkout.json()).resolves.toMatchObject({ error: 'feature_not_available', feature: 'quota_store' })
|
||||
await expectFeatureGate(checkout)
|
||||
expect(orders.status).toBe(402)
|
||||
await expect(orders.json()).resolves.toMatchObject({ error: 'feature_not_available', feature: 'quota_store' })
|
||||
await expectFeatureGate(orders)
|
||||
})
|
||||
|
||||
it('rejects malformed successful checkout responses', async () => {
|
||||
@@ -1127,7 +1134,7 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'invalid_cloud_response' })
|
||||
await expect(res.json()).resolves.toMatchObject({ error: { code: 502, message: 'invalid_cloud_response' } })
|
||||
})
|
||||
|
||||
it('surfaces Cloud checkout error responses [spec: quota-store/checkout-error-surfacing]', async () => {
|
||||
@@ -1148,7 +1155,7 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'cloud_down' })
|
||||
await expect(res.json()).resolves.toMatchObject({ error: { code: 502, message: 'cloud_down' } })
|
||||
})
|
||||
|
||||
it('uses status errors when Cloud checkout error bodies have no string error', async () => {
|
||||
@@ -1169,7 +1176,7 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(502)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'cloud_request_failed_504' })
|
||||
await expect(res.json()).resolves.toMatchObject({ error: { code: 502, message: 'cloud_request_failed_504' } })
|
||||
})
|
||||
|
||||
it('accepts current Cloud quota-change webhook tokens with audience equal to instance id', async () => {
|
||||
@@ -1623,7 +1630,9 @@ describe('Quota Store API', () => {
|
||||
const res = await postWebhook(app, payload)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'invalid_payload' })
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: { message: 'Invalid payload', details: [{ reason: 'INVALID_PAYLOAD' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('storage decreases revoke matching Cloud order entitlements without changing base quota', async () => {
|
||||
@@ -1982,7 +1991,7 @@ describe('Quota Store API', () => {
|
||||
|
||||
expect(first.status).toBe(200)
|
||||
expect(retry.status).toBe(400)
|
||||
await expect(retry.json()).resolves.toEqual({ error: 'webhook_payload_conflict' })
|
||||
await expect(retry.json()).resolves.toMatchObject({ error: { code: 400, message: 'webhook_payload_conflict' } })
|
||||
})
|
||||
|
||||
it('allows failed delivery retries when the payload is unchanged', async () => {
|
||||
@@ -2009,7 +2018,7 @@ describe('Quota Store API', () => {
|
||||
const retry = await postWebhook(app, payload)
|
||||
|
||||
expect(failed.status).toBe(400)
|
||||
await expect(failed.json()).resolves.toEqual({ error: 'target_quota_missing' })
|
||||
await expect(failed.json()).resolves.toMatchObject({ error: { code: 400, message: 'target_quota_missing' } })
|
||||
expect(retry.status).toBe(200)
|
||||
await expect(retry.json()).resolves.toMatchObject({ success: true, duplicate: false })
|
||||
const deliveries = await db.all<{ status: string; error: string | null }>(
|
||||
@@ -2206,7 +2215,9 @@ describe('Quota Store API', () => {
|
||||
const res = await postWebhook(app, payload)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
await expect(res.json()).resolves.toMatchObject({ error: 'invalid_payload' })
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: { message: 'Invalid payload', details: [{ reason: 'INVALID_PAYLOAD' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects deliveries without resource details', async () => {
|
||||
@@ -2222,7 +2233,9 @@ describe('Quota Store API', () => {
|
||||
const res = await postWebhook(app, payload)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
await expect(res.json()).resolves.toMatchObject({ error: 'invalid_payload' })
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: { message: 'Invalid payload', details: [{ reason: 'INVALID_PAYLOAD' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects credit-only commerce fulfillment events on the quota webhook [spec: quota-store/webhook-rejects-commerce]', async () => {
|
||||
@@ -2255,7 +2268,172 @@ describe('Quota Store API', () => {
|
||||
const res = await postWebhook(app, payload)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
await expect(res.json()).resolves.toMatchObject({ error: 'invalid_payload' })
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: { message: 'Invalid payload', details: [{ reason: 'INVALID_PAYLOAD' }] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Nulls the bound store id while keeping the refresh token + cached cert, so the
|
||||
// quota_store feature gate still passes (license stays bound/active) but
|
||||
// getCloudStoreBinding throws quota_store_binding_missing — the state the
|
||||
// storefront proxies surface as 403.
|
||||
async function breakStoreBinding(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
await db.run(sql`UPDATE license_bindings SET cloud_store_id = NULL`)
|
||||
}
|
||||
|
||||
describe('Quota Store API — storefront proxy error branches', () => {
|
||||
it('proxies credit products through the store products endpoint', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app, 'credit-products@example.com')
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
items: [
|
||||
cloudProduct({
|
||||
id: 'cloud-credit-1',
|
||||
name: 'Credit Pack',
|
||||
metadata: { deliverable: { type: 'zpan.credits', credits: 1000 } },
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
}),
|
||||
} as Response)
|
||||
|
||||
const res = await app.request('/api/store/credits/products', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
total: 1,
|
||||
items: [{ id: 'cloud-credit-1' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a discount quote from Cloud', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app, 'discount@example.com')
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ code: 'SAVE10', currency: 'usd', subtotal: 1000, discount: 100, total: 900 }),
|
||||
} as Response)
|
||||
|
||||
const res = await app.request('/api/store/discount-quotes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'SAVE10', priceId: 'price-usd' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
code: 'SAVE10',
|
||||
currency: 'usd',
|
||||
subtotal: 1000,
|
||||
discount: 100,
|
||||
total: 900,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 403 (binding_missing) for storefront reads when the store is not bound', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app, 'unbound-reads@example.com')
|
||||
await breakStoreBinding(db)
|
||||
|
||||
const packages = await app.request('/api/store/packages', { headers })
|
||||
const creditProducts = await app.request('/api/store/credits/products', { headers })
|
||||
const targets = await app.request('/api/store/targets', { headers })
|
||||
const discount = await app.request('/api/store/discount-quotes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'SAVE10', priceId: 'price-usd' }),
|
||||
})
|
||||
|
||||
for (const res of [packages, creditProducts, targets, discount]) {
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('quota_store_binding_missing')
|
||||
expect(body.error.status).toBe('PERMISSION_DENIED')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 403 (binding_missing) for owner-scoped store endpoints when the store is not bound', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app, 'unbound-owner@example.com')
|
||||
await breakStoreBinding(db)
|
||||
|
||||
const credits = await app.request('/api/store/credits', { headers })
|
||||
const ledger = await app.request('/api/store/credits/ledger-entries', { headers })
|
||||
const billing = await app.request('/api/store/billing-portal-sessions', { method: 'POST', headers })
|
||||
const checkout = await app.request('/api/store/checkouts', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ packageId: 'cloud-pkg-1' }),
|
||||
})
|
||||
const redeem = await app.request('/api/store/credits/redemptions', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'ZS-TEST-1' }),
|
||||
})
|
||||
|
||||
for (const res of [credits, ledger, billing, checkout, redeem]) {
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('quota_store_binding_missing')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 502 when Cloud fails while fetching an order for payment/cancel', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app, 'order-cloud-error@example.com')
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'cloud_boom' }),
|
||||
} as Response)
|
||||
const payment = await app.request('/api/store/orders/order-err/payments', { method: 'POST', headers })
|
||||
expect(payment.status).toBe(502)
|
||||
await expect(payment.json()).resolves.toMatchObject({ error: { code: 502, message: 'cloud_boom' } })
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'cloud_boom' }),
|
||||
} as Response)
|
||||
const cancel = await app.request('/api/store/orders/order-err', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'canceled' }),
|
||||
})
|
||||
expect(cancel.status).toBe(502)
|
||||
await expect(cancel.json()).resolves.toMatchObject({ error: { code: 502, message: 'cloud_boom' } })
|
||||
})
|
||||
|
||||
it('returns 403 (store not ready) for order endpoints when the store is not bound', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app, 'unbound-orders@example.com')
|
||||
await breakStoreBinding(db)
|
||||
|
||||
const orders = await app.request('/api/store/orders', { headers })
|
||||
const payment = await app.request('/api/store/orders/order-1/payments', { method: 'POST', headers })
|
||||
const cancel = await app.request('/api/store/orders/order-1', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'canceled' }),
|
||||
})
|
||||
|
||||
for (const res of [orders, payment, cancel]) {
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('quota_store_binding_missing')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
listTargets,
|
||||
redeemGiftCard,
|
||||
} from '../../usecases/store/store'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
import { cloudStoreOrdersQuerySchema, getCloudBaseUrl } from './helpers'
|
||||
import { getCloudOrders, getInstanceOrigin } from './shared'
|
||||
|
||||
@@ -207,46 +207,46 @@ app.use(requireFeature('quota_store'))
|
||||
export const cloudStore = app
|
||||
.openapi(packagesRoute, async (c) => {
|
||||
const result = await listPackages(c.get('deps'), getCloudBaseUrl(c))
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(creditProductsRoute, async (c) => {
|
||||
const result = await listCreditProducts(c.get('deps'), getCloudBaseUrl(c))
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(targetsRoute, async (c) => {
|
||||
const result = await listTargets(c.get('deps'), c.get('userId')!)
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(creditsRoute, async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await getCreditBalance(c.get('deps'), getCloudBaseUrl(c), targetOrgId)
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(ledgerRoute, async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await getCreditLedger(c.get('deps'), getCloudBaseUrl(c), targetOrgId)
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(redeemRoute, async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await redeemGiftCard(c.get('deps'), getCloudBaseUrl(c), {
|
||||
orgId: targetOrgId,
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(checkoutRoute, async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await createCheckout(c.get('deps'), getCloudBaseUrl(c), {
|
||||
userId: c.get('userId')!,
|
||||
orgId: targetOrgId,
|
||||
@@ -254,63 +254,65 @@ export const cloudStore = app
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (result.ok) return c.json(result.value, 200)
|
||||
if (result.reason === 'binding_missing') return c.json({ error: result.error }, 403)
|
||||
if (result.reason === 'price_missing') return c.json({ error: 'package_price_missing' }, 400)
|
||||
if (result.reason === 'workspace_plan_exists') return c.json({ error: 'workspace_plan_exists' }, 409)
|
||||
return c.json({ error: result.error }, 502)
|
||||
if (result.reason === 'binding_missing') return apiError(c, 403, result.error)
|
||||
if (result.reason === 'price_missing')
|
||||
return apiError(c, 400, 'Package price missing', { reason: 'PACKAGE_PRICE_MISSING' })
|
||||
if (result.reason === 'workspace_plan_exists')
|
||||
return apiError(c, 409, 'Workspace plan already exists', { reason: 'WORKSPACE_PLAN_EXISTS' })
|
||||
return apiError(c, 502, result.error)
|
||||
})
|
||||
.openapi(discountRoute, async (c) => {
|
||||
const result = await getDiscountQuote(c.get('deps'), getCloudBaseUrl(c), c.req.valid('json'))
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(billingPortalRoute, async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await createBillingPortalSession(c.get('deps'), getCloudBaseUrl(c), {
|
||||
orgId: targetOrgId,
|
||||
origin: await getInstanceOrigin(c),
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.error }, result.reason === 'binding_missing' ? 403 : 502)
|
||||
if (!result.ok) return apiError(c, result.reason === 'binding_missing' ? 403 : 502, result.error)
|
||||
return c.json(result.value, 200)
|
||||
})
|
||||
.openapi(ordersRoute, async (c) => {
|
||||
const ready = await getStoreReadiness(c.get('deps'))
|
||||
if (!ready.ready) return c.json({ error: ready.error }, 403)
|
||||
if (!ready.ready) return apiError(c, 403, ready.error)
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const query = c.req.valid('query')
|
||||
const result = await getCloudOrders(c, { limit: query.limit, offset: query.offset, customerId: targetOrgId })
|
||||
if ('error' in result) return c.json(result, 502)
|
||||
if ('error' in result) return apiError(c, 502, result.error)
|
||||
return c.json(result, 200)
|
||||
})
|
||||
.openapi(continuePaymentRoute, async (c) => {
|
||||
const ready = await getStoreReadiness(c.get('deps'))
|
||||
if (!ready.ready) return c.json({ error: ready.error }, 403)
|
||||
if (!ready.ready) return apiError(c, 403, ready.error)
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await continueOrderPayment(c.get('deps'), getCloudBaseUrl(c), {
|
||||
orgId: targetOrgId,
|
||||
orderId: c.req.valid('param').orderId,
|
||||
origin: await getInstanceOrigin(c),
|
||||
})
|
||||
if (result.ok) return c.json(result.value, 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'not_found' }, 404)
|
||||
if (result.reason === 'forbidden') return c.json({ error: 'Forbidden' }, 403)
|
||||
return c.json({ error: result.error }, 502)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Order not found')
|
||||
if (result.reason === 'forbidden') return apiError(c, 403, 'Forbidden')
|
||||
return apiError(c, 502, result.error)
|
||||
})
|
||||
.openapi(cancelOrderRoute, async (c) => {
|
||||
const ready = await getStoreReadiness(c.get('deps'))
|
||||
if (!ready.ready) return c.json({ error: ready.error }, 403)
|
||||
if (!ready.ready) return apiError(c, 403, ready.error)
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!targetOrgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await cancelOrder(c.get('deps'), getCloudBaseUrl(c), {
|
||||
orgId: targetOrgId,
|
||||
orderId: c.req.valid('param').orderId,
|
||||
status: c.req.valid('json').status,
|
||||
})
|
||||
if (result.ok) return c.json(result.value, 200)
|
||||
if (result.reason === 'not_found') return c.json({ error: 'not_found' }, 404)
|
||||
if (result.reason === 'forbidden') return c.json({ error: 'Forbidden' }, 403)
|
||||
return c.json({ error: result.error }, 502)
|
||||
if (result.reason === 'not_found') return apiError(c, 404, 'Order not found')
|
||||
if (result.reason === 'forbidden') return apiError(c, 403, 'Forbidden')
|
||||
return apiError(c, 502, result.error)
|
||||
})
|
||||
|
||||
@@ -134,10 +134,13 @@ describe('object download cloud traffic reporting', () => {
|
||||
const res = await app.request('/api/objects/m-cloud-report-blocked', { headers })
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
error: 'insufficient_credits',
|
||||
code: 'insufficient_credits',
|
||||
resource: 'storage_egress',
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 402,
|
||||
message: 'Insufficient credits',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [{ reason: 'INSUFFICIENT_CREDITS', metadata: { resource: 'storage_egress' } }],
|
||||
},
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
@@ -245,10 +248,13 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
error: 'insufficient_credits',
|
||||
code: 'insufficient_credits',
|
||||
resource: 'storage_egress',
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 402,
|
||||
message: 'Insufficient credits',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [{ reason: 'INSUFFICIENT_CREDITS', metadata: { resource: 'storage_egress' } }],
|
||||
},
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
@@ -313,10 +319,13 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
const res = await app.request(`/api/shares/${share.token}/objects/${ref}?downloadUrl=1`, { redirect: 'manual' })
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
error: 'insufficient_credits',
|
||||
code: 'insufficient_credits',
|
||||
resource: 'storage_egress',
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
code: 402,
|
||||
message: 'Insufficient credits',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [{ reason: 'INSUFFICIENT_CREDITS', metadata: { resource: 'storage_egress' } }],
|
||||
},
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorReason } from '@shared/schemas'
|
||||
import type { Context } from 'hono'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
reportDownloadEgress,
|
||||
type TrafficReportSource,
|
||||
} from '../../usecases/store/traffic-metering'
|
||||
import { apiError } from '../openapi'
|
||||
|
||||
// Thin http adapters over the download-metering usecase: resolve the cloud base
|
||||
// URL from the request, call the usecase (deps passed whole), and render the
|
||||
@@ -29,7 +31,10 @@ interface DownloadTrafficParams {
|
||||
const cloudBaseUrl = (c: Context<Env>) => c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
|
||||
function insufficientCredits(c: Context<Env>): Response {
|
||||
return c.json({ error: 'insufficient_credits', code: 'insufficient_credits', resource: 'storage_egress' }, 402)
|
||||
return apiError(c, 402, 'Insufficient credits', {
|
||||
reason: ErrorReason.INSUFFICIENT_CREDITS,
|
||||
metadata: { resource: 'storage_egress' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { requireFeature } from '../../middleware/require-feature'
|
||||
import { processDeliveryWebhook } from '../../usecases/store/store'
|
||||
import { apiError } from '../openapi'
|
||||
import { getCloudBaseUrl, parseJson, sha256Hex } from './helpers'
|
||||
|
||||
export const cloudStoreWebhooks = new Hono<Env>().use(requireFeature('quota_store')).post('/webhook', async (c) => {
|
||||
@@ -14,7 +15,8 @@ export const cloudStoreWebhooks = new Hono<Env>().use(requireFeature('quota_stor
|
||||
body: parseJson(rawPayload),
|
||||
})
|
||||
if (outcome.ok) return c.json({ success: true, duplicate: outcome.duplicate, eventId: outcome.eventId })
|
||||
if (outcome.reason === 'invalid_token') return c.json({ error: 'invalid_event_token' }, 401)
|
||||
if (outcome.reason === 'invalid_payload') return c.json({ error: 'invalid_payload' }, 400)
|
||||
return c.json({ error: outcome.error }, 400)
|
||||
if (outcome.reason === 'invalid_token')
|
||||
return apiError(c, 401, 'Invalid event token', { reason: 'INVALID_EVENT_TOKEN' })
|
||||
if (outcome.reason === 'invalid_payload') return apiError(c, 400, 'Invalid payload', { reason: 'INVALID_PAYLOAD' })
|
||||
return apiError(c, 400, outcome.error)
|
||||
})
|
||||
|
||||
@@ -157,8 +157,10 @@ describe('GET /api/teams/:teamId/invitations', () => {
|
||||
|
||||
const res = await app.request(`/api/teams/${orgId}/invitations`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { invitations: unknown[] }
|
||||
expect(body.invitations).toEqual([])
|
||||
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
|
||||
expect(body.items).toEqual([])
|
||||
expect(body.total).toBe(0)
|
||||
expect(body.page).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+39
-35
@@ -1,4 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { ErrorReason, pageQuerySchema, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import type { ActivityEventWithUser, InviteLinkInfo, PendingInvitation } from '../usecases/ports'
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
toEntitlementResultDTO,
|
||||
toQuotaEntitlementDTO,
|
||||
} from './entitlements'
|
||||
import { errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
|
||||
const inviteLinkInfoSchema = z
|
||||
.object({
|
||||
@@ -54,6 +55,8 @@ function toPendingInvitationDTO(p: PendingInvitation): z.infer<typeof pendingInv
|
||||
return { ...p, expiresAt: p.expiresAt ? p.expiresAt.toISOString() : null, createdAt: p.createdAt.toISOString() }
|
||||
}
|
||||
|
||||
const pendingInvitationListSchema = pageSchema(pendingInvitationSchema, 'TeamInvitationList')
|
||||
|
||||
const activityEventSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -73,14 +76,7 @@ function toActivityEventDTO(e: ActivityEventWithUser): z.infer<typeof activityEv
|
||||
return { ...e, createdAt: e.createdAt.toISOString() }
|
||||
}
|
||||
|
||||
const activityPageSchema = z
|
||||
.object({
|
||||
items: z.array(activityEventSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi('ActivityPage')
|
||||
const activityPageSchema = pageSchema(activityEventSchema, 'ActivityPage')
|
||||
|
||||
const teamSummarySchema = z
|
||||
.object({
|
||||
@@ -96,7 +92,7 @@ const teamSummarySchema = z
|
||||
})
|
||||
.openapi('TeamSummary')
|
||||
|
||||
const teamListSchema = z.object({ items: z.array(teamSummarySchema), total: z.number().int() }).openapi('TeamList')
|
||||
const teamListSchema = pageSchema(teamSummarySchema, 'TeamList')
|
||||
|
||||
const createLinkSchema = z.object({
|
||||
role: z.enum(['editor', 'viewer']).default('viewer'),
|
||||
@@ -134,7 +130,7 @@ const inviteLinkInfoRoute = createRoute({
|
||||
|
||||
export const publicTeams = new OpenAPIHono<Env>().openapi(inviteLinkInfoRoute, async (c) => {
|
||||
const info = await getInviteLinkInfo(c.get('deps'), c.req.valid('param').token)
|
||||
if (!info) return c.json({ error: 'Invalid or expired invite link' }, 404)
|
||||
if (!info) return apiError(c, 404, 'Invalid or expired invite link')
|
||||
return c.json(toInviteLinkInfoDTO(info), 200)
|
||||
})
|
||||
|
||||
@@ -160,7 +156,7 @@ const listInvitationsRoute = createRoute({
|
||||
path: '/{teamId}/invitations',
|
||||
request: { params: z.object({ teamId: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(z.object({ invitations: z.array(pendingInvitationSchema) }), 'Pending invitations'),
|
||||
200: jsonContent(pendingInvitationListSchema, 'Pending invitations'),
|
||||
403: errorResponse('Forbidden'),
|
||||
},
|
||||
})
|
||||
@@ -188,7 +184,7 @@ const activityRoute = createRoute({
|
||||
path: '/{teamId}/activity',
|
||||
request: {
|
||||
params: z.object({ teamId: z.string() }),
|
||||
query: z.object({ page: z.string().optional(), pageSize: z.string().optional() }),
|
||||
query: pageQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
200: jsonContent(activityPageSchema, 'Activity'),
|
||||
@@ -240,7 +236,7 @@ export const teams = teamsApp
|
||||
role,
|
||||
expiresIn,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'Forbidden' }, 403)
|
||||
if (!result.ok) return apiError(c, 403, 'Forbidden')
|
||||
return c.json({ token: result.token, expiresAt: result.expiresAt.toISOString() }, 201)
|
||||
})
|
||||
.openapi(listInvitationsRoute, async (c) => {
|
||||
@@ -248,8 +244,9 @@ export const teams = teamsApp
|
||||
teamId: c.req.valid('param').teamId,
|
||||
userId: c.get('userId')!,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'Forbidden' }, 403)
|
||||
return c.json({ invitations: result.invitations.map(toPendingInvitationDTO) }, 200)
|
||||
if (!result.ok) return apiError(c, 403, 'Forbidden')
|
||||
const items = result.invitations.map(toPendingInvitationDTO)
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(joinTeamRoute, async (c) => {
|
||||
const result = await joinTeam(c.get('deps'), {
|
||||
@@ -258,21 +255,19 @@ export const teams = teamsApp
|
||||
token: c.req.valid('json').token,
|
||||
})
|
||||
if (result.ok) return c.json({ ok: true as const }, 200)
|
||||
if (result.reason === 'invalid') return c.json({ error: 'Invalid invite link' }, 404)
|
||||
if (result.reason === 'expired') return c.json({ error: 'Invite link has expired' }, 410)
|
||||
return c.json({ error: 'Already a member of this team' }, 409)
|
||||
if (result.reason === 'invalid') return apiError(c, 404, 'Invalid invite link')
|
||||
if (result.reason === 'expired') return apiError(c, 410, 'Invite link has expired')
|
||||
return apiError(c, 409, 'Already a member of this team')
|
||||
})
|
||||
.openapi(activityRoute, async (c) => {
|
||||
const { page: pageStr, pageSize: pageSizeStr } = c.req.valid('query')
|
||||
const page = Number(pageStr ?? '1')
|
||||
const pageSize = Number(pageSizeStr ?? '20')
|
||||
const { page, pageSize } = c.req.valid('query')
|
||||
const result = await listActivity(c.get('deps'), {
|
||||
teamId: c.req.valid('param').teamId,
|
||||
userId: c.get('userId')!,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'Forbidden' }, 403)
|
||||
if (!result.ok) return apiError(c, 403, 'Forbidden')
|
||||
return c.json(
|
||||
{ items: result.result.items.map(toActivityEventDTO), total: result.result.total, page, pageSize },
|
||||
200,
|
||||
@@ -281,9 +276,12 @@ export const teams = teamsApp
|
||||
.openapi(setLogoRoute, async (c) => {
|
||||
const teamId = c.req.valid('param').teamId
|
||||
const form = await c.req.formData().catch(() => null)
|
||||
if (!form) return c.json({ error: 'Expected multipart/form-data with a file field' }, 415)
|
||||
if (!form)
|
||||
return apiError(c, 415, 'Expected multipart/form-data with a file field', {
|
||||
reason: ErrorReason.UNSUPPORTED_MEDIA_TYPE,
|
||||
})
|
||||
const file = form.get('file')
|
||||
if (!(file instanceof File)) return c.json({ error: 'file field is required' }, 400)
|
||||
if (!(file instanceof File)) return apiError(c, 400, 'file field is required')
|
||||
|
||||
const result = await setTeamLogo(c.get('deps'), {
|
||||
platform: c.get('platform'),
|
||||
@@ -292,8 +290,10 @@ export const teams = teamsApp
|
||||
file,
|
||||
})
|
||||
if (result.ok) return c.json({ url: result.url }, 200)
|
||||
if (result.reason === 'forbidden') return c.json({ error: 'Forbidden' }, 403)
|
||||
return c.json({ error: result.error }, result.status)
|
||||
if (result.reason === 'forbidden') return apiError(c, 403, 'Forbidden')
|
||||
if (result.status === 413) return apiError(c, 413, result.error, { reason: ErrorReason.PAYLOAD_TOO_LARGE })
|
||||
if (result.status === 503) return apiError(c, 503, result.error, { reason: ErrorReason.NO_STORAGE_CONFIGURED })
|
||||
return apiError(c, 400, result.error)
|
||||
})
|
||||
.openapi(deleteLogoRoute, async (c) => {
|
||||
const result = await deleteTeamLogo(c.get('deps'), {
|
||||
@@ -301,7 +301,7 @@ export const teams = teamsApp
|
||||
teamId: c.req.valid('param').teamId,
|
||||
userId: c.get('userId') as string,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'Forbidden' }, 403)
|
||||
if (!result.ok) return apiError(c, 403, 'Forbidden')
|
||||
return c.json({ ok: true as const }, 200)
|
||||
})
|
||||
|
||||
@@ -391,16 +391,20 @@ const revokeEntitlementRoute = createRoute({
|
||||
})
|
||||
|
||||
export const adminTeams = new OpenAPIHono<Env>()
|
||||
.openapi(listTeamsRoute, async (c) => c.json(await listTeams(c.get('deps')), 200))
|
||||
.openapi(listTeamsRoute, async (c) => {
|
||||
const { items } = await listTeams(c.get('deps'))
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(getTeamRoute, async (c) => {
|
||||
const team = await getTeam(c.get('deps'), c.req.valid('param').teamId)
|
||||
if (!team) return c.json({ error: 'Team not found' }, 404)
|
||||
if (!team) return apiError(c, 404, 'Team not found')
|
||||
return c.json(team, 200)
|
||||
})
|
||||
.openapi(listEntitlementsRoute, async (c) => {
|
||||
const result = await listTeamEntitlements(c.get('deps'), c.req.valid('param').teamId)
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
return c.json({ orgId: result.result.orgId, items: result.result.items.map(toQuotaEntitlementDTO) }, 200)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
const items = result.result.items.map(toQuotaEntitlementDTO)
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(grantEntitlementRoute, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
@@ -413,7 +417,7 @@ export const adminTeams = new OpenAPIHono<Env>()
|
||||
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
||||
note: body.note,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(toEntitlementResultDTO(result.result), 201)
|
||||
})
|
||||
.openapi(updateEntitlementRoute, async (c) => {
|
||||
@@ -427,7 +431,7 @@ export const adminTeams = new OpenAPIHono<Env>()
|
||||
expiresAt: 'expiresAt' in body ? (body.expiresAt ? new Date(body.expiresAt) : null) : undefined,
|
||||
note: body.note,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(toEntitlementResultDTO(result.result), 200)
|
||||
})
|
||||
.openapi(revokeEntitlementRoute, async (c) => {
|
||||
@@ -437,6 +441,6 @@ export const adminTeams = new OpenAPIHono<Env>()
|
||||
targetOrgId: c.req.valid('param').teamId,
|
||||
entitlementId: c.req.valid('param').eid,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(toEntitlementResultDTO(result.result), 200)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { requireAuth, requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { emptyTrash } from '../usecases/trash'
|
||||
import { errorResponse, jsonContent } from './openapi'
|
||||
import { apiError, errorResponse, jsonContent } from './openapi'
|
||||
|
||||
const emptyTrashRoute = createRoute({
|
||||
operationId: 'emptyTrash',
|
||||
@@ -22,7 +22,7 @@ app.use(requireAuth)
|
||||
|
||||
const trash = app.openapi(emptyTrashRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'No active organization' }, 400)
|
||||
if (!orgId) return apiError(c, 400, 'No active organization')
|
||||
const result = await emptyTrash(c.get('deps'), { orgId, userId: c.get('userId')! })
|
||||
return c.json({ purged: result.purged }, 200)
|
||||
})
|
||||
|
||||
@@ -289,8 +289,8 @@ describe('Admin Users API', () => {
|
||||
// Banned user's existing session should be rejected with 403
|
||||
const res = await app.request('/api/quotas/me', { headers: userHeaders })
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Account disabled')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Account disabled')
|
||||
})
|
||||
|
||||
it('DELETE /api/users/:id returns 404 for missing user', async () => {
|
||||
@@ -546,7 +546,8 @@ describe('Admin Users API', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'Only admin-granted entitlements can be modified' })
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Only admin-granted entitlements can be modified')
|
||||
})
|
||||
|
||||
it('PATCH /api/users/:id/entitlements/:eid rejects non-admin-grant sources', async () => {
|
||||
@@ -569,7 +570,8 @@ describe('Admin Users API', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'Only admin-granted entitlements can be modified' })
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Only admin-granted entitlements can be modified')
|
||||
})
|
||||
|
||||
it('POST /api/users/:id/entitlements rejects traffic grants', async () => {
|
||||
@@ -601,7 +603,8 @@ describe('Admin Users API', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(await res.json()).toEqual({ error: `Personal organization not found for user: ${userId}` })
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe(`Personal organization not found for user: ${userId}`)
|
||||
})
|
||||
|
||||
it('DELETE /api/users deletes selected users', async () => {
|
||||
@@ -635,7 +638,8 @@ describe('Admin Users API', () => {
|
||||
body: JSON.stringify({ action: 'disable', ids: ['missing-user'] }),
|
||||
})
|
||||
expect(patch.status).toBe(404)
|
||||
expect(await patch.json()).toEqual({ error: 'User not found: missing-user' })
|
||||
const patchBody = (await patch.json()) as { error: { message: string } }
|
||||
expect(patchBody.error.message).toBe('User not found: missing-user')
|
||||
|
||||
const del = await app.request('/api/users', {
|
||||
method: 'DELETE',
|
||||
@@ -643,7 +647,8 @@ describe('Admin Users API', () => {
|
||||
body: JSON.stringify({ ids: ['missing-user'] }),
|
||||
})
|
||||
expect(del.status).toBe(404)
|
||||
expect(await del.json()).toEqual({ error: 'User not found: missing-user' })
|
||||
const delBody = (await del.json()) as { error: { message: string } }
|
||||
expect(delBody.error.message).toBe('User not found: missing-user')
|
||||
})
|
||||
|
||||
it('POST /api/users/:id/entitlements rejects non-positive bytes', async () => {
|
||||
@@ -839,8 +844,8 @@ describe('GET /api/users/:username', () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/users/nonexistent')
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({ error: 'User not found' })
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('User not found')
|
||||
})
|
||||
|
||||
it('returns user info and empty shares [spec: profile/user-info]', async () => {
|
||||
@@ -883,8 +888,8 @@ describe('GET /api/users/:username/objects', () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/users/nonexistent/objects')
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({ error: 'User not found' })
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('User not found')
|
||||
})
|
||||
|
||||
it('returns empty items and breadcrumb for known user [spec: profile/empty-listing]', async () => {
|
||||
|
||||
+29
-29
@@ -1,4 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { pageQuerySchema, pageSchema } from '@shared/schemas'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import type { UserWithOrg } from '../usecases/ports'
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
toEntitlementResultDTO,
|
||||
toQuotaEntitlementDTO,
|
||||
} from './entitlements'
|
||||
import { errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
import { apiError, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
|
||||
const userSchema = z
|
||||
.object({
|
||||
@@ -47,7 +48,7 @@ function toUserDTO(u: UserWithOrg): z.infer<typeof userSchema> {
|
||||
return { ...u, createdAt: u.createdAt.toISOString() }
|
||||
}
|
||||
|
||||
const userListSchema = z.object({ items: z.array(userSchema), total: z.number().int() }).openapi('UserList')
|
||||
const userListSchema = pageSchema(userSchema, 'UserList')
|
||||
|
||||
const publicUserSchema = z
|
||||
.object({ username: z.string(), name: z.string(), image: z.string().nullable() })
|
||||
@@ -119,7 +120,7 @@ const listUsersRoute = createRoute({
|
||||
path: '/',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: {
|
||||
query: z.object({ page: z.string().optional(), pageSize: z.string().optional(), search: z.string().optional() }),
|
||||
query: pageQuerySchema.extend({ search: z.string().optional() }),
|
||||
},
|
||||
responses: { 200: jsonContent(userListSchema, 'Users') },
|
||||
})
|
||||
@@ -272,15 +273,15 @@ const revokeUserEntitlementRoute = createRoute({
|
||||
export const users = new OpenAPIHono<Env>()
|
||||
.openapi(setAvatarRoute, async (c) => {
|
||||
const form = await c.req.formData().catch(() => null)
|
||||
if (!form) return c.json({ error: 'Expected multipart/form-data with a file field' }, 415)
|
||||
if (!form) return apiError(c, 415, 'Expected multipart/form-data with a file field')
|
||||
const file = form.get('file')
|
||||
if (!(file instanceof File)) return c.json({ error: 'file field is required' }, 400)
|
||||
if (!(file instanceof File)) return apiError(c, 400, 'file field is required')
|
||||
const result = await updateAvatar(c.get('deps'), {
|
||||
platform: c.get('platform'),
|
||||
userId: c.get('userId') as string,
|
||||
file,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.error }, result.status)
|
||||
if (!result.ok) return apiError(c, result.status, result.error)
|
||||
return c.json({ url: result.url }, 200)
|
||||
})
|
||||
.openapi(deleteAvatarRoute, async (c) => {
|
||||
@@ -288,11 +289,9 @@ export const users = new OpenAPIHono<Env>()
|
||||
return c.json({ ok: true as const }, 200)
|
||||
})
|
||||
.openapi(listUsersRoute, async (c) => {
|
||||
const page = Math.max(1, Number(c.req.query('page') ?? '1'))
|
||||
const pageSize = Math.min(100, Math.max(1, Number(c.req.query('pageSize') ?? '20')))
|
||||
const search = c.req.query('search')
|
||||
const { page, pageSize, search } = c.req.valid('query')
|
||||
const result = await listUsers(c.get('deps'), { page, pageSize, search })
|
||||
return c.json({ items: result.items.map(toUserDTO), total: result.total }, 200)
|
||||
return c.json({ items: result.items.map(toUserDTO), total: result.total, page, pageSize }, 200)
|
||||
})
|
||||
.openapi(batchStatusRoute, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
@@ -302,36 +301,36 @@ export const users = new OpenAPIHono<Env>()
|
||||
ids: body.ids,
|
||||
status: body.action === 'disable' ? 'disabled' : 'active',
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json({ ...result.result, status: result.status }, 200)
|
||||
})
|
||||
.openapi(batchDeleteRoute, async (c) => {
|
||||
const { ids } = c.req.valid('json')
|
||||
const result = await deleteUsers(c.get('deps'), { adminUserId: c.get('userId')!, orgId: c.get('orgId')!, ids })
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(result.result, 200)
|
||||
})
|
||||
.openapi(getUserRoute, async (c) => {
|
||||
const username = c.req.valid('param').username
|
||||
if (c.get('userRole') === 'admin') {
|
||||
const id = await resolveUserId(c.get('deps'), username)
|
||||
if (!id) return c.json({ error: 'User not found' }, 404)
|
||||
if (!id) return apiError(c, 404, 'User not found')
|
||||
const result = await getUser(c.get('deps'), id)
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(toUserDTO(result.user), 200)
|
||||
}
|
||||
const user = await getPublicProfile(c.get('deps'), username)
|
||||
if (!user) return c.json({ error: 'User not found' }, 404)
|
||||
if (!user) return apiError(c, 404, 'User not found')
|
||||
return c.json({ user, shares: [] }, 200)
|
||||
})
|
||||
.openapi(userObjectsRoute, async (c) => {
|
||||
const user = await getPublicProfile(c.get('deps'), c.req.valid('param').username)
|
||||
if (!user) return c.json({ error: 'User not found' }, 404)
|
||||
if (!user) return apiError(c, 404, 'User not found')
|
||||
return c.json({ items: [], breadcrumb: [] }, 200)
|
||||
})
|
||||
.openapi(setUserStatusRoute, async (c) => {
|
||||
const id = await resolveUserId(c.get('deps'), c.req.valid('param').username)
|
||||
if (!id) return c.json({ error: 'User not found' }, 404)
|
||||
if (!id) return apiError(c, 404, 'User not found')
|
||||
const { status } = c.req.valid('json')
|
||||
const result = await setUserStatus(c.get('deps'), {
|
||||
adminUserId: c.get('userId')!,
|
||||
@@ -339,30 +338,31 @@ export const users = new OpenAPIHono<Env>()
|
||||
userId: id,
|
||||
status,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'User not found' }, 404)
|
||||
if (!result.ok) return apiError(c, 404, 'User not found')
|
||||
return c.json({ id, status }, 200)
|
||||
})
|
||||
.openapi(deleteUserRoute, async (c) => {
|
||||
const id = await resolveUserId(c.get('deps'), c.req.valid('param').username)
|
||||
if (!id) return c.json({ error: 'User not found' }, 404)
|
||||
if (!id) return apiError(c, 404, 'User not found')
|
||||
const result = await deleteUser(c.get('deps'), {
|
||||
adminUserId: c.get('userId')!,
|
||||
orgId: c.get('orgId')!,
|
||||
userId: id,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: 'User not found' }, 404)
|
||||
if (!result.ok) return apiError(c, 404, 'User not found')
|
||||
return c.json({ id, deleted: true as const }, 200)
|
||||
})
|
||||
.openapi(listUserEntitlementsRoute, async (c) => {
|
||||
const id = await resolveUserId(c.get('deps'), c.req.valid('param').username)
|
||||
if (!id) return c.json({ error: 'User not found' }, 404)
|
||||
if (!id) return apiError(c, 404, 'User not found')
|
||||
const result = await listUserEntitlements(c.get('deps'), id)
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
return c.json({ orgId: result.result.orgId, items: result.result.items.map(toQuotaEntitlementDTO) }, 200)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
const items = result.result.items.map(toQuotaEntitlementDTO)
|
||||
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
||||
})
|
||||
.openapi(grantUserEntitlementRoute, async (c) => {
|
||||
const id = await resolveUserId(c.get('deps'), c.req.valid('param').username)
|
||||
if (!id) return c.json({ error: 'User not found' }, 404)
|
||||
if (!id) return apiError(c, 404, 'User not found')
|
||||
const body = c.req.valid('json')
|
||||
const result = await grantUserEntitlement(c.get('deps'), {
|
||||
adminUserId: c.get('userId')!,
|
||||
@@ -373,12 +373,12 @@ export const users = new OpenAPIHono<Env>()
|
||||
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
||||
note: body.note,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(toEntitlementResultDTO(result.result), 201)
|
||||
})
|
||||
.openapi(updateUserEntitlementRoute, async (c) => {
|
||||
const id = await resolveUserId(c.get('deps'), c.req.valid('param').username)
|
||||
if (!id) return c.json({ error: 'User not found' }, 404)
|
||||
if (!id) return apiError(c, 404, 'User not found')
|
||||
const body = c.req.valid('json')
|
||||
const result = await updateUserEntitlement(c.get('deps'), {
|
||||
adminUserId: c.get('userId')!,
|
||||
@@ -389,18 +389,18 @@ export const users = new OpenAPIHono<Env>()
|
||||
expiresAt: 'expiresAt' in body ? (body.expiresAt ? new Date(body.expiresAt) : null) : undefined,
|
||||
note: body.note,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(toEntitlementResultDTO(result.result), 200)
|
||||
})
|
||||
.openapi(revokeUserEntitlementRoute, async (c) => {
|
||||
const id = await resolveUserId(c.get('deps'), c.req.valid('param').username)
|
||||
if (!id) return c.json({ error: 'User not found' }, 404)
|
||||
if (!id) return apiError(c, 404, 'User not found')
|
||||
const result = await revokeUserEntitlement(c.get('deps'), {
|
||||
adminUserId: c.get('userId')!,
|
||||
adminOrgId: c.get('orgId')!,
|
||||
targetUserId: id,
|
||||
entitlementId: c.req.valid('param').eid,
|
||||
})
|
||||
if (!result.ok) return c.json({ error: result.failure.error }, result.failure.status)
|
||||
if (!result.ok) return apiError(c, result.failure.status, result.failure.error)
|
||||
return c.json(toEntitlementResultDTO(result.result), 200)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ErrorReason } from '@shared/schemas'
|
||||
import type { Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { ApiKeyTemplate } from '../../shared/api-key-templates'
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
resolveWebDavDownload,
|
||||
resolveWebDavPath,
|
||||
} from '../usecases/webdav'
|
||||
import { apiError } from './openapi'
|
||||
|
||||
const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD'])
|
||||
const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'PROPPATCH', 'LOCK', 'UNLOCK'])
|
||||
@@ -804,7 +806,10 @@ async function reserveWebDavTraffic(
|
||||
})
|
||||
if (outcome.ok) return null
|
||||
if (outcome.reason === 'quota_exceeded') return c.text('Traffic quota exceeded', 422)
|
||||
return c.json({ error: 'insufficient_credits', code: 'insufficient_credits', resource: 'storage_egress' }, 402)
|
||||
return apiError(c, 402, 'Insufficient credits', {
|
||||
reason: ErrorReason.INSUFFICIENT_CREDITS,
|
||||
metadata: { resource: 'storage_egress' },
|
||||
})
|
||||
}
|
||||
|
||||
async function putFile(c: DavContext, auth: DavAuth): Promise<Response> {
|
||||
|
||||
@@ -1,22 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NameConflictError, StorageQuotaExceededError, WebDavPathError } from '../usecases/ports'
|
||||
import { mapDomainError } from './http-errors'
|
||||
import {
|
||||
BackgroundJobError,
|
||||
DownloadError,
|
||||
NameConflictError,
|
||||
ObjectUploadSessionError,
|
||||
StorageQuotaExceededError,
|
||||
WebDavPathError,
|
||||
} from '../usecases/ports'
|
||||
import { ApiError, buildErrorBody, mapDomainError } from './http-errors'
|
||||
|
||||
describe('buildErrorBody', () => {
|
||||
it('defaults reason and canonical status from the HTTP code', () => {
|
||||
const body = buildErrorBody(404, 'Not found')
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 404,
|
||||
message: 'Not found',
|
||||
status: 'NOT_FOUND',
|
||||
details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason: 'NOT_FOUND', domain: 'zpan.dev' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to INTERNAL for unmapped 5xx and UNKNOWN for unmapped 4xx', () => {
|
||||
expect(buildErrorBody(599, 'x').error.status).toBe('INTERNAL')
|
||||
expect(buildErrorBody(418, 'x').error.status).toBe('UNKNOWN')
|
||||
})
|
||||
|
||||
it('honors explicit reason, canonical status, metadata, and domain overrides', () => {
|
||||
const body = buildErrorBody(422, 'Quota exceeded', {
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
metadata: { resource: 'storage_egress' },
|
||||
domain: 'custom.example',
|
||||
})
|
||||
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
expect(body.error.details?.[0]).toEqual({
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
domain: 'custom.example',
|
||||
metadata: { resource: 'storage_egress' },
|
||||
})
|
||||
})
|
||||
|
||||
it('omits metadata when none is given', () => {
|
||||
expect(buildErrorBody(403, 'Forbidden').error.details?.[0]?.metadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('renders its AIP-193 body and preserves the message', () => {
|
||||
const err = new ApiError(402, 'Insufficient credits', {
|
||||
reason: 'INSUFFICIENT_CREDITS',
|
||||
metadata: { resource: 'storage_egress' },
|
||||
})
|
||||
expect(err.message).toBe('Insufficient credits')
|
||||
expect(err.toBody()).toEqual({
|
||||
error: {
|
||||
code: 402,
|
||||
message: 'Insufficient credits',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'INSUFFICIENT_CREDITS',
|
||||
domain: 'zpan.dev',
|
||||
metadata: { resource: 'storage_egress' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapDomainError', () => {
|
||||
it('maps StorageQuotaExceededError to 422', () => {
|
||||
const reasonOf = (m: ReturnType<typeof mapDomainError>) => m?.json.error.details?.[0]?.reason
|
||||
|
||||
it('maps StorageQuotaExceededError to 422 / RESOURCE_EXHAUSTED', () => {
|
||||
const m = mapDomainError(new StorageQuotaExceededError())
|
||||
expect(m).toEqual({ status: 422, message: 'Quota exceeded', json: { error: 'Quota exceeded' } })
|
||||
expect(m?.status).toBe(422)
|
||||
expect(m?.message).toBe('Quota exceeded')
|
||||
expect(m?.json.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
expect(reasonOf(m)).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
|
||||
it('maps NameConflictError to 409 with conflict metadata', () => {
|
||||
it('maps NameConflictError to 409 / ALREADY_EXISTS with conflict metadata', () => {
|
||||
const m = mapDomainError(new NameConflictError('doc.txt', 'id-1'))
|
||||
expect(m?.status).toBe(409)
|
||||
expect(m?.json).toMatchObject({ code: 'NAME_CONFLICT', conflictingName: 'doc.txt', conflictingId: 'id-1' })
|
||||
expect(m?.json.error.status).toBe('ALREADY_EXISTS')
|
||||
expect(reasonOf(m)).toBe('NAME_CONFLICT')
|
||||
expect(m?.json.error.details?.[0]?.metadata).toEqual({ conflictingName: 'doc.txt', conflictingId: 'id-1' })
|
||||
})
|
||||
|
||||
it('maps WebDavPathError to its own status', () => {
|
||||
it('omits conflictingId metadata when it is empty', () => {
|
||||
const m = mapDomainError(new NameConflictError('doc.txt', ''))
|
||||
expect(m?.json.error.details?.[0]?.metadata).toEqual({ conflictingName: 'doc.txt' })
|
||||
})
|
||||
|
||||
it('maps ObjectUploadSessionError by code', () => {
|
||||
expect(mapDomainError(new ObjectUploadSessionError('storage_failure', 'boom'))?.status).toBe(502)
|
||||
expect(reasonOf(mapDomainError(new ObjectUploadSessionError('storage_failure', 'boom')))).toBe('STORAGE_FAILURE')
|
||||
expect(mapDomainError(new ObjectUploadSessionError('not_found'))?.status).toBe(404)
|
||||
const invalid = mapDomainError(new ObjectUploadSessionError('invalid_state'))
|
||||
expect(invalid?.status).toBe(409)
|
||||
expect(reasonOf(invalid)).toBe('INVALID_STATE')
|
||||
})
|
||||
|
||||
it('maps WebDavPathError to its own status with the canonical default reason', () => {
|
||||
const m = mapDomainError(new WebDavPathError('Bad path', 409))
|
||||
expect(m).toEqual({ status: 409, message: 'Bad path', json: { error: 'Bad path' } })
|
||||
expect(m?.status).toBe(409)
|
||||
expect(m?.message).toBe('Bad path')
|
||||
expect(m?.json.error.status).toBe('ABORTED')
|
||||
expect(reasonOf(m)).toBe('ABORTED')
|
||||
})
|
||||
|
||||
it('maps DownloadError by code with an UPPER_SNAKE reason', () => {
|
||||
expect(mapDomainError(new DownloadError('not_found'))?.status).toBe(404)
|
||||
expect(mapDomainError(new DownloadError('forbidden'))?.status).toBe(403)
|
||||
const other = mapDomainError(new DownloadError('invalid_state', 'Task is paused'))
|
||||
expect(other?.status).toBe(409)
|
||||
expect(other?.message).toBe('Task is paused')
|
||||
expect(reasonOf(other)).toBe('INVALID_STATE')
|
||||
})
|
||||
|
||||
it('maps BackgroundJobError by code', () => {
|
||||
expect(reasonOf(mapDomainError(new BackgroundJobError('not_cancelable')))).toBe('NOT_CANCELABLE')
|
||||
expect(reasonOf(mapDomainError(new BackgroundJobError('not_retryable')))).toBe('NOT_RETRYABLE')
|
||||
expect(mapDomainError(new BackgroundJobError('not_found'))?.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns null for unrecognized errors', () => {
|
||||
|
||||
+86
-34
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
type CanonicalStatus,
|
||||
canonicalStatusForHttp,
|
||||
ERROR_DOMAIN,
|
||||
ERROR_INFO_TYPE,
|
||||
ErrorReason,
|
||||
type ErrorResponse,
|
||||
} from '@shared/schemas'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import {
|
||||
BackgroundJobError,
|
||||
@@ -8,66 +16,110 @@ import {
|
||||
WebDavPathError,
|
||||
} from '../usecases/ports'
|
||||
|
||||
// Per-error overrides for the AIP-193 body. `reason` defaults to the canonical
|
||||
// `status`; `status` defaults to the HTTP-status mapping; `domain` to zpan.dev.
|
||||
export interface ErrorOptions {
|
||||
reason?: string
|
||||
status?: CanonicalStatus
|
||||
metadata?: Record<string, string>
|
||||
domain?: string
|
||||
}
|
||||
|
||||
// The single place that builds an AIP-193 (`google.rpc.Status`) error body. Every
|
||||
// error the API surfaces — thrown domain errors mapped in `onError`, and inline
|
||||
// handler rejections via `apiError` — flows through here, so the wire shape is
|
||||
// defined exactly once.
|
||||
export function buildErrorBody(httpStatus: number, message: string, opts: ErrorOptions = {}): ErrorResponse {
|
||||
const status = opts.status ?? canonicalStatusForHttp(httpStatus)
|
||||
const reason = opts.reason ?? status
|
||||
return {
|
||||
error: {
|
||||
code: httpStatus,
|
||||
message,
|
||||
status,
|
||||
details: [
|
||||
{
|
||||
'@type': ERROR_INFO_TYPE,
|
||||
reason,
|
||||
domain: opts.domain ?? ERROR_DOMAIN,
|
||||
...(opts.metadata ? { metadata: opts.metadata } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A throwable carrying everything needed to render an AIP-193 body. Handlers and
|
||||
// usecases can `throw new ApiError(...)`; `onError` renders it. Inline handler
|
||||
// sites that prefer `return` use the `apiError` helper instead (see http/openapi).
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly httpStatus: ContentfulStatusCode,
|
||||
message: string,
|
||||
readonly options: ErrorOptions = {},
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
|
||||
toBody(): ErrorResponse {
|
||||
return buildErrorBody(this.httpStatus, this.message, this.options)
|
||||
}
|
||||
}
|
||||
|
||||
export interface DomainErrorMapping {
|
||||
status: ContentfulStatusCode
|
||||
/** Plain message for text responses (e.g. WebDAV). */
|
||||
message: string
|
||||
/** Structured body for JSON responses. */
|
||||
json: Record<string, unknown>
|
||||
/** AIP-193 body for JSON responses. */
|
||||
json: ErrorResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* The single place that translates a domain error into its HTTP status and
|
||||
* response body. Every error a usecase throws and the API surfaces flows through
|
||||
* here — wired into the global `app.onError`, so handlers `throw` instead of
|
||||
* hand-rolling per-route try/catch. Returns null for errors we don't translate;
|
||||
* `onError` then falls back to a generic 500.
|
||||
*
|
||||
* To support a new domain error: add a branch here, nowhere else.
|
||||
*/
|
||||
const mapping = (status: ContentfulStatusCode, message: string, opts?: ErrorOptions): DomainErrorMapping => ({
|
||||
status,
|
||||
message,
|
||||
json: buildErrorBody(status, message, opts),
|
||||
})
|
||||
|
||||
// Translate a domain error a usecase threw into its HTTP status + AIP-193 body.
|
||||
// Wired into the global `app.onError`, so handlers `throw` instead of hand-rolling
|
||||
// per-route try/catch. Returns null for errors we don't translate; `onError` then
|
||||
// falls back to a generic 500. To support a new domain error: add a branch here.
|
||||
export function mapDomainError(error: unknown): DomainErrorMapping | null {
|
||||
if (error instanceof StorageQuotaExceededError) {
|
||||
return { status: 422, message: 'Quota exceeded', json: { error: 'Quota exceeded' } }
|
||||
return mapping(422, 'Quota exceeded', { reason: ErrorReason.QUOTA_EXCEEDED, status: 'RESOURCE_EXHAUSTED' })
|
||||
}
|
||||
if (error instanceof NameConflictError) {
|
||||
return {
|
||||
status: 409,
|
||||
message: error.message,
|
||||
json: {
|
||||
error: error.message,
|
||||
code: 'NAME_CONFLICT',
|
||||
conflictingName: error.conflictingName,
|
||||
conflictingId: error.conflictingId,
|
||||
},
|
||||
}
|
||||
const metadata: Record<string, string> = { conflictingName: error.conflictingName }
|
||||
if (error.conflictingId) metadata.conflictingId = error.conflictingId
|
||||
return mapping(409, error.message, { reason: ErrorReason.NAME_CONFLICT, status: 'ALREADY_EXISTS', metadata })
|
||||
}
|
||||
if (error instanceof ObjectUploadSessionError) {
|
||||
if (error.code === 'storage_failure') {
|
||||
return { status: 502, message: error.message, json: { error: error.message } }
|
||||
return mapping(502, error.message, { reason: 'STORAGE_FAILURE' })
|
||||
}
|
||||
if (error.code === 'not_found') {
|
||||
return { status: 404, message: 'Not found', json: { error: 'Not found' } }
|
||||
return mapping(404, 'Not found')
|
||||
}
|
||||
return { status: 409, message: 'Invalid upload session state', json: { error: 'Invalid upload session state' } }
|
||||
return mapping(409, 'Invalid upload session state', { reason: 'INVALID_STATE' })
|
||||
}
|
||||
if (error instanceof WebDavPathError) {
|
||||
return { status: error.status as ContentfulStatusCode, message: error.message, json: { error: error.message } }
|
||||
return mapping(error.status as ContentfulStatusCode, error.message)
|
||||
}
|
||||
if (error instanceof DownloadError) {
|
||||
if (error.code === 'not_found') return { status: 404, message: 'Not found', json: { error: 'Not found' } }
|
||||
if (error.code === 'forbidden') return { status: 403, message: 'Forbidden', json: { error: 'Forbidden' } }
|
||||
return { status: 409, message: error.message, json: { error: error.message } }
|
||||
const reason = error.code.toUpperCase()
|
||||
if (error.code === 'not_found') return mapping(404, 'Not found', { reason })
|
||||
if (error.code === 'forbidden') return mapping(403, 'Forbidden', { reason })
|
||||
return mapping(409, error.message, { reason })
|
||||
}
|
||||
if (error instanceof BackgroundJobError) {
|
||||
if (error.code === 'not_cancelable') {
|
||||
const m = 'Background job cannot be canceled'
|
||||
return { status: 409, message: m, json: { error: m } }
|
||||
return mapping(409, 'Background job cannot be canceled', { reason: 'NOT_CANCELABLE' })
|
||||
}
|
||||
if (error.code === 'not_retryable') {
|
||||
const m = 'Background job cannot be retried'
|
||||
return { status: 409, message: m, json: { error: m } }
|
||||
return mapping(409, 'Background job cannot be retried', { reason: 'NOT_RETRYABLE' })
|
||||
}
|
||||
return { status: 404, message: 'Not found', json: { error: 'Not found' } }
|
||||
return mapping(404, 'Not found')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ describe('requireAdmin middleware', () => {
|
||||
await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin')
|
||||
const headers = await authedHeaders(app, 'regular@example.com', 'password123456')
|
||||
const res = await app.request('/api/admin-only', { headers })
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe('Forbidden')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
})
|
||||
|
||||
it('allows request when user has admin role', async () => {
|
||||
@@ -247,8 +247,8 @@ describe('requireTeamRole — team org with viewer role', () => {
|
||||
const updatedCookies = await setActiveOrg(app, cookies, teamOrgId)
|
||||
|
||||
const res = await app.request('/api/test/editor', { method: 'POST', headers: { Cookie: updatedCookies } })
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe('Forbidden')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { apiError } from '../http/openapi'
|
||||
import { ApiKeyRateLimitError } from '../usecases/ports'
|
||||
import type { Env } from './platform'
|
||||
|
||||
@@ -45,7 +46,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
|
||||
apiKey = await deps.apiKeys.verifyApiKey(c.get('auth'), platform.db, token)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiKeyRateLimitError) {
|
||||
const res = c.json({ error: error.message }, 429)
|
||||
const res = apiError(c, 429, error.message)
|
||||
if (error.retryAfterMs !== undefined)
|
||||
res.headers.set('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)))
|
||||
return res
|
||||
@@ -77,7 +78,7 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
|
||||
|
||||
if (result?.user?.id) {
|
||||
if (await c.get('deps').userAdmin.isBanned(result.user.id)) {
|
||||
return c.json({ error: 'Account disabled' }, 403)
|
||||
return apiError(c, 403, 'Account disabled')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,14 +105,14 @@ export const authMiddleware = createMiddleware<Env>(async (c, next) => {
|
||||
|
||||
export const requireDownloader = createMiddleware<Env>(async (c, next) => {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (principal?.kind !== 'downloader') return apiError(c, 401, 'Unauthorized')
|
||||
await next()
|
||||
})
|
||||
|
||||
export const requireAuth = createMiddleware<Env>(async (c, next) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
return apiError(c, 401, 'Unauthorized')
|
||||
}
|
||||
await next()
|
||||
})
|
||||
@@ -119,11 +120,11 @@ export const requireAuth = createMiddleware<Env>(async (c, next) => {
|
||||
export const requireAdmin = createMiddleware<Env>(async (c, next) => {
|
||||
const userId = c.get('userId')
|
||||
if (!userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
return apiError(c, 401, 'Unauthorized')
|
||||
}
|
||||
const userRole = c.get('userRole')
|
||||
if (userRole !== 'admin') {
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
}
|
||||
await next()
|
||||
})
|
||||
@@ -136,7 +137,7 @@ export function requireTeamRole(minRole: 'viewer' | 'editor' | 'owner') {
|
||||
const orgId = c.get('orgId')
|
||||
const userId = c.get('userId')
|
||||
if (!orgId || !userId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
return apiError(c, 401, 'Unauthorized')
|
||||
}
|
||||
|
||||
// Query member role first — avoids an extra DB round trip for the common case.
|
||||
@@ -146,7 +147,7 @@ export function requireTeamRole(minRole: 'viewer' | 'editor' | 'owner') {
|
||||
if (role !== null) {
|
||||
const userLevel = ROLE_LEVELS[role] ?? 0
|
||||
if (userLevel < ROLE_LEVELS[minRole]) {
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
}
|
||||
await next()
|
||||
return
|
||||
@@ -158,6 +159,6 @@ export function requireTeamRole(minRole: 'viewer' | 'editor' | 'owner') {
|
||||
return
|
||||
}
|
||||
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { requirePermission } from './authz.js'
|
||||
|
||||
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
|
||||
type TestApp = TestCtx['app']
|
||||
type TestDb = TestCtx['db']
|
||||
type TestAuth = TestCtx['auth']
|
||||
|
||||
// Mounts the permission-gated probe routes on a real app so requirePermission
|
||||
// runs after the production authMiddleware (which resolves the principal,
|
||||
// userId, orgId, and deps from the request). Each route maps to one guard in
|
||||
// requirePermission; the body is a sentinel proving the middleware called next.
|
||||
function mountProbes(app: TestApp) {
|
||||
app.get('/api/test-authz/api-perm', requirePermission('remoteDownload', 'create'), (c) => c.json({ ok: true }))
|
||||
app.get('/api/test-authz/no-downloader', requirePermission('remoteDownload', 'read'), (c) => c.json({ ok: true }))
|
||||
app.get(
|
||||
'/api/test-authz/team-editor',
|
||||
requirePermission('remoteDownload', 'create', { minTeamRole: 'editor' }),
|
||||
(c) => c.json({ ok: true }),
|
||||
)
|
||||
}
|
||||
|
||||
// Creates an API key via the real better-auth plugin (keys are properly hashed)
|
||||
// scoped to the given permissions. Returns the raw key usable as a Bearer token.
|
||||
async function createApiKey(
|
||||
auth: TestAuth,
|
||||
orgId: string,
|
||||
userId: string,
|
||||
permissions?: Record<string, string[]>,
|
||||
): Promise<string> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
|
||||
const result = (await (auth.api as any).createApiKey({
|
||||
body: {
|
||||
configId: 'ihost',
|
||||
organizationId: orgId,
|
||||
userId,
|
||||
...(permissions ? { permissions } : {}),
|
||||
},
|
||||
})) as { key: string }
|
||||
return result.key
|
||||
}
|
||||
|
||||
async function getOrgId(db: TestDb): Promise<string> {
|
||||
const rows = await db.all<{ id: string }>(sql`
|
||||
SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1
|
||||
`)
|
||||
return rows[0].id
|
||||
}
|
||||
|
||||
async function getUserId(db: TestDb, email: string): Promise<string> {
|
||||
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`)
|
||||
return rows[0].id
|
||||
}
|
||||
|
||||
// Registers a downloader and returns its bearer token. Mirrors the device-login
|
||||
// flow the CLI uses; needed to mint a `downloader` principal.
|
||||
async function registerDownloader(app: TestApp, name: string): Promise<string> {
|
||||
const admin = await adminHeaders(app)
|
||||
const codeRes = await app.request('/api/auth/device/code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: 'zpan-cli', scope: 'downloader:register' }),
|
||||
})
|
||||
const code = (await codeRes.json()) as { device_code: string; user_code: string }
|
||||
// Claim the user code with the admin session before approving (device flow).
|
||||
await app.request(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`, { headers: admin })
|
||||
await app.request('/api/auth/device/approve', {
|
||||
method: 'POST',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userCode: code.user_code }),
|
||||
})
|
||||
const tokenRes = await app.request('/api/auth/device/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
device_code: code.device_code,
|
||||
client_id: 'zpan-cli',
|
||||
}),
|
||||
})
|
||||
const token = (await tokenRes.json()) as { access_token: string }
|
||||
const createRes = await app.request('/api/downloads/downloaders', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.access_token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
heartbeat: {
|
||||
version: '1.0.0',
|
||||
hostname: 'host',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
engine: 'builtin',
|
||||
capabilities: [],
|
||||
maxConcurrentTasks: 1,
|
||||
currentTasks: 0,
|
||||
downloadBps: 0,
|
||||
uploadBps: 0,
|
||||
freeDiskBytes: 0,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const created = (await createRes.json()) as { token: string }
|
||||
return created.token
|
||||
}
|
||||
|
||||
describe('requirePermission middleware', () => {
|
||||
it('returns 401 when there is no principal (unauthenticated)', async () => {
|
||||
const { app } = await createTestApp()
|
||||
mountProbes(app)
|
||||
const res = await app.request('/api/test-authz/api-perm')
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Unauthorized')
|
||||
expect(body.error.status).toBe('UNAUTHENTICATED')
|
||||
})
|
||||
|
||||
it('returns 403 when an api-key principal lacks the required permission', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
mountProbes(app)
|
||||
await authedHeaders(app)
|
||||
const orgId = await getOrgId(db)
|
||||
const userId = await getUserId(db, 'test@example.com')
|
||||
// Key authenticates (valid) but carries only `read`, not the `create` the
|
||||
// probe route demands, so the api-key branch denies with 403.
|
||||
const key = await createApiKey(auth, orgId, userId, { remoteDownload: ['read'] })
|
||||
|
||||
const res = await app.request('/api/test-authz/api-perm', {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
expect(body.error.status).toBe('PERMISSION_DENIED')
|
||||
})
|
||||
|
||||
it('allows an api-key principal that has the required permission', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
mountProbes(app)
|
||||
await authedHeaders(app)
|
||||
const orgId = await getOrgId(db)
|
||||
const userId = await getUserId(db, 'test@example.com')
|
||||
const key = await createApiKey(auth, orgId, userId, { remoteDownload: ['create'] })
|
||||
|
||||
const res = await app.request('/api/test-authz/api-perm', {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 for a downloader principal when allowDownloader is not set', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
mountProbes(app)
|
||||
const downloaderToken = await registerDownloader(app, 'authz-downloader')
|
||||
|
||||
const res = await app.request('/api/test-authz/no-downloader', {
|
||||
headers: { Authorization: `Bearer ${downloaderToken}` },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Unauthorized')
|
||||
expect(body.error.status).toBe('UNAUTHENTICATED')
|
||||
})
|
||||
|
||||
it('returns 403 when a team member role is below the required minTeamRole', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
mountProbes(app)
|
||||
const headers = await authedHeaders(app, 'viewer@example.com')
|
||||
const userId = await getUserId(db, 'viewer@example.com')
|
||||
const teamOrgId = 'team-low-role'
|
||||
await db.run(sql`
|
||||
INSERT INTO organization (id, name, slug, metadata)
|
||||
VALUES (${teamOrgId}, 'Low Role Team', ${teamOrgId}, '{"type":"team"}')
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO member (id, organization_id, user_id, role)
|
||||
VALUES (${`member-${teamOrgId}`}, ${teamOrgId}, ${userId}, 'viewer')
|
||||
`)
|
||||
const setActive = await app.request('/api/auth/organization/set-active', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organizationId: teamOrgId }),
|
||||
})
|
||||
const cookies = setActive.headers.getSetCookie()
|
||||
if (cookies.length > 0) headers.Cookie = cookies.map((c) => c.split(';')[0]).join('; ')
|
||||
|
||||
const res = await app.request('/api/test-authz/team-editor', { headers })
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string; status: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
expect(body.error.status).toBe('PERMISSION_DENIED')
|
||||
})
|
||||
|
||||
it('allows a team member whose role meets the required minTeamRole', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
mountProbes(app)
|
||||
const headers = await authedHeaders(app, 'editor@example.com')
|
||||
const userId = await getUserId(db, 'editor@example.com')
|
||||
const teamOrgId = 'team-ok-role'
|
||||
await db.run(sql`
|
||||
INSERT INTO organization (id, name, slug, metadata)
|
||||
VALUES (${teamOrgId}, 'OK Role Team', ${teamOrgId}, '{"type":"team"}')
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO member (id, organization_id, user_id, role)
|
||||
VALUES (${`member-${teamOrgId}`}, ${teamOrgId}, ${userId}, 'editor')
|
||||
`)
|
||||
const setActive = await app.request('/api/auth/organization/set-active', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organizationId: teamOrgId }),
|
||||
})
|
||||
const cookies = setActive.headers.getSetCookie()
|
||||
if (cookies.length > 0) headers.Cookie = cookies.map((c) => c.split(';')[0]).join('; ')
|
||||
|
||||
const res = await app.request('/api/test-authz/team-editor', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('allows a personal-org user without a member row via the isPersonalOrg fallback', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
mountProbes(app)
|
||||
const headers = await authedHeaders(app, 'personal@example.com')
|
||||
const orgId = await getOrgId(db)
|
||||
// Drop the member row so getMemberRole returns null, forcing the
|
||||
// isPersonalOrg branch (a personal org owner still has full access).
|
||||
await db.run(sql`DELETE FROM member WHERE organization_id = ${orgId}`)
|
||||
|
||||
const res = await app.request('/api/test-authz/team-editor', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 403 for a team org with no member row that is not personal', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
mountProbes(app)
|
||||
const headers = await authedHeaders(app, 'orphan@example.com')
|
||||
const userId = await getUserId(db, 'orphan@example.com')
|
||||
const teamOrgId = 'team-no-member'
|
||||
await db.run(sql`
|
||||
INSERT INTO organization (id, name, slug, metadata)
|
||||
VALUES (${teamOrgId}, 'No Member Team', ${teamOrgId}, '{"type":"team"}')
|
||||
`)
|
||||
// Member row only needed so set-active accepts it; remove it afterwards to
|
||||
// hit the "no member row, not personal" final 403.
|
||||
await db.run(sql`
|
||||
INSERT INTO member (id, organization_id, user_id, role)
|
||||
VALUES (${`member-${teamOrgId}`}, ${teamOrgId}, ${userId}, 'owner')
|
||||
`)
|
||||
const setActive = await app.request('/api/auth/organization/set-active', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organizationId: teamOrgId }),
|
||||
})
|
||||
const cookies = setActive.headers.getSetCookie()
|
||||
if (cookies.length > 0) headers.Cookie = cookies.map((c) => c.split(';')[0]).join('; ')
|
||||
await db.run(sql`DELETE FROM member WHERE organization_id = ${teamOrgId}`)
|
||||
|
||||
const res = await app.request('/api/test-authz/team-editor', { headers })
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('Forbidden')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { apiError } from '../http/openapi'
|
||||
import type { Env } from './platform'
|
||||
|
||||
const ROLE_LEVELS: Record<string, number> = {
|
||||
@@ -15,35 +16,35 @@ export function requirePermission(
|
||||
) {
|
||||
return createMiddleware<Env>(async (c, next) => {
|
||||
const principal = c.get('principal')
|
||||
if (!principal) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!principal) return apiError(c, 401, 'Unauthorized')
|
||||
|
||||
if (principal.kind === 'downloader') {
|
||||
if (opts.allowDownloader) return next()
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
return apiError(c, 401, 'Unauthorized')
|
||||
}
|
||||
|
||||
if (principal.kind === 'download-task-upload') return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (principal.kind === 'download-task-upload') return apiError(c, 401, 'Unauthorized')
|
||||
|
||||
if (principal.kind === 'api-key') {
|
||||
if (!c.get('deps').apiKeys.hasApiKeyPermission(principal.permissions, resource, action)) {
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
const userId = c.get('userId')
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!userId) return apiError(c, 401, 'Unauthorized')
|
||||
if (!opts.minTeamRole) return next()
|
||||
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
if (!orgId) return apiError(c, 401, 'Unauthorized')
|
||||
|
||||
const role = await c.get('deps').org.getMemberRole(orgId, userId)
|
||||
if (role !== null) {
|
||||
if ((ROLE_LEVELS[role] ?? 0) < ROLE_LEVELS[opts.minTeamRole]) return c.json({ error: 'Forbidden' }, 403)
|
||||
if ((ROLE_LEVELS[role] ?? 0) < ROLE_LEVELS[opts.minTeamRole]) return apiError(c, 403, 'Forbidden')
|
||||
return next()
|
||||
}
|
||||
if (await c.get('deps').org.isPersonalOrg(orgId)) return next()
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
return apiError(c, 403, 'Forbidden')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from '../lib/http-errors'
|
||||
import { NameConflictError } from '../usecases/ports'
|
||||
import { isHandledError, renderError } from './error-handler'
|
||||
import type { Env } from './platform'
|
||||
|
||||
// Build a real Context so renderError's c.json / c.set behave as in production.
|
||||
async function ctx(): Promise<Context<Env>> {
|
||||
let captured!: Context<Env>
|
||||
const app = new Hono<Env>()
|
||||
app.get('/x', (c) => {
|
||||
c.set('errorLog', null)
|
||||
captured = c as unknown as Context<Env>
|
||||
return c.body(null, 200)
|
||||
})
|
||||
await app.request('/x')
|
||||
return captured
|
||||
}
|
||||
|
||||
describe('renderError', () => {
|
||||
it('renders an ApiError as its AIP-193 body + status and records errorLog', async () => {
|
||||
const c = await ctx()
|
||||
const res = renderError(c, new ApiError(402, 'Insufficient credits', { reason: 'INSUFFICIENT_CREDITS' }))
|
||||
expect(res.status).toBe(402)
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: { status: 'FAILED_PRECONDITION', message: 'Insufficient credits' },
|
||||
})
|
||||
expect(c.get('errorLog')).toEqual({ reason: 'INSUFFICIENT_CREDITS', message: 'Insufficient credits' })
|
||||
})
|
||||
|
||||
it('renders a mapped domain error with its mapped status + reason', async () => {
|
||||
const c = await ctx()
|
||||
const res = renderError(c, new NameConflictError('doc.txt', 'id-1'))
|
||||
expect(res.status).toBe(409)
|
||||
expect(c.get('errorLog')?.reason).toBe('NAME_CONFLICT')
|
||||
})
|
||||
|
||||
it('renders an unknown error as a generic 500 while logging the full cause chain', async () => {
|
||||
const c = await ctx()
|
||||
const err = new Error('top') as Error & { cause?: unknown }
|
||||
err.cause = new Error('D1_ERROR: disk full')
|
||||
const res = renderError(c, err)
|
||||
expect(res.status).toBe(500)
|
||||
expect(((await res.json()) as { error: { message: string } }).error.message).toBe('Internal Server Error')
|
||||
const log = c.get('errorLog')
|
||||
expect(log?.reason).toBe('INTERNAL')
|
||||
expect(log?.message).toContain('top')
|
||||
expect(log?.message).toContain('D1_ERROR: disk full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isHandledError', () => {
|
||||
it('is true for ApiError and mapped domain errors, false otherwise', () => {
|
||||
expect(isHandledError(new ApiError(400, 'x'))).toBe(true)
|
||||
expect(isHandledError(new NameConflictError('a', 'b'))).toBe(true)
|
||||
expect(isHandledError(new Error('boom'))).toBe(false)
|
||||
expect(isHandledError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Context } from 'hono'
|
||||
import { formatError } from '../lib/errors'
|
||||
import { ApiError, buildErrorBody, mapDomainError } from '../lib/http-errors'
|
||||
import type { Env } from './platform'
|
||||
|
||||
// Turn any thrown error into the AIP-193 response we return to the client, and
|
||||
// stash its reason + message on the context for the access log. Shared by the
|
||||
// accessLog boundary (which catches /api throws so it can log the real mapped
|
||||
// status) and `app.onError` (the backstop for errors thrown outside that
|
||||
// boundary, e.g. earlier middleware or non-access-logged routes).
|
||||
//
|
||||
// The client never sees an internal stack: an untranslated error becomes a
|
||||
// generic 500 body, while the full `cause` chain goes only to `errorLog` →
|
||||
// the access log. Domain errors and `ApiError` carry their own safe message.
|
||||
export function renderError(c: Context<Env>, err: unknown): Response {
|
||||
if (err instanceof ApiError) {
|
||||
const body = err.toBody()
|
||||
c.set('errorLog', { reason: body.error.details?.[0]?.reason ?? body.error.status, message: err.message })
|
||||
return c.json(body, err.httpStatus)
|
||||
}
|
||||
|
||||
const mapped = mapDomainError(err)
|
||||
if (mapped) {
|
||||
c.set('errorLog', {
|
||||
reason: mapped.json.error.details?.[0]?.reason ?? mapped.json.error.status,
|
||||
message: mapped.message,
|
||||
})
|
||||
return c.json(mapped.json, mapped.status)
|
||||
}
|
||||
|
||||
const detail = formatError(err)
|
||||
c.set('errorLog', { reason: 'INTERNAL', message: detail })
|
||||
return c.json(buildErrorBody(500, 'Internal Server Error', { reason: 'INTERNAL' }), 500)
|
||||
}
|
||||
|
||||
// True when `renderError` would translate `err` into a specific (non-500) result.
|
||||
// Lets `app.onError` log only genuinely unhandled errors as `http.unhandled_error`.
|
||||
export function isHandledError(err: unknown): boolean {
|
||||
return err instanceof ApiError || mapDomainError(err) !== null
|
||||
}
|
||||
@@ -215,7 +215,12 @@ describe('imageHostingDomain middleware — custom domain redirect', () => {
|
||||
redirect: 'manual',
|
||||
})
|
||||
expect(res.status).toBe(422)
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Traffic quota exceeded' })
|
||||
const quotaBody = (await res.json()) as {
|
||||
error: { message: string; status: string; details: { reason: string }[] }
|
||||
}
|
||||
expect(quotaBody.error.message).toBe('Traffic quota exceeded')
|
||||
expect(quotaBody.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
expect(quotaBody.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
expect(S3Service.prototype.presignInline).not.toHaveBeenCalled()
|
||||
expect(await getAccessCount(db, 'dm-quota-over')).toBe(0)
|
||||
})
|
||||
@@ -304,8 +309,8 @@ describe('imageHostingDomain middleware — custom domain redirect', () => {
|
||||
headers: { host: 'img.empty.com' },
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe('path required')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('path required')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -328,8 +333,8 @@ describe('imageHostingDomain middleware — referer allowlist', () => {
|
||||
redirect: 'manual',
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
const body = (await res.json()) as { error: string }
|
||||
expect(body.error).toBe('forbidden referer')
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('forbidden referer')
|
||||
})
|
||||
|
||||
it('referer allowlist allows matching origin → 302', async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ErrorReason } from '@shared/schemas'
|
||||
import type { Context, Next } from 'hono'
|
||||
import { apiError } from '../http/openapi'
|
||||
import { PRESIGN_TTL_SECS } from '../http/share-utils'
|
||||
import { reportTrafficForDownload } from '../http/store/traffic-metering'
|
||||
import type { Env } from '../middleware/platform'
|
||||
@@ -41,20 +43,24 @@ function checkReferer(refererAllowlist: string[], refererHeader: string | null):
|
||||
|
||||
async function handleImageByPath(c: Context<Env>, orgId: string, virtualPath: string): Promise<Response> {
|
||||
const resolved = await c.get('deps').imageHosting.resolveActiveByOrgPath(orgId, virtualPath)
|
||||
if (!resolved) return c.json({ error: 'Not found' }, 404)
|
||||
if (!resolved) return apiError(c, 404, 'Not found')
|
||||
|
||||
const { image, refererAllowlist } = resolved
|
||||
|
||||
const refererHeader = c.req.header('Referer') ?? null
|
||||
if (!checkReferer(refererAllowlist, refererHeader)) {
|
||||
return c.json({ error: 'forbidden referer' }, 403)
|
||||
return apiError(c, 403, 'forbidden referer')
|
||||
}
|
||||
|
||||
const storage = await c.get('deps').storages.get(image.storageId)
|
||||
if (!storage) return c.json({ error: 'Storage not found' }, 404)
|
||||
if (!storage) return apiError(c, 404, 'Storage not found')
|
||||
|
||||
const trafficAllowed = await c.get('deps').quota.consumeTrafficIfQuotaAllows(image.orgId, image.size)
|
||||
if (!trafficAllowed) return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
if (!trafficAllowed)
|
||||
return apiError(c, 422, 'Traffic quota exceeded', {
|
||||
reason: ErrorReason.QUOTA_EXCEEDED,
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
})
|
||||
|
||||
let url: string
|
||||
try {
|
||||
@@ -100,7 +106,7 @@ export async function imageHostingDomain(c: Context<Env>, next: Next): Promise<R
|
||||
if (!orgId) return next()
|
||||
|
||||
const virtualPath = c.req.path.replace(/^\/+/, '')
|
||||
if (!virtualPath) return c.json({ error: 'path required' }, 404)
|
||||
if (!virtualPath) return apiError(c, 404, 'path required')
|
||||
|
||||
return handleImageByPath(c, orgId, virtualPath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ErrorReason } from '@shared/schemas'
|
||||
import type { Handler } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apiError } from '../http/openapi'
|
||||
import { NameConflictError } from '../usecases/ports'
|
||||
import { renderError } from './error-handler'
|
||||
import { accessLog } from './logger'
|
||||
import type { Env } from './platform'
|
||||
|
||||
// Parse one `key="json"` access-log line into a record.
|
||||
function parseLine(line: string): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const m of line.matchAll(/(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g)) {
|
||||
out[m[1]] = m[2].startsWith('"') ? (JSON.parse(m[2]) as string) : m[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('accessLog', () => {
|
||||
let lines: string[]
|
||||
beforeEach(() => {
|
||||
lines = []
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string) => {
|
||||
lines.push(line)
|
||||
})
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
// Mirror production: accessLog at the boundary, errorLog initialised like
|
||||
// platformMiddleware, and app.onError rendering thrown errors via renderError
|
||||
// (Hono routes throws there, not to a middleware catch — see app.ts).
|
||||
function appWith(handler: Handler<Env>) {
|
||||
const app = new Hono<Env>()
|
||||
app.use('*', accessLog)
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('errorLog', null)
|
||||
await next()
|
||||
})
|
||||
app.get('/x', handler)
|
||||
app.onError((err, c) => renderError(c, err))
|
||||
return app
|
||||
}
|
||||
|
||||
it('logs a success without an error field', async () => {
|
||||
const app = appWith((c) => c.json({ ok: true }, 200))
|
||||
await app.request('/x')
|
||||
const f = parseLine(lines[0])
|
||||
expect(f.status).toBe('200')
|
||||
expect(f.error).toBeUndefined()
|
||||
expect(f.reason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('logs reason + message for an inline apiError', async () => {
|
||||
const app = appWith((c) => apiError(c, 404, 'Widget not found'))
|
||||
const res = await app.request('/x')
|
||||
expect(res.status).toBe(404)
|
||||
const f = parseLine(lines[0])
|
||||
expect(f.status).toBe('404')
|
||||
expect(f.reason).toBe('NOT_FOUND')
|
||||
expect(f.error).toBe('Widget not found')
|
||||
})
|
||||
|
||||
it('carries the specific reason + metadata message for a special error', async () => {
|
||||
const app = appWith((c) =>
|
||||
apiError(c, 402, 'Insufficient credits', {
|
||||
reason: ErrorReason.INSUFFICIENT_CREDITS,
|
||||
metadata: { resource: 'storage_egress' },
|
||||
}),
|
||||
)
|
||||
await app.request('/x')
|
||||
const f = parseLine(lines[0])
|
||||
expect(f.reason).toBe('INSUFFICIENT_CREDITS')
|
||||
expect(f.error).toBe('Insufficient credits')
|
||||
})
|
||||
|
||||
it('logs a thrown domain error with its MAPPED status, not 500', async () => {
|
||||
const app = appWith(() => {
|
||||
throw new NameConflictError('doc.txt', 'id-1')
|
||||
})
|
||||
const res = await app.request('/x')
|
||||
expect(res.status).toBe(409)
|
||||
const f = parseLine(lines[0])
|
||||
expect(f.status).toBe('409')
|
||||
expect(f.reason).toBe('NAME_CONFLICT')
|
||||
})
|
||||
|
||||
it('logs the full cause chain for an unhandled 500 (and hides it from the client)', async () => {
|
||||
const app = appWith(() => {
|
||||
const err = new Error('top') as Error & { cause?: unknown }
|
||||
err.cause = new Error('D1_ERROR: disk full')
|
||||
throw err
|
||||
})
|
||||
const res = await app.request('/x')
|
||||
expect(res.status).toBe(500)
|
||||
// Client body is generic — no internal detail leaks.
|
||||
expect(((await res.json()) as { error: { message: string } }).error.message).toBe('Internal Server Error')
|
||||
// The access log keeps the full chain.
|
||||
const f = parseLine(lines[0])
|
||||
expect(f.status).toBe('500')
|
||||
expect(f.reason).toBe('INTERNAL')
|
||||
expect(f.error).toContain('top')
|
||||
expect(f.error).toContain('D1_ERROR: disk full')
|
||||
})
|
||||
})
|
||||
+22
-18
@@ -1,30 +1,28 @@
|
||||
import type { Context } from 'hono'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { formatError } from '../lib/errors'
|
||||
import type { Env } from './platform'
|
||||
|
||||
// The request boundary for /api and /dav: one structured line per request, logged
|
||||
// after the response is finalized. By the time `next()` returns, the status and
|
||||
// `errorLog` are settled regardless of how the response was produced — an inline
|
||||
// `apiError(...)` return sets `errorLog` directly, and a thrown error is rendered
|
||||
// by `app.onError` (via `renderError`, which also sets `errorLog`) before control
|
||||
// returns here. So the log records the REAL mapped status (a thrown 409 logs as
|
||||
// 409, not 500) and carries the error's reason + full message for every 4xx/5xx,
|
||||
// not just unhandled crashes.
|
||||
export const accessLog = createMiddleware<Env>(async (c, next) => {
|
||||
const start = Date.now()
|
||||
try {
|
||||
await next()
|
||||
} catch (error) {
|
||||
writeAccessLog(c, start, 500, error)
|
||||
throw error
|
||||
}
|
||||
writeAccessLog(c, start, c.res.status)
|
||||
await next()
|
||||
writeAccessLog(c, start)
|
||||
})
|
||||
|
||||
function writeAccessLog(c: Context<Env>, start: number, status: number, error?: unknown) {
|
||||
const fields = accessLogFields(c, start, status, error)
|
||||
function writeAccessLog(c: Context<Env>, start: number) {
|
||||
const fields = accessLogFields(c, start)
|
||||
console.log(fields.map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(' '))
|
||||
}
|
||||
|
||||
function accessLogFields(
|
||||
c: Context<Env>,
|
||||
start: number,
|
||||
status: number,
|
||||
error?: unknown,
|
||||
): Array<[string, string | number]> {
|
||||
function accessLogFields(c: Context<Env>, start: number): Array<[string, string | number]> {
|
||||
const status = c.res.status
|
||||
const fields: Array<[string, string | number]> = [
|
||||
['method', c.req.method],
|
||||
['path', c.req.path],
|
||||
@@ -45,8 +43,14 @@ function accessLogFields(
|
||||
)
|
||||
}
|
||||
|
||||
if (error !== undefined) {
|
||||
fields.push(['error', formatError(error)])
|
||||
// Every failed request carries its reason + full message — set by apiError on
|
||||
// inline returns and by renderError on thrown errors (incl. the full cause
|
||||
// chain for unhandled 500s, which never reaches the client body).
|
||||
const errorLog = c.get('errorLog')
|
||||
if (errorLog) {
|
||||
fields.push(['reason', errorLog.reason], ['error', errorLog.message])
|
||||
} else if (status >= 400) {
|
||||
fields.push(['error', c.res.statusText || '-'])
|
||||
}
|
||||
|
||||
return fields
|
||||
|
||||
@@ -12,6 +12,10 @@ export type Env = {
|
||||
userId: string | null
|
||||
userRole: string | null
|
||||
orgId: string | null
|
||||
// Structured detail for the access log on a failed request. Set by `apiError`
|
||||
// and `app.onError`; read by the accessLog middleware so every 4xx/5xx carries
|
||||
// its reason + full message, not just unhandled crashes.
|
||||
errorLog: { reason: string; message: string } | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,5 +57,6 @@ export const platformMiddleware = (platform: Platform, auth: Auth) =>
|
||||
c.set('platform', platform)
|
||||
c.set('auth', auth)
|
||||
c.set('principal', null)
|
||||
c.set('errorLog', null)
|
||||
await next()
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ErrorReason } from '@shared/schemas'
|
||||
import type { ProFeature } from '@shared/types'
|
||||
import type { Context } from 'hono'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { hasFeature } from '../domain/licensing'
|
||||
import { apiError } from '../http/openapi'
|
||||
import { loadBindingState, normalizeHost } from '../usecases/site/licensing'
|
||||
import { getSitePublicOrigin } from '../usecases/site/public-origin'
|
||||
import type { Env } from './platform'
|
||||
@@ -19,7 +21,10 @@ export function requireFeature(name: ProFeature) {
|
||||
(await configuredPublicHost(c)) ?? normalizeHost(c.req.header('host')) ?? new URL(c.req.url).host
|
||||
const state = await loadBindingState(c.get('deps'), { currentHost, cloudBaseUrl })
|
||||
if (!hasFeature(name, state)) {
|
||||
return c.json({ error: 'feature_not_available', feature: name, upgrade_url: '/settings/billing' }, 402)
|
||||
return apiError(c, 402, 'Feature not available', {
|
||||
reason: ErrorReason.FEATURE_NOT_AVAILABLE,
|
||||
metadata: { feature: name, upgradeUrl: '/settings/billing' },
|
||||
})
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
@@ -617,8 +617,9 @@ describe('POST /api/shares/:token/objects', () => {
|
||||
body: JSON.stringify({ targetOrgId: personalOrgId }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { code?: string }
|
||||
expect(body.code).toBe('DIRECT_SAVE_FORBIDDEN')
|
||||
const body = (await res.json()) as { error: { message: string; details: { reason: string }[] } }
|
||||
expect(body.error.message).toBe('Direct link shares cannot be saved. Ask the sender for a landing share.')
|
||||
expect(body.error.details[0].reason).toBe('DIRECT_SAVE_FORBIDDEN')
|
||||
})
|
||||
|
||||
it('returns 401 when password-protected share requires cookie and user is not recipient', async () => {
|
||||
@@ -749,8 +750,10 @@ describe('POST /api/shares/:token/objects', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { code?: string }
|
||||
expect(body.code).toBe('QUOTA_EXCEEDED')
|
||||
const body = (await res.json()) as { error: { message: string; status: string; details: { reason: string }[] } }
|
||||
expect(body.error.message).toBe('Quota exceeded')
|
||||
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
|
||||
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
|
||||
})
|
||||
|
||||
it('successfully saves a landing single-file share', async () => {
|
||||
|
||||
@@ -168,9 +168,12 @@ describe('PUT auth_signup_mode via admin API', () => {
|
||||
const headers = await adminHeaders(ctx)
|
||||
const res = await putSignupMode(ctx, headers, 'open')
|
||||
expect(res.status).toBe(402)
|
||||
const body = (await res.json()) as { error: string; feature: string }
|
||||
expect(body.error).toBe('feature_not_available')
|
||||
expect(body.feature).toBe('open_registration')
|
||||
const body = (await res.json()) as {
|
||||
error: { message: string; details: { reason: string; metadata: Record<string, string> }[] }
|
||||
}
|
||||
expect(body.error.message).toBe('Feature not available')
|
||||
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(body.error.details[0].metadata.feature).toBe('open_registration')
|
||||
})
|
||||
|
||||
it('setting open with Pro succeeds', async () => {
|
||||
|
||||
+109
-16
@@ -1,21 +1,114 @@
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
// The canonical error body every endpoint returns on failure: a human-readable
|
||||
// `error` plus an optional machine-readable `code` (e.g. `NAME_CONFLICT`) clients
|
||||
// and SDKs can switch on. Named once so the OpenAPI document — and every generated
|
||||
// SDK — shares a single `ErrorResponse` model instead of re-inlining it per
|
||||
// operation.
|
||||
export const errorResponseSchema = z.object({ error: z.string(), code: z.string().optional() }).openapi('ErrorResponse')
|
||||
// Every error response follows the Google API error model (AIP-193,
|
||||
// https://google.aip.dev/193): a `google.rpc.Status` wrapped in `error`. One model
|
||||
// for the whole API so an SDK can model "an error" once instead of a grab-bag of
|
||||
// per-route top-level fields.
|
||||
//
|
||||
// { "error": {
|
||||
// "code": 413, // HTTP status
|
||||
// "message": "File exceeds the limit.", // developer-facing, English
|
||||
// "status": "FAILED_PRECONDITION", // canonical google.rpc.Code name
|
||||
// "details": [{
|
||||
// "@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
// "reason": "PAYLOAD_TOO_LARGE", // the machine-readable switch key
|
||||
// "domain": "zpan.dev",
|
||||
// "metadata": { "maxBytes": "5242880" } // dynamic context, string→string
|
||||
// }] } }
|
||||
//
|
||||
// Clients switch on `details[].reason` (stable, UPPER_SNAKE); `status` gives a
|
||||
// transport-independent error class; loose context that used to leak as extra
|
||||
// top-level fields now lives in `metadata`.
|
||||
|
||||
// A feature-gated rejection (HTTP 402): the caller's plan lacks a capability.
|
||||
// Carries the feature key plus optional limit context the UI uses to prompt an
|
||||
// upgrade.
|
||||
export const featureGateErrorSchema = z
|
||||
export const ERROR_DOMAIN = 'zpan.dev'
|
||||
|
||||
export const ERROR_INFO_TYPE = 'type.googleapis.com/google.rpc.ErrorInfo'
|
||||
|
||||
// The canonical google.rpc.Code enum names we surface in `error.status`.
|
||||
export const canonicalStatuses = [
|
||||
'INVALID_ARGUMENT',
|
||||
'FAILED_PRECONDITION',
|
||||
'OUT_OF_RANGE',
|
||||
'UNAUTHENTICATED',
|
||||
'PERMISSION_DENIED',
|
||||
'NOT_FOUND',
|
||||
'ALREADY_EXISTS',
|
||||
'ABORTED',
|
||||
'RESOURCE_EXHAUSTED',
|
||||
'CANCELLED',
|
||||
'DEADLINE_EXCEEDED',
|
||||
'UNIMPLEMENTED',
|
||||
'UNAVAILABLE',
|
||||
'DATA_LOSS',
|
||||
'INTERNAL',
|
||||
'UNKNOWN',
|
||||
] as const
|
||||
|
||||
export type CanonicalStatus = (typeof canonicalStatuses)[number]
|
||||
|
||||
// Default HTTP status → canonical status. A specific site may override the
|
||||
// canonical status (e.g. a 409 name conflict is ALREADY_EXISTS, a 422 quota
|
||||
// breach is RESOURCE_EXHAUSTED) while keeping its HTTP code.
|
||||
const HTTP_TO_CANONICAL: Record<number, CanonicalStatus> = {
|
||||
400: 'INVALID_ARGUMENT',
|
||||
401: 'UNAUTHENTICATED',
|
||||
402: 'FAILED_PRECONDITION',
|
||||
403: 'PERMISSION_DENIED',
|
||||
404: 'NOT_FOUND',
|
||||
405: 'FAILED_PRECONDITION',
|
||||
409: 'ABORTED',
|
||||
410: 'NOT_FOUND',
|
||||
413: 'FAILED_PRECONDITION',
|
||||
415: 'INVALID_ARGUMENT',
|
||||
422: 'INVALID_ARGUMENT',
|
||||
429: 'RESOURCE_EXHAUSTED',
|
||||
500: 'INTERNAL',
|
||||
501: 'UNIMPLEMENTED',
|
||||
502: 'UNAVAILABLE',
|
||||
503: 'UNAVAILABLE',
|
||||
504: 'DEADLINE_EXCEEDED',
|
||||
}
|
||||
|
||||
export function canonicalStatusForHttp(httpStatus: number): CanonicalStatus {
|
||||
return HTTP_TO_CANONICAL[httpStatus] ?? (httpStatus >= 500 ? 'INTERNAL' : 'UNKNOWN')
|
||||
}
|
||||
|
||||
// The machine-readable `reason` values shared across resources. One-off,
|
||||
// resource-local reasons stay as string literals at their call site; these are the
|
||||
// ones referenced in more than one place or worth switching on from an SDK.
|
||||
export const ErrorReason = {
|
||||
NAME_CONFLICT: 'NAME_CONFLICT',
|
||||
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
|
||||
INSUFFICIENT_CREDITS: 'INSUFFICIENT_CREDITS',
|
||||
FEATURE_NOT_AVAILABLE: 'FEATURE_NOT_AVAILABLE',
|
||||
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
||||
UNSUPPORTED_MEDIA_TYPE: 'UNSUPPORTED_MEDIA_TYPE',
|
||||
NO_STORAGE_CONFIGURED: 'NO_STORAGE_CONFIGURED',
|
||||
} as const
|
||||
|
||||
export const errorInfoSchema = z
|
||||
.object({
|
||||
error: z.string(),
|
||||
feature: z.string(),
|
||||
currentCount: z.number().int().optional(),
|
||||
limit: z.number().int().optional(),
|
||||
upgrade_url: z.string().optional(),
|
||||
'@type': z.literal(ERROR_INFO_TYPE),
|
||||
// Stable, UPPER_SNAKE_CASE, ≤63 chars (AIP-193 / google.rpc.ErrorInfo).
|
||||
reason: z.string(),
|
||||
domain: z.string(),
|
||||
// Dynamic context. AIP-193 requires string values.
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.openapi('FeatureGateError')
|
||||
.openapi('ErrorInfo')
|
||||
|
||||
// The canonical error body for every failing endpoint. Named once so the OpenAPI
|
||||
// document — and every generated SDK — shares a single `Error` model.
|
||||
export const errorResponseSchema = z
|
||||
.object({
|
||||
error: z.object({
|
||||
code: z.number().int(),
|
||||
message: z.string(),
|
||||
status: z.string(),
|
||||
details: z.array(errorInfoSchema).optional(),
|
||||
}),
|
||||
})
|
||||
.openapi('Error')
|
||||
|
||||
export type ErrorResponse = z.infer<typeof errorResponseSchema>
|
||||
export type ErrorInfo = z.infer<typeof errorInfoSchema>
|
||||
|
||||
+12
-1
@@ -115,9 +115,20 @@ export {
|
||||
updateDownloaderSchema,
|
||||
updateDownloadTaskSchema,
|
||||
} from './downloads'
|
||||
export { errorResponseSchema, featureGateErrorSchema } from './errors'
|
||||
export type { CanonicalStatus, ErrorInfo, ErrorResponse } from './errors'
|
||||
export {
|
||||
canonicalStatuses,
|
||||
canonicalStatusForHttp,
|
||||
ERROR_DOMAIN,
|
||||
ERROR_INFO_TYPE,
|
||||
ErrorReason,
|
||||
errorInfoSchema,
|
||||
errorResponseSchema,
|
||||
} from './errors'
|
||||
export type { ListNotificationsQuery } from './notification'
|
||||
export { listNotificationsQuerySchema } from './notification'
|
||||
export type { Page, PageQuery } from './pagination'
|
||||
export { pageQuerySchema, pageSchema } from './pagination'
|
||||
export type { CreateShareInput, CreateShareRequest, ShareKind } from './share'
|
||||
export {
|
||||
createShareRequestSchema,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
import { pageQuerySchema } from './pagination'
|
||||
|
||||
export const listNotificationsQuerySchema = z.object({
|
||||
page: z.string().optional(),
|
||||
pageSize: z.string().optional(),
|
||||
export const listNotificationsQuerySchema = pageQuerySchema.extend({
|
||||
unread: z.string().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
// One pagination contract for the whole API (AIP-193 sibling concern in #443):
|
||||
// every list endpoint returns `Page<T> = { items, total, page, pageSize }` and
|
||||
// accepts integer `page`/`pageSize` query params. The only intentional exception
|
||||
// is image-hosting/images, which stays cursor-paginated for large galleries.
|
||||
|
||||
// Integer, coerced query params with sane bounds. Use as `request.query`.
|
||||
export const pageQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export type PageQuery = z.infer<typeof pageQuerySchema>
|
||||
|
||||
// Build the named `Page<Item>` response component. `name` becomes the OpenAPI
|
||||
// schema name (e.g. 'ObjectPage'), so each item type gets a distinct, generated
|
||||
// SDK model instead of an inlined anonymous object.
|
||||
export const pageSchema = <T extends z.ZodType>(item: T, name: string) =>
|
||||
z
|
||||
.object({
|
||||
items: z.array(item),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
})
|
||||
.openapi(name)
|
||||
|
||||
export type Page<T> = {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
@@ -36,7 +36,7 @@ describe('withConflictRetry', () => {
|
||||
|
||||
it('calls prompt and re-runs with chosen strategy on NAME_CONFLICT', async () => {
|
||||
const fakeError = Object.assign(new Error('conflict'), {
|
||||
body: { conflictingName: 'report.pdf', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'report.pdf' },
|
||||
})
|
||||
vi.mocked(isNameConflictError).mockImplementation((e) => e === fakeError)
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('withConflictRetry', () => {
|
||||
|
||||
it('returns undefined when user cancels the conflict dialog', async () => {
|
||||
const fakeError = Object.assign(new Error('conflict'), {
|
||||
body: { conflictingName: 'file.txt', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'file.txt' },
|
||||
})
|
||||
vi.mocked(isNameConflictError).mockImplementation((e) => e === fakeError)
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('withConflictRetry', () => {
|
||||
|
||||
it('passes showApplyToAll: true to prompt when opts.showApplyToAll is true', async () => {
|
||||
const fakeError = Object.assign(new Error('conflict'), {
|
||||
body: { conflictingName: 'data.csv', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'data.csv' },
|
||||
})
|
||||
vi.mocked(isNameConflictError).mockImplementation((e) => e === fakeError)
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('withConflictRetry', () => {
|
||||
|
||||
it('uses the chosen strategy in the retry call', async () => {
|
||||
const fakeError = Object.assign(new Error('conflict'), {
|
||||
body: { conflictingName: 'x.txt', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'x.txt' },
|
||||
})
|
||||
vi.mocked(isNameConflictError).mockImplementation((e) => e === fakeError)
|
||||
|
||||
@@ -116,10 +116,10 @@ describe('withConflictRetry', () => {
|
||||
|
||||
it('retries on each conflict and calls prompt for each one', async () => {
|
||||
const error1 = Object.assign(new Error('conflict1'), {
|
||||
body: { conflictingName: 'a.txt', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'a.txt' },
|
||||
})
|
||||
const error2 = Object.assign(new Error('conflict2'), {
|
||||
body: { conflictingName: 'a (1).txt', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'a (1).txt' },
|
||||
})
|
||||
vi.mocked(isNameConflictError).mockImplementation((e) => e === error1 || e === error2)
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('withConflictRetry', () => {
|
||||
it('throws the last NameConflictError after MAX_CONFLICT_RETRIES (3) consecutive conflicts', async () => {
|
||||
const makeConflictError = (name: string) =>
|
||||
Object.assign(new Error(`conflict: ${name}`), {
|
||||
body: { conflictingName: name, code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: name },
|
||||
})
|
||||
|
||||
const errors = [
|
||||
@@ -169,10 +169,10 @@ describe('withConflictRetry', () => {
|
||||
|
||||
it('returns undefined and stops retrying when user cancels the second prompt', async () => {
|
||||
const error1 = Object.assign(new Error('conflict1'), {
|
||||
body: { conflictingName: 'b.txt', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'b.txt' },
|
||||
})
|
||||
const error2 = Object.assign(new Error('conflict2'), {
|
||||
body: { conflictingName: 'b (1).txt', code: 'NAME_CONFLICT' },
|
||||
metadata: { conflictingName: 'b (1).txt' },
|
||||
})
|
||||
vi.mocked(isNameConflictError).mockImplementation((e) => e === error1 || e === error2)
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function withConflictRetry<T>(
|
||||
} catch (e) {
|
||||
if (!isNameConflictError(e)) throw e
|
||||
if (attempt === MAX_CONFLICT_RETRIES) throw e
|
||||
const res = await prompt({ kind, name: e.body.conflictingName, showApplyToAll: opts.showApplyToAll })
|
||||
const res = await prompt({ kind, name: e.metadata?.conflictingName, showApplyToAll: opts.showApplyToAll })
|
||||
if ('cancelled' in res) return undefined
|
||||
strategy = res.strategy
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function TransferSpaceDialog({ item, onOpenChange, onCompleted }: Transfe
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.body.code === 'QUOTA_EXCEEDED') {
|
||||
if (err instanceof ApiError && err.reason === 'QUOTA_EXCEEDED') {
|
||||
toast.error(t('files.transferQuotaExceeded'))
|
||||
} else {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
|
||||
@@ -21,7 +21,8 @@ function makeNotification(overrides: Partial<Notification> = {}): Notification {
|
||||
}
|
||||
|
||||
// ─── "Mark all as read" visibility ───────────────────────────────────────────
|
||||
// Mirrors the `hasUnread` check: const hasUnread = (data?.unreadCount ?? 0) > 0
|
||||
// Mirrors the `hasUnread` check: const hasUnread = (unread?.count ?? 0) > 0
|
||||
// (`unread` comes from the ['notifications','unread-count'] query → getUnreadCount)
|
||||
|
||||
function shouldShowMarkAllRead(unreadCount: number | undefined): boolean {
|
||||
return (unreadCount ?? 0) > 0
|
||||
|
||||
@@ -4,7 +4,7 @@ import { openAnnouncementsDialog } from '@/components/announcements/site-announc
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import { useEntitlement } from '@/hooks/useEntitlement'
|
||||
import { listNotifications, markAllNotificationsRead } from '@/lib/api'
|
||||
import { getUnreadCount, listNotifications, markAllNotificationsRead } from '@/lib/api'
|
||||
import { NotificationItem } from './notification-item'
|
||||
|
||||
export function NotificationDropdown() {
|
||||
@@ -18,8 +18,13 @@ export function NotificationDropdown() {
|
||||
queryFn: () => listNotifications(1, 10),
|
||||
})
|
||||
|
||||
const { data: unread } = useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: getUnreadCount,
|
||||
})
|
||||
|
||||
const items = data?.items ?? []
|
||||
const hasUnread = (data?.unreadCount ?? 0) > 0
|
||||
const hasUnread = (unread?.count ?? 0) > 0
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
await markAllNotificationsRead()
|
||||
|
||||
@@ -57,7 +57,7 @@ export function SaveToDriveDialog({ open, onOpenChange, token, onPasswordRequire
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 400 && err.body.code === 'QUOTA_EXCEEDED') {
|
||||
if (err.status === 400 && err.reason === 'QUOTA_EXCEEDED') {
|
||||
toast.error(t('share.quotaExceeded'))
|
||||
} else if (err.status === 401) {
|
||||
toast.error(t('share.passwordRequired'))
|
||||
|
||||
@@ -92,7 +92,7 @@ function LinkInviteTab({ orgId }: { orgId: string }) {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json()
|
||||
throw new Error((body as { error?: string }).error ?? 'Failed to generate link')
|
||||
throw new Error((body as { error?: { message?: string } }).error?.message ?? 'Failed to generate link')
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
@@ -163,7 +163,7 @@ function PendingInvitations({ orgId }: { orgId: string }) {
|
||||
const res = await teamsApi[':teamId'].invitations.$get({ param: { teamId: orgId } })
|
||||
if (!res.ok) throw new Error('Failed to load invitations')
|
||||
const body = await res.json()
|
||||
return (body as { invitations: PendingInvitation[] }).invitations
|
||||
return (body as { items: PendingInvitation[] }).items
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ async function uploadFile(
|
||||
await confirmUpload(created.id, resolvedStrategy)
|
||||
} catch (e) {
|
||||
if (!prompt || !isNameConflictError(e)) throw e
|
||||
const res = await prompt({ kind: 'file', name: e.body.conflictingName, showApplyToAll })
|
||||
const res = await prompt({ kind: 'file', name: e.metadata?.conflictingName, showApplyToAll })
|
||||
if ('cancelled' in res) return 'cancelled'
|
||||
await confirmUpload(created.id, res.strategy)
|
||||
}
|
||||
|
||||
+81
-20
@@ -207,15 +207,16 @@ describe('api', () => {
|
||||
await expect(listObjects('root')).rejects.toThrow('forbidden')
|
||||
})
|
||||
|
||||
it('falls back to statusText when error body has no error field', async () => {
|
||||
it('falls back to HTTP status when error body has no error field', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({}, false, 500))
|
||||
|
||||
await expect(listObjects('root')).rejects.toThrow('Bad Request')
|
||||
await expect(listObjects('root')).rejects.toThrow('HTTP 500')
|
||||
})
|
||||
|
||||
it('falls back to statusText when json parse fails', async () => {
|
||||
it('falls back to HTTP status when json parse fails', async () => {
|
||||
const res = {
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
json: async () => {
|
||||
throw new Error('parse error')
|
||||
@@ -223,7 +224,7 @@ describe('api', () => {
|
||||
} as unknown as Response
|
||||
vi.mocked(fetch).mockResolvedValueOnce(res)
|
||||
|
||||
await expect(listObjects('root')).rejects.toThrow('Service Unavailable')
|
||||
await expect(listObjects('root')).rejects.toThrow('HTTP 503')
|
||||
})
|
||||
|
||||
it('passes credentials: include', async () => {
|
||||
@@ -648,12 +649,29 @@ describe('api', () => {
|
||||
|
||||
it('throws on quota exceeded response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeResponse({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, false, 422),
|
||||
makeResponse(
|
||||
{
|
||||
error: {
|
||||
code: 422,
|
||||
message: 'Quota exceeded',
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
domain: 'zpan.dev',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
false,
|
||||
422,
|
||||
),
|
||||
)
|
||||
|
||||
await expect(transferObject('id1', { targetOrgId: 'org-team', targetParent: '', mode: 'copy' })).rejects.toThrow(
|
||||
'Quota exceeded',
|
||||
)
|
||||
await expect(
|
||||
transferObject('id1', { targetOrgId: 'org-team', targetParent: '', mode: 'copy' }),
|
||||
).rejects.toMatchObject({ name: 'ApiError', status: 422, reason: 'QUOTA_EXCEEDED' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1298,12 +1316,25 @@ describe('api', () => {
|
||||
})
|
||||
|
||||
it('throws ApiError for background job failures', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Background job cannot be retried' }, false, 409))
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeResponse(
|
||||
{
|
||||
error: {
|
||||
code: 409,
|
||||
message: 'Background job cannot be retried',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [],
|
||||
},
|
||||
},
|
||||
false,
|
||||
409,
|
||||
),
|
||||
)
|
||||
|
||||
await expect(retryBackgroundJob('job-1')).rejects.toMatchObject({
|
||||
name: 'ApiError',
|
||||
status: 409,
|
||||
body: { error: 'Background job cannot be retried' },
|
||||
message: 'Background job cannot be retried',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2175,7 +2206,7 @@ describe('api', () => {
|
||||
|
||||
describe('listNotifications', () => {
|
||||
it('calls /api/notifications with default params', async () => {
|
||||
const payload = { items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }
|
||||
const payload = { items: [], total: 0, page: 1, pageSize: 20 }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await listNotifications()
|
||||
@@ -2189,7 +2220,7 @@ describe('api', () => {
|
||||
})
|
||||
|
||||
it('passes page, pageSize, and unreadOnly params', async () => {
|
||||
const payload = { items: [], total: 5, unreadCount: 5, page: 2, pageSize: 10 }
|
||||
const payload = { items: [], total: 5, page: 2, pageSize: 10 }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
await listNotifications(2, 10, true)
|
||||
@@ -2619,14 +2650,33 @@ describe('api', () => {
|
||||
expect(JSON.parse(init.body as string)).toEqual({ targetOrgId: 'org-1', targetParent: 'Docs' })
|
||||
})
|
||||
|
||||
it('throws ApiError with QUOTA_EXCEEDED code on 400', async () => {
|
||||
it('throws ApiError with QUOTA_EXCEEDED reason on 400', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeResponse({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, false, 400),
|
||||
makeResponse(
|
||||
{
|
||||
error: {
|
||||
code: 400,
|
||||
message: 'Quota exceeded',
|
||||
status: 'FAILED_PRECONDITION',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
domain: 'zpan.dev',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
false,
|
||||
400,
|
||||
),
|
||||
)
|
||||
|
||||
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toThrow(
|
||||
'Quota exceeded',
|
||||
)
|
||||
await expect(saveShareToDrive('tok123', { targetOrgId: 'org-1', targetParent: '' })).rejects.toMatchObject({
|
||||
name: 'ApiError',
|
||||
status: 400,
|
||||
reason: 'QUOTA_EXCEEDED',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws ApiError on 401 (password required)', async () => {
|
||||
@@ -3875,14 +3925,25 @@ describe('api', () => {
|
||||
})
|
||||
|
||||
describe('isNameConflictError', () => {
|
||||
const errorBody = (reason: string, metadata?: Record<string, string>) => ({
|
||||
error: {
|
||||
code: 409,
|
||||
message: 'Name already exists',
|
||||
status: 'ALREADY_EXISTS',
|
||||
details: [{ '@type': 'type.googleapis.com/google.rpc.ErrorInfo', reason, domain: 'zpan.dev', metadata }],
|
||||
},
|
||||
})
|
||||
|
||||
it('returns true only for 409 NAME_CONFLICT ApiErrors', () => {
|
||||
const conflict = new ApiError(409, { code: 'NAME_CONFLICT', conflictingName: 'a', conflictingId: 'id1' })
|
||||
const conflict = new ApiError(409, errorBody('NAME_CONFLICT', { conflictingName: 'a', conflictingId: 'id1' }))
|
||||
expect(isNameConflictError(conflict)).toBe(true)
|
||||
expect(conflict.metadata).toEqual({ conflictingName: 'a', conflictingId: 'id1' })
|
||||
expect(conflict.reason).toBe('NAME_CONFLICT')
|
||||
})
|
||||
|
||||
it('returns false for other ApiErrors and non-errors', () => {
|
||||
expect(isNameConflictError(new ApiError(409, { code: 'OTHER' }))).toBe(false)
|
||||
expect(isNameConflictError(new ApiError(404, { code: 'NAME_CONFLICT' }))).toBe(false)
|
||||
expect(isNameConflictError(new ApiError(409, errorBody('OTHER')))).toBe(false)
|
||||
expect(isNameConflictError(new ApiError(404, errorBody('NAME_CONFLICT')))).toBe(false)
|
||||
expect(isNameConflictError(new Error('nope'))).toBe(false)
|
||||
expect(isNameConflictError(null)).toBe(false)
|
||||
})
|
||||
|
||||
+65
-35
@@ -109,41 +109,72 @@ export type UserQuota = Pick<
|
||||
| 'currentPlan'
|
||||
>
|
||||
|
||||
export interface ErrorInfo {
|
||||
reason: string
|
||||
domain: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error?: string
|
||||
code?: string
|
||||
[key: string]: unknown
|
||||
error: {
|
||||
code: number
|
||||
message: string
|
||||
status: string
|
||||
details?: ErrorInfo[]
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly body: ApiErrorBody
|
||||
readonly reason: string | undefined
|
||||
readonly metadata: Record<string, string> | undefined
|
||||
readonly canonicalStatus: string | undefined
|
||||
constructor(status: number, body: ApiErrorBody) {
|
||||
super(body.error ?? `HTTP ${status}`)
|
||||
super(body.error.message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
this.reason = body.error.details?.[0]?.reason
|
||||
this.metadata = body.error.details?.[0]?.metadata
|
||||
this.canonicalStatus = body.error.status
|
||||
}
|
||||
}
|
||||
|
||||
// Normalizes any error payload into the AIP-193 `google.rpc.Status` body the
|
||||
// server now returns. Real server errors pass through; network failures,
|
||||
// non-JSON responses, and external (S3) fallbacks are wrapped synthetically.
|
||||
function toErrorBody(status: number, raw: unknown): ApiErrorBody {
|
||||
if (raw && typeof raw === 'object' && 'error' in raw) {
|
||||
const error = (raw as { error: unknown }).error
|
||||
if (error && typeof error === 'object') return raw as ApiErrorBody
|
||||
return {
|
||||
error: {
|
||||
code: status,
|
||||
message: typeof error === 'string' ? error : `HTTP ${status}`,
|
||||
status: '',
|
||||
details: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
error: { code: status, message: `HTTP ${status}`, status: '', details: [] },
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
export interface NameConflictBody extends ApiErrorBody {
|
||||
code: 'NAME_CONFLICT'
|
||||
conflictingName: string
|
||||
conflictingId: string
|
||||
}
|
||||
|
||||
export function isNameConflictError(err: unknown): err is ApiError & { body: NameConflictBody } {
|
||||
return err instanceof ApiError && err.status === 409 && err.body.code === 'NAME_CONFLICT'
|
||||
export function isNameConflictError(
|
||||
err: unknown,
|
||||
): err is ApiError & { metadata: { conflictingName: string; conflictingId: string } } {
|
||||
return err instanceof ApiError && err.status === 409 && err.reason === 'NAME_CONFLICT'
|
||||
}
|
||||
|
||||
async function unwrap<T>(promise: Promise<Response>): Promise<T> {
|
||||
const res = await promise
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
const body: ApiErrorBody = { ...parsed, error: parsed.error ?? res.statusText }
|
||||
throw new ApiError(res.status, body)
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
@@ -790,7 +821,6 @@ export function listTeamActivities(teamId: string, page = 1, pageSize = 20) {
|
||||
export type NotificationListResult = {
|
||||
items: Notification[]
|
||||
total: number
|
||||
unreadCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
@@ -809,7 +839,7 @@ export function getUnreadCount() {
|
||||
|
||||
export function markNotificationRead(id: string) {
|
||||
return notificationsApi[':id'].$patch({ param: { id } }).then((res) => {
|
||||
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
|
||||
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -891,7 +921,7 @@ export function getShare(token: string) {
|
||||
|
||||
export function deleteShare(token: string) {
|
||||
return authedSharesApi[':token'].$delete({ param: { token } }).then((res) => {
|
||||
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
|
||||
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -972,7 +1002,7 @@ export function updateIhostConfig(data: { customDomain?: string | null; refererA
|
||||
|
||||
export function deleteIhostConfig() {
|
||||
return ihostConfigApi.index.$delete().then((res) => {
|
||||
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
|
||||
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -997,8 +1027,8 @@ export interface CreateIhostApiKeyResult extends IhostApiKey {
|
||||
async function apiKeyFetch<T>(path: string, options: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api/auth${path}`, { credentials: 'include', ...options })
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
@@ -1161,8 +1191,8 @@ async function fetchSession(): Promise<SessionData> {
|
||||
try {
|
||||
const res = await fetch('/api/auth/get-session', { credentials: 'include', signal: controller.signal })
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, body)
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, body))
|
||||
}
|
||||
return res.json()
|
||||
} catch (error) {
|
||||
@@ -1340,8 +1370,8 @@ export function confirmIhostImage(id: string) {
|
||||
export async function deleteIhostImage(id: string) {
|
||||
const res = await ihostApi.images[':id'].$delete({ param: { id } })
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1361,8 +1391,8 @@ async function putImageMultipart(url: string, file: File): Promise<{ url: string
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
return res.json() as Promise<{ url: string }>
|
||||
}
|
||||
@@ -1374,8 +1404,8 @@ export function uploadAvatar(file: File) {
|
||||
export async function deleteAvatar() {
|
||||
const res = await users.me.avatar.$delete()
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1386,8 +1416,8 @@ export function uploadTeamLogo(teamId: string, file: File) {
|
||||
export async function deleteTeamLogo(teamId: string) {
|
||||
const res = await teamsApi[':teamId'].logo.$delete({ param: { teamId } })
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1433,8 +1463,8 @@ export async function saveBranding(data: {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
return res.json() as Promise<BrandingConfig>
|
||||
}
|
||||
@@ -1442,8 +1472,8 @@ export async function saveBranding(data: {
|
||||
export async function resetBrandingField(field: BrandingField): Promise<void> {
|
||||
const res = await brandingAdminApi[':field'].$delete({ param: { field } })
|
||||
if (!res.ok) {
|
||||
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
|
||||
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
|
||||
const parsed = await res.json().catch(() => ({}))
|
||||
throw new ApiError(res.status, toErrorBody(res.status, parsed))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,13 +77,26 @@ vi.mock('@/lib/browser-navigation', () => ({
|
||||
vi.mock('@/lib/api', () => {
|
||||
class MockApiError extends Error {
|
||||
readonly status: number
|
||||
readonly body: { error?: string }
|
||||
readonly body: {
|
||||
error: {
|
||||
code: number
|
||||
message: string
|
||||
status: string
|
||||
details?: Array<{ reason: string; domain: string; metadata?: Record<string, string> }>
|
||||
}
|
||||
}
|
||||
readonly reason: string | undefined
|
||||
readonly metadata: Record<string, string> | undefined
|
||||
readonly canonicalStatus: string | undefined
|
||||
|
||||
constructor(status: number, body: { error?: string }) {
|
||||
super(body.error ?? `HTTP ${status}`)
|
||||
constructor(status: number, body: MockApiError['body']) {
|
||||
super(body.error.message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
this.reason = body.error.details?.[0]?.reason
|
||||
this.metadata = body.error.details?.[0]?.metadata
|
||||
this.canonicalStatus = body.error.status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,7 +408,16 @@ describe('StoragePage', () => {
|
||||
})
|
||||
|
||||
it('hides self-service forms when storage purchases are disabled', async () => {
|
||||
vi.mocked(listCloudProducts).mockRejectedValue(new ApiError(403, { error: 'quota_store_disabled' }))
|
||||
vi.mocked(listCloudProducts).mockRejectedValue(
|
||||
new ApiError(402, {
|
||||
error: {
|
||||
code: 402,
|
||||
message: 'Feature not available',
|
||||
status: 'PERMISSION_DENIED',
|
||||
details: [{ reason: 'FEATURE_NOT_AVAILABLE', domain: 'zpan.dev', metadata: { feature: 'quota_store' } }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -320,5 +320,7 @@ function resolveCheckoutSelection(
|
||||
}
|
||||
|
||||
function isCloudStoreDisabledError(error: unknown) {
|
||||
return error instanceof ApiError && error.body.error === 'quota_store_disabled'
|
||||
return (
|
||||
error instanceof ApiError && error.reason === 'FEATURE_NOT_AVAILABLE' && error.metadata?.feature === 'quota_store'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function TeamInvitePage() {
|
||||
const res = await teamsApi[':teamId'].members.$post({ param: { teamId }, json: { token } })
|
||||
if (!res.ok) {
|
||||
const body = await res.json()
|
||||
throw new Error((body as { error?: string }).error ?? 'Failed to join')
|
||||
throw new Error((body as { error?: { message?: string } }).error?.message ?? 'Failed to join')
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
|
||||
@@ -32,11 +32,17 @@ vi.mock('@/lib/api', () => {
|
||||
class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly body: ApiErrorBody
|
||||
readonly reason: string | undefined
|
||||
readonly metadata: Record<string, string> | undefined
|
||||
readonly canonicalStatus: string | undefined
|
||||
constructor(status: number, body: ApiErrorBody) {
|
||||
super((typeof body.error === 'string' ? body.error : undefined) ?? `HTTP ${status}`)
|
||||
super(body.error.message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
this.reason = body.error.details?.[0]?.reason
|
||||
this.metadata = body.error.details?.[0]?.metadata
|
||||
this.canonicalStatus = body.error.status
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -109,7 +115,14 @@ describe('StorageCheckoutRedirect', () => {
|
||||
})
|
||||
|
||||
it('handles workspace_plan_exists error, cancels pending plan order, and retries checkout', async () => {
|
||||
const apiError = new ApiError(400, { error: { code: 'workspace_plan_exists' } } as unknown as ApiErrorBody)
|
||||
const apiError = new ApiError(409, {
|
||||
error: {
|
||||
code: 409,
|
||||
message: 'Workspace plan already exists',
|
||||
status: 'ALREADY_EXISTS',
|
||||
details: [{ reason: 'WORKSPACE_PLAN_EXISTS', domain: 'zpan.dev' }],
|
||||
},
|
||||
})
|
||||
|
||||
vi.mocked(createCloudCheckout).mockRejectedValueOnce(apiError).mockResolvedValueOnce({
|
||||
orderId: 'order-2',
|
||||
|
||||
@@ -99,13 +99,7 @@ async function createCheckoutSession(search: CheckoutSearch) {
|
||||
const result = await createCloudCheckout(search.packageId, search.priceId, search.promotionCode)
|
||||
return result.url
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
(err.body.error === 'workspace_plan_exists' ||
|
||||
(err.body.error &&
|
||||
typeof err.body.error === 'object' &&
|
||||
(err.body.error as Record<string, unknown>).code === 'workspace_plan_exists'))
|
||||
) {
|
||||
if (err instanceof ApiError && err.reason === 'WORKSPACE_PLAN_EXISTS') {
|
||||
const ordersRes = await listCloudOrders()
|
||||
const pendingPlanOrder = ordersRes.items.find(
|
||||
(order) =>
|
||||
|
||||
Reference in New Issue
Block a user