refactor(api)!: unify errors to AIP-193 + Page<T> pagination, enrich access log (#443) (#444)

* 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:
Jasper Van
2026-06-16 22:58:35 -04:00
committed by GitHub
parent e132cb9e41
commit b3ba6c00ff
94 changed files with 3392 additions and 1523 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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 })
+4 -11
View File
@@ -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 () => {
+9 -9
View File
@@ -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)
})
+21 -17
View File
@@ -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)
})
+5 -3
View File
@@ -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')
+6 -7
View File
@@ -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')
})
})
+51 -34
View File
@@ -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)
})
+3 -2
View File
@@ -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')
? {
+3 -2
View File
@@ -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 () => {
+7 -16
View File
@@ -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))
+2 -2
View File
@@ -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 () => {
+262 -27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
+99 -3
View File
@@ -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
View File
@@ -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
+139 -15
View File
@@ -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
View File
@@ -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 () => {
+17 -22
View File
@@ -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)
})
+3 -2
View File
@@ -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
View File
@@ -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 () => {
+28 -22
View File
@@ -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 () => {
+11 -6
View File
@@ -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 () => {
+3 -3
View File
@@ -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')
})
})
+16 -21
View File
@@ -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 () => {
+8 -14
View File
@@ -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' } })
})
})
+11 -11
View File
@@ -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)
+13 -7
View File
@@ -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 () => {
+25 -13
View File
@@ -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
+8 -4
View File
@@ -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
View File
@@ -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)
})
+195 -17
View File
@@ -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')
}
})
})
+33 -31
View File
@@ -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()
+6 -1
View File
@@ -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' },
})
}
/**
+5 -3
View File
@@ -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)
})
+4 -2
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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)
})
+16 -11
View File
@@ -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
View File
@@ -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)
})
+6 -1
View File
@@ -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> {
+118 -8
View File
@@ -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
View File
@@ -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
}
+4 -4
View File
@@ -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')
})
})
+10 -9
View File
@@ -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')
})
}
+267
View File
@@ -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')
})
})
+9 -8
View File
@@ -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')
})
}
+61
View File
@@ -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)
})
})
+40
View File
@@ -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 () => {
+11 -5
View File
@@ -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)
}
+105
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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()
})
+6 -1
View File
@@ -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()
})
+7 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+3 -4
View File
@@ -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(),
})
+34
View File
@@ -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'))
+2 -2
View File
@@ -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
},
})
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
}
+26 -4
View File
@@ -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({
+3 -1
View File
@@ -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'
)
}
+1 -1
View File
@@ -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()
},
+15 -2
View File
@@ -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',
+1 -7
View File
@@ -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) =>