From b3ba6c00ffc96aa8dea63809bce665aac5ff525c Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Tue, 16 Jun 2026 22:58:35 -0400 Subject: [PATCH] refactor(api)!: unify errors to AIP-193 + Page pagination, enrich access log (#443) (#444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(api)!: unify errors to AIP-193 + Page 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 = { 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/internal/openapi/client.gen.go | 1251 +++++++++-------- e2e/name-conflict.spec.ts | 2 +- .../api-keys-rate-limit.integration.test.ts | 4 +- server/app.ts | 17 +- .../http/background-jobs.integration.test.ts | 12 +- server/http/background-jobs.ts | 15 +- .../download-tasks.integration.test.ts | 52 +- server/http/downloads/download-tasks.ts | 18 +- server/http/downloads/downloaders.ts | 38 +- server/http/entitlements.ts | 8 +- server/http/image-hosting/config.ts | 13 +- .../image-hosting/images.integration.test.ts | 100 +- server/http/image-hosting/images.ts | 85 +- server/http/internal.ts | 5 +- server/http/notifications.cf-test.ts | 5 +- server/http/notifications.integration.test.ts | 7 +- server/http/notifications.ts | 23 +- server/http/objects.cf-test.ts | 4 +- server/http/objects.integration.test.ts | 289 +++- server/http/objects.ts | 131 +- server/http/openapi.ts | 21 +- server/http/quotas.ts | 18 +- server/http/redirect.integration.test.ts | 102 +- server/http/redirect.ts | 35 +- server/http/shares.integration.test.ts | 154 +- server/http/shares.ts | 90 +- .../site/announcements.integration.test.ts | 10 +- server/http/site/announcements.ts | 39 +- server/http/site/audit.integration.test.ts | 5 +- server/http/site/audit.ts | 32 +- .../site/auth-providers.integration.test.ts | 18 +- server/http/site/auth-providers.ts | 50 +- server/http/site/branding.integration.test.ts | 5 +- server/http/site/branding.ts | 17 +- .../site/email-config.integration.test.ts | 15 +- server/http/site/email-config.ts | 6 +- .../http/site/invitations.integration.test.ts | 149 +- server/http/site/invitations.ts | 37 +- .../site/invite-codes.integration.test.ts | 4 +- server/http/site/invite-codes.ts | 22 +- .../site/licensing-admin.integration.test.ts | 12 +- .../http/site/licensing.integration.test.ts | 10 +- server/http/site/licensing.ts | 22 +- server/http/site/storages.cf-test.ts | 20 +- server/http/site/storages.integration.test.ts | 13 +- server/http/site/storages.ts | 38 +- server/http/site/system.test.ts | 12 +- server/http/site/system.ts | 31 +- server/http/store/store.integration.test.ts | 212 ++- server/http/store/storefront.ts | 64 +- .../traffic-metering.integration.test.ts | 33 +- server/http/store/traffic-metering.ts | 7 +- server/http/store/webhooks.ts | 8 +- server/http/teams.integration.test.ts | 6 +- server/http/teams.ts | 74 +- server/http/trash.ts | 4 +- server/http/users.integration.test.ts | 27 +- server/http/users.ts | 58 +- server/http/webdav.ts | 7 +- server/lib/http-errors.test.ts | 126 +- server/lib/http-errors.ts | 120 +- server/middleware/auth.integration.test.ts | 8 +- server/middleware/auth.ts | 19 +- server/middleware/authz.integration.test.ts | 267 ++++ server/middleware/authz.ts | 17 +- server/middleware/error-handler.test.ts | 61 + server/middleware/error-handler.ts | 40 + .../image-hosting-domain.integration.test.ts | 15 +- server/middleware/image-hosting-domain.ts | 16 +- server/middleware/logger.test.ts | 105 ++ server/middleware/logger.ts | 40 +- server/middleware/platform.ts | 5 + server/middleware/require-feature.ts | 7 +- server/usecases/object.integration.test.ts | 11 +- .../site/signup-mode.integration.test.ts | 9 +- shared/schemas/errors.ts | 125 +- shared/schemas/index.ts | 13 +- shared/schemas/notification.ts | 7 +- shared/schemas/pagination.ts | 34 + .../files/hooks/use-conflict-resolver.test.ts | 18 +- .../files/hooks/use-conflict-resolver.ts | 2 +- .../files/transfer-space-dialog.tsx | 2 +- .../notification-dropdown.test.ts | 3 +- .../notifications/notification-dropdown.tsx | 9 +- src/components/share/save-to-drive-dialog.tsx | 2 +- src/components/team/invite-dialog.tsx | 4 +- src/components/upload/upload-dropzone.tsx | 2 +- src/lib/api.test.ts | 101 +- src/lib/api.ts | 100 +- src/routes/_authenticated/storage.test.tsx | 30 +- src/routes/_authenticated/storage.tsx | 4 +- src/routes/_authenticated/teams/invite.tsx | 2 +- src/routes/store/checkout.test.tsx | 17 +- src/routes/store/checkout.tsx | 8 +- 94 files changed, 3392 insertions(+), 1523 deletions(-) create mode 100644 server/middleware/authz.integration.test.ts create mode 100644 server/middleware/error-handler.test.ts create mode 100644 server/middleware/error-handler.ts create mode 100644 server/middleware/logger.test.ts create mode 100644 shared/schemas/pagination.ts diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index 58e1bc5f..a6ad2ac9 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -201,6 +201,21 @@ func (e DownloaderStatus) Valid() bool { } } +// Defines values for ErrorInfoType. +const ( + TypeGoogleapisComgoogleRpcErrorInfo ErrorInfoType = "type.googleapis.com/google.rpc.ErrorInfo" +) + +// Valid indicates whether the value is a known member of the ErrorInfoType enum. +func (e ErrorInfoType) Valid() bool { + switch e { + case TypeGoogleapisComgoogleRpcErrorInfo: + return true + default: + return false + } +} + // Defines values for ImageHostingConfigDomainStatus. const ( ImageHostingConfigDomainStatusNone ImageHostingConfigDomainStatus = "none" @@ -1657,7 +1672,10 @@ type AuthProviderConfig struct { // AuthProviderList defines model for AuthProviderList. type AuthProviderList struct { - Items []AuthProviderList_Items_Item `json:"items"` + Items []AuthProviderList_Items_Item `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // AuthProviderList_Items_Item defines model for AuthProviderList.items.Item. @@ -1918,6 +1936,14 @@ type DownloaderEngine string // DownloaderStatus defines model for Downloader.Status. type DownloaderStatus string +// DownloaderList defines model for DownloaderList. +type DownloaderList struct { + Items []Downloader `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` +} + // EffectiveQuota defines model for EffectiveQuota. type EffectiveQuota struct { BaseQuota int `json:"baseQuota"` @@ -1955,8 +1981,10 @@ type EmailSettings struct { // EntitlementList defines model for EntitlementList. type EntitlementList struct { - Items []QuotaEntitlement `json:"items"` - OrgId string `json:"orgId"` + Items []QuotaEntitlement `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // EntitlementResult defines model for EntitlementResult. @@ -1965,21 +1993,27 @@ type EntitlementResult struct { OrgId string `json:"orgId"` } -// ErrorResponse defines model for ErrorResponse. -type ErrorResponse struct { - Code *string `json:"code,omitempty"` - Error string `json:"error"` +// Error defines model for Error. +type Error struct { + Error struct { + Code int `json:"code"` + Details *[]ErrorInfo `json:"details,omitempty"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` } -// FeatureGateError defines model for FeatureGateError. -type FeatureGateError struct { - CurrentCount *int `json:"currentCount,omitempty"` - Error string `json:"error"` - Feature string `json:"feature"` - Limit *int `json:"limit,omitempty"` - UpgradeUrl *string `json:"upgrade_url,omitempty"` +// ErrorInfo defines model for ErrorInfo. +type ErrorInfo struct { + Type ErrorInfoType `json:"@type"` + Domain string `json:"domain"` + Metadata *map[string]string `json:"metadata,omitempty"` + Reason string `json:"reason"` } +// ErrorInfoType defines model for ErrorInfo.Type. +type ErrorInfoType string + // ImageHosting defines model for ImageHosting. type ImageHosting struct { AccessCount int `json:"accessCount"` @@ -2078,8 +2112,10 @@ type InviteCode struct { // InviteCodeList defines model for InviteCodeList. type InviteCodeList struct { - Items []InviteCode `json:"items"` - Total int `json:"total"` + Items []InviteCode `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // LicenseBindingState defines model for LicenseBindingState. @@ -2145,11 +2181,10 @@ type Notification struct { // NotificationPage defines model for NotificationPage. type NotificationPage struct { - Items []Notification `json:"items"` - Page int `json:"page"` - PageSize int `json:"pageSize"` - Total int `json:"total"` - UnreadCount int `json:"unreadCount"` + Items []Notification `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // ObjectPage defines model for ObjectPage. @@ -2213,8 +2248,10 @@ type QuotaEntitlement struct { // QuotaOverview defines model for QuotaOverview. type QuotaOverview struct { - Items []QuotaOverviewItem `json:"items"` - Total int `json:"total"` + Items []QuotaOverviewItem `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // QuotaOverviewItem defines model for QuotaOverviewItem. @@ -2377,8 +2414,10 @@ type SiteInvitation struct { // SiteInvitationList defines model for SiteInvitationList. type SiteInvitationList struct { - Items []SiteInvitation `json:"items"` - Total int `json:"total"` + Items []SiteInvitation `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // Storage defines model for Storage. @@ -2405,8 +2444,10 @@ type Storage struct { // StorageList defines model for StorageList. type StorageList struct { - Items []Storage `json:"items"` - Total int `json:"total"` + Items []Storage `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // SystemOption defines model for SystemOption. @@ -2418,8 +2459,18 @@ type SystemOption struct { // SystemOptionList defines model for SystemOptionList. type SystemOptionList struct { - Items []SystemOption `json:"items"` - Total int `json:"total"` + Items []SystemOption `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` +} + +// TeamInvitationList defines model for TeamInvitationList. +type TeamInvitationList struct { + Items []PendingInvitation `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // TeamInviteLinkCreated defines model for TeamInviteLinkCreated. @@ -2438,8 +2489,10 @@ type TeamInviteLinkInfo struct { // TeamList defines model for TeamList. type TeamList struct { - Items []TeamSummary `json:"items"` - Total int `json:"total"` + Items []TeamSummary `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // TeamSummary defines model for TeamSummary. @@ -2495,8 +2548,10 @@ type UserDetail1 struct { // UserList defines model for UserList. type UserList struct { - Items []User `json:"items"` - Total int `json:"total"` + Items []User `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` } // BanUserJSONBody defines parameters for BanUser. @@ -3508,20 +3563,20 @@ type PresignImageHostingUploadJSONBodyMime string // ListNotificationsParams defines parameters for ListNotifications. type ListNotificationsParams struct { - Page *string `form:"page,omitempty" json:"page,omitempty"` - PageSize *string `form:"pageSize,omitempty" json:"pageSize,omitempty"` + Page *int `form:"page,omitempty" json:"page,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` Unread *string `form:"unread,omitempty" json:"unread,omitempty"` } // ListObjectsParams defines parameters for ListObjects. type ListObjectsParams struct { + Page *int `form:"page,omitempty" json:"page,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` Parent *string `form:"parent,omitempty" json:"parent,omitempty"` Path *string `form:"path,omitempty" json:"path,omitempty"` Status *string `form:"status,omitempty" json:"status,omitempty"` Type *string `form:"type,omitempty" json:"type,omitempty"` Search *string `form:"search,omitempty" json:"search,omitempty"` - Page *string `form:"page,omitempty" json:"page,omitempty"` - PageSize *string `form:"pageSize,omitempty" json:"pageSize,omitempty"` OrgId *string `form:"orgId,omitempty" json:"orgId,omitempty"` } @@ -3678,10 +3733,10 @@ type VerifySharePassword200JSONResponseBodyOk bool // ListAnnouncementsParams defines parameters for ListAnnouncements. type ListAnnouncementsParams struct { + Page *int `form:"page,omitempty" json:"page,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` Scope *ListAnnouncementsParamsScope `form:"scope,omitempty" json:"scope,omitempty"` Status *ListAnnouncementsParamsStatus `form:"status,omitempty" json:"status,omitempty"` - Page *string `form:"page,omitempty" json:"page,omitempty"` - PageSize *string `form:"pageSize,omitempty" json:"pageSize,omitempty"` } // ListAnnouncementsParamsScope defines parameters for ListAnnouncements. @@ -3717,8 +3772,8 @@ type UpdateAnnouncementJSONBodyStatus string // ListAuditEventsParams defines parameters for ListAuditEvents. type ListAuditEventsParams struct { - Page *string `form:"page,omitempty" json:"page,omitempty"` - PageSize *string `form:"pageSize,omitempty" json:"pageSize,omitempty"` + Page *int `form:"page,omitempty" json:"page,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` OrgId *string `form:"orgId,omitempty" json:"orgId,omitempty"` UserId *string `form:"userId,omitempty" json:"userId,omitempty"` Action *string `form:"action,omitempty" json:"action,omitempty"` @@ -3923,8 +3978,8 @@ type CancelOrderJSONBodyStatus string // ListTeamActivityParams defines parameters for ListTeamActivity. type ListTeamActivityParams struct { - Page *string `form:"page,omitempty" json:"page,omitempty"` - PageSize *string `form:"pageSize,omitempty" json:"pageSize,omitempty"` + Page *int `form:"page,omitempty" json:"page,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` } // GrantTeamEntitlementJSONBody defines parameters for GrantTeamEntitlement. @@ -3972,8 +4027,8 @@ type DeleteUsersJSONBody struct { // AdminListUsersParams defines parameters for AdminListUsers. type AdminListUsersParams struct { - Page *string `form:"page,omitempty" json:"page,omitempty"` - PageSize *string `form:"pageSize,omitempty" json:"pageSize,omitempty"` + Page *int `form:"page,omitempty" json:"page,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` Search *string `form:"search,omitempty" json:"search,omitempty"` } @@ -14222,7 +14277,7 @@ func NewListNotificationsRequest(server string, params *ListNotificationsParams) if params.Page != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -14234,7 +14289,7 @@ func NewListNotificationsRequest(server string, params *ListNotificationsParams) if params.PageSize != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -14386,6 +14441,30 @@ func NewListObjectsRequest(server string, params *ListObjectsParams) (*http.Requ // per the OpenAPI spec (e.g. "color=blue,black,brown"). var rawQueryFragments []string + if params.Page != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.PageSize != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.Parent != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "parent", *params.Parent, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { @@ -14446,30 +14525,6 @@ func NewListObjectsRequest(server string, params *ListObjectsParams) (*http.Requ } - if params.Page != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.PageSize != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - if params.OrgId != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "orgId", *params.OrgId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { @@ -15447,6 +15502,30 @@ func NewListAnnouncementsRequest(server string, params *ListAnnouncementsParams) // per the OpenAPI spec (e.g. "color=blue,black,brown"). var rawQueryFragments []string + if params.Page != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.PageSize != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.Scope != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "scope", *params.Scope, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { @@ -15471,30 +15550,6 @@ func NewListAnnouncementsRequest(server string, params *ListAnnouncementsParams) } - if params.Page != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.PageSize != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - if encoded := queryValues.Encode(); encoded != "" { rawQueryFragments = append(rawQueryFragments, encoded) } @@ -15694,7 +15749,7 @@ func NewListAuditEventsRequest(server string, params *ListAuditEventsParams) (*h if params.Page != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -15706,7 +15761,7 @@ func NewListAuditEventsRequest(server string, params *ListAuditEventsParams) (*h if params.PageSize != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -17577,7 +17632,7 @@ func NewListTeamActivityRequest(server string, teamId string, params *ListTeamAc if params.Page != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -17589,7 +17644,7 @@ func NewListTeamActivityRequest(server string, teamId string, params *ListTeamAc if params.PageSize != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -18082,7 +18137,7 @@ func NewAdminListUsersRequest(server string, params *AdminListUsersParams) (*htt if params.Page != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -18094,7 +18149,7 @@ func NewAdminListUsersRequest(server string, params *AdminListUsersParams) (*htt if params.PageSize != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -23986,7 +24041,7 @@ type ListBackgroundJobsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *BackgroundJobPage - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24017,7 +24072,7 @@ type CreateBackgroundJobResponse struct { Body []byte HTTPResponse *http.Response JSON201 *BackgroundJob - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24048,7 +24103,7 @@ type GetBackgroundJobResponse struct { Body []byte HTTPResponse *http.Response JSON200 *BackgroundJob - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24079,8 +24134,8 @@ type RetryBackgroundJobResponse struct { Body []byte HTTPResponse *http.Response JSON201 *BackgroundJob - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24111,8 +24166,8 @@ type CancelBackgroundJobResponse struct { Body []byte HTTPResponse *http.Response JSON200 *BackgroundJob - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24142,11 +24197,8 @@ func (r CancelBackgroundJobResponse) ContentType() string { type ListDownloadersResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *struct { - Items []Downloader `json:"items"` - Total int `json:"total"` - } - JSON401 *ErrorResponse + JSON200 *DownloaderList + JSON401 *Error } // Status returns HTTPResponse.Status @@ -24180,8 +24232,8 @@ type CreateDownloaderResponse struct { Downloader Downloader `json:"downloader"` Token string `json:"token"` } - JSON401 *ErrorResponse - JSON402 *FeatureGateError + JSON401 *Error + JSON402 *Error } // Status returns HTTPResponse.Status @@ -24212,8 +24264,8 @@ type RecordDownloaderHeartbeatResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Downloader - JSON401 *ErrorResponse - JSON404 *ErrorResponse + JSON401 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24247,7 +24299,7 @@ type DeleteDownloaderResponse struct { Deleted bool `json:"deleted"` Id string `json:"id"` } - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24278,8 +24330,8 @@ type UpdateDownloaderResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Downloader - JSON402 *FeatureGateError - JSON404 *ErrorResponse + JSON402 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24310,7 +24362,7 @@ type ListDownloadTasksResponse struct { Body []byte HTTPResponse *http.Response JSON200 *DownloadTaskPage - JSON401 *ErrorResponse + JSON401 *Error } // Status returns HTTPResponse.Status @@ -24341,10 +24393,10 @@ type CreateDownloadTaskResponse struct { Body []byte HTTPResponse *http.Response JSON201 *DownloadTask - JSON401 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24378,10 +24430,10 @@ type DeleteDownloadTaskResponse struct { Deleted DeleteDownloadTask200JSONResponseBodyDeleted `json:"deleted"` Id string `json:"id"` } - JSON401 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24412,10 +24464,10 @@ type GetDownloadTaskResponse struct { Body []byte HTTPResponse *http.Response JSON200 *DownloadTask - JSON401 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24446,10 +24498,10 @@ type UpdateDownloadTaskResponse struct { Body []byte HTTPResponse *http.Response JSON200 *DownloadTask - JSON401 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24480,10 +24532,10 @@ type RetryDownloadTaskResponse struct { Body []byte HTTPResponse *http.Response JSON201 *DownloadTask - JSON401 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24514,10 +24566,10 @@ type SetDownloadTaskStatusResponse struct { Body []byte HTTPResponse *http.Response JSON200 *DownloadTask - JSON401 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24547,7 +24599,7 @@ func (r SetDownloadTaskStatusResponse) ContentType() string { type StreamEventsResponse struct { Body []byte HTTPResponse *http.Response - JSON401 *ErrorResponse + JSON401 *Error } // Status returns HTTPResponse.Status @@ -24577,7 +24629,7 @@ func (r StreamEventsResponse) ContentType() string { type DeleteImageHostingConfigResponse struct { Body []byte HTTPResponse *http.Response - JSON401 *ErrorResponse + JSON401 *Error } // Status returns HTTPResponse.Status @@ -24608,7 +24660,7 @@ type GetImageHostingConfigResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ImageHostingConfigResponse - JSON401 *ErrorResponse + JSON401 *Error } // Status returns HTTPResponse.Status @@ -24639,9 +24691,9 @@ type UpdateImageHostingConfigResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ImageHostingConfig - JSON400 *ErrorResponse - JSON401 *ErrorResponse - JSON409 *ErrorResponse + JSON400 *Error + JSON401 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -24672,8 +24724,8 @@ type ListImageHostingsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ImageHostingList - JSON400 *ErrorResponse - JSON403 *ErrorResponse + JSON400 *Error + JSON403 *Error } // Status returns HTTPResponse.Status @@ -24704,13 +24756,10 @@ type PresignImageHostingUploadResponse struct { Body []byte HTTPResponse *http.Response JSON201 *ImageHostingDraft - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON413 *struct { - Error string `json:"error"` - MaxBytes int `json:"maxBytes"` - } - JSON503 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON413 *Error + JSON503 *Error } // Status returns HTTPResponse.Status @@ -24740,9 +24789,9 @@ func (r PresignImageHostingUploadResponse) ContentType() string { type DeleteImageHostingResponse struct { Body []byte HTTPResponse *http.Response - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24773,9 +24822,9 @@ type GetImageHostingResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ImageHosting - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24806,10 +24855,10 @@ type ConfirmImageHostingResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ImageHosting - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON422 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON422 *Error } // Status returns HTTPResponse.Status @@ -24933,7 +24982,7 @@ func (r GetNotificationStatsResponse) ContentType() string { type MarkNotificationReadResponse struct { Body []byte HTTPResponse *http.Response - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -24964,8 +25013,8 @@ type ListObjectsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ObjectPage - JSON400 *ErrorResponse - JSON403 *ErrorResponse + JSON400 *Error + JSON403 *Error } // Status returns HTTPResponse.Status @@ -25013,10 +25062,10 @@ type CreateObjectResponse struct { UpdatedAt string `json:"updatedAt"` UploadUrl *string `json:"uploadUrl,omitempty"` } - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON409 *ErrorResponse - JSON500 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON409 *Error + JSON503 *Error } // Status returns HTTPResponse.Status @@ -25051,9 +25100,9 @@ type DeleteObjectResponse struct { Id string `json:"id"` Purged DeleteObject200JSONResponseBody_Purged `json:"purged"` } - JSON400 *ErrorResponse - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON400 *Error + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -25100,14 +25149,10 @@ type GetObjectResponse struct { Type string `json:"type"` UpdatedAt string `json:"updatedAt"` } - JSON400 *ErrorResponse - JSON402 *struct { - Code string `json:"code"` - Error string `json:"error"` - Resource string `json:"resource"` - } - JSON404 *ErrorResponse - JSON422 *ErrorResponse + JSON400 *Error + JSON402 *Error + JSON404 *Error + JSON422 *Error } // Status returns HTTPResponse.Status @@ -25138,8 +25183,8 @@ type UpdateObjectResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Matter - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25170,8 +25215,8 @@ type CopyObjectResponse struct { Body []byte HTTPResponse *http.Response JSON201 *Matter - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25202,10 +25247,10 @@ type SetObjectStatusResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Matter - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON422 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON422 *Error } // Status returns HTTPResponse.Status @@ -25236,10 +25281,10 @@ type TransferObjectResponse struct { Body []byte HTTPResponse *http.Response JSON201 *TransferResult - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON422 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON422 *Error } // Status returns HTTPResponse.Status @@ -25279,10 +25324,10 @@ type CreateObjectUploadSessionResponse struct { UpdatedAt string `json:"updatedAt"` UploadId string `json:"uploadId"` } - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -25322,9 +25367,9 @@ type AbortObjectUploadResponse struct { UpdatedAt string `json:"updatedAt"` UploadId string `json:"uploadId"` } - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25362,10 +25407,10 @@ type PresignObjectUploadPartsResponse struct { } `json:"parts"` UploadId string `json:"uploadId"` } - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -25405,10 +25450,10 @@ type CompleteObjectUploadResponse struct { UpdatedAt string `json:"updatedAt"` UploadId string `json:"uploadId"` } - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -25469,7 +25514,7 @@ type GetMyQuotaResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EffectiveQuota - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25530,8 +25575,8 @@ type CreateShareResponse struct { Body []byte HTTPResponse *http.Response JSON201 *CreatedShare - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25561,8 +25606,8 @@ func (r CreateShareResponse) ContentType() string { type RevokeShareResponse struct { Body []byte HTTPResponse *http.Response - JSON403 *ErrorResponse - JSON404 *ErrorResponse + JSON403 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25593,8 +25638,8 @@ type GetShareResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ShareView - JSON404 *ErrorResponse - JSON410 *ErrorResponse + JSON404 *Error + JSON410 *Error } // Status returns HTTPResponse.Status @@ -25625,10 +25670,10 @@ type ListShareObjectsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ShareObjects - JSON400 *ErrorResponse - JSON401 *ErrorResponse - JSON404 *ErrorResponse - JSON410 *ErrorResponse + JSON400 *Error + JSON401 *Error + JSON404 *Error + JSON410 *Error } // Status returns HTTPResponse.Status @@ -25659,11 +25704,11 @@ type SaveShareResponse struct { Body []byte HTTPResponse *http.Response JSON201 *SaveShareResult - JSON400 *ErrorResponse - JSON401 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON410 *ErrorResponse + JSON400 *Error + JSON401 *Error + JSON403 *Error + JSON404 *Error + JSON410 *Error } // Status returns HTTPResponse.Status @@ -25696,8 +25741,8 @@ type VerifySharePasswordResponse struct { JSON200 *struct { Ok VerifySharePassword200JSONResponseBodyOk `json:"ok"` } - JSON403 *ErrorResponse - JSON404 *ErrorResponse + JSON403 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25728,7 +25773,7 @@ type ListAnnouncementsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *AnnouncementList - JSON403 *ErrorResponse + JSON403 *Error } // Status returns HTTPResponse.Status @@ -25792,7 +25837,7 @@ type DeleteAnnouncementResponse struct { Deleted DeleteAnnouncement200JSONResponseBodyDeleted `json:"deleted"` Id string `json:"id"` } - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25823,7 +25868,7 @@ type GetAnnouncementResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Announcement - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25854,7 +25899,7 @@ type UpdateAnnouncementResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Announcement - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -25948,7 +25993,7 @@ type DeleteAuthProviderResponse struct { Deleted DeleteAuthProvider200JSONResponseBodyDeleted `json:"deleted"` ProviderId string `json:"providerId"` } - JSON400 *ErrorResponse + JSON400 *Error } // Status returns HTTPResponse.Status @@ -25979,8 +26024,8 @@ type UpsertAuthProviderResponse struct { Body []byte HTTPResponse *http.Response JSON200 *AuthProviderConfig - JSON400 *ErrorResponse - JSON402 *FeatureGateError + JSON400 *Error + JSON402 *Error } // Status returns HTTPResponse.Status @@ -26041,11 +26086,11 @@ type UpdateBrandingResponse struct { Body []byte HTTPResponse *http.Response JSON200 *BrandingConfig - JSON400 *ErrorResponse - JSON413 *ErrorResponse - JSON415 *ErrorResponse - JSON422 *ErrorResponse - JSON503 *ErrorResponse + JSON400 *Error + JSON413 *Error + JSON415 *Error + JSON422 *Error + JSON503 *Error } // Status returns HTTPResponse.Status @@ -26079,7 +26124,7 @@ type ResetBrandingFieldResponse struct { Field string `json:"field"` Reset ResetBrandingField200JSONResponseBodyReset `json:"reset"` } - JSON400 *ErrorResponse + JSON400 *Error } // Status returns HTTPResponse.Status @@ -26204,10 +26249,7 @@ type SendTestEmailResponse struct { JSON200 *struct { Success bool `json:"success"` } - JSON400 *struct { - Error string `json:"error"` - Success bool `json:"success"` - } + JSON400 *Error } // Status returns HTTPResponse.Status @@ -26298,8 +26340,8 @@ type CreateSiteInvitationResponse struct { Body []byte HTTPResponse *http.Response JSON201 *SiteInvitation - JSON401 *ErrorResponse - JSON409 *ErrorResponse + JSON401 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -26333,9 +26375,9 @@ type RevokeSiteInvitationResponse struct { Id string `json:"id"` Revoked RevokeSiteInvitation200JSONResponseBodyRevoked `json:"revoked"` } - JSON400 *ErrorResponse - JSON401 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON401 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -26366,8 +26408,8 @@ type ResendSiteInvitationResponse struct { Body []byte HTTPResponse *http.Response JSON200 *SiteInvitation - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -26398,7 +26440,7 @@ type GetSiteInvitationResponse struct { Body []byte HTTPResponse *http.Response JSON200 *SiteInvitation - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -26461,7 +26503,7 @@ type GenerateInviteCodesResponse struct { JSON201 *struct { Codes []InviteCode `json:"codes"` } - JSON401 *ErrorResponse + JSON401 *Error } // Status returns HTTPResponse.Status @@ -26528,8 +26570,8 @@ type DeleteInviteCodeResponse struct { Deleted DeleteInviteCode200JSONResponseBodyDeleted `json:"deleted"` Id string `json:"id"` } - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -26623,11 +26665,7 @@ type PollLicensePairingResponse struct { Body []byte HTTPResponse *http.Response JSON200 *LicensePairingStatus - JSON502 *struct { - CloudUnbindError *string `json:"cloud_unbind_error"` - Error string `json:"error"` - Reason string `json:"reason"` - } + JSON502 *Error } // Status returns HTTPResponse.Status @@ -26784,8 +26822,8 @@ type GetSystemOptionResponse struct { Body []byte HTTPResponse *http.Response JSON200 *SystemOption - JSON403 *ErrorResponse - JSON404 *ErrorResponse + JSON403 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -26817,8 +26855,8 @@ type SetSystemOptionResponse struct { HTTPResponse *http.Response JSON200 *SystemOption JSON201 *SystemOption - JSON400 *ErrorResponse - JSON402 *FeatureGateError + JSON400 *Error + JSON402 *Error } // Status returns HTTPResponse.Status @@ -26879,7 +26917,7 @@ type CreateStorageResponse struct { Body []byte HTTPResponse *http.Response JSON201 *Storage - JSON402 *FeatureGateError + JSON402 *Error } // Status returns HTTPResponse.Status @@ -26913,8 +26951,8 @@ type DeleteStorageResponse struct { Deleted DeleteStorage200JSONResponseBodyDeleted `json:"deleted"` Id string `json:"id"` } - JSON404 *ErrorResponse - JSON409 *ErrorResponse + JSON404 *Error + JSON409 *Error } // Status returns HTTPResponse.Status @@ -26945,7 +26983,7 @@ type GetStorageResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Storage - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -26976,8 +27014,8 @@ type UpdateStorageResponse struct { Body []byte HTTPResponse *http.Response JSON200 *Storage - JSON402 *FeatureGateError - JSON404 *ErrorResponse + JSON402 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27008,9 +27046,9 @@ type CreateBillingPortalSessionResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27041,10 +27079,10 @@ type CreateCheckoutResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON409 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON409 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27075,9 +27113,9 @@ type GetCreditBalanceResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27108,9 +27146,9 @@ type GetCreditLedgerResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27141,8 +27179,8 @@ type ListCreditProductsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27173,9 +27211,9 @@ type RedeemGiftCardResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27206,8 +27244,8 @@ type GetDiscountQuoteResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27238,9 +27276,9 @@ type ListOrdersResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27271,10 +27309,10 @@ type CancelOrderResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27305,10 +27343,10 @@ type ContinueOrderPaymentResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON404 *ErrorResponse - JSON502 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27339,8 +27377,8 @@ type ListStorePackagesResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27371,8 +27409,8 @@ type ListStoreTargetsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *CloudStoreValue - JSON403 *ErrorResponse - JSON502 *ErrorResponse + JSON403 *Error + JSON502 *Error } // Status returns HTTPResponse.Status @@ -27433,7 +27471,7 @@ type GetTeamInviteLinkResponse struct { Body []byte HTTPResponse *http.Response JSON200 *TeamInviteLinkInfo - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27464,7 +27502,7 @@ type GetTeamResponse struct { Body []byte HTTPResponse *http.Response JSON200 *TeamSummary - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27495,7 +27533,7 @@ type ListTeamActivityResponse struct { Body []byte HTTPResponse *http.Response JSON200 *ActivityPage - JSON403 *ErrorResponse + JSON403 *Error } // Status returns HTTPResponse.Status @@ -27526,8 +27564,8 @@ type ListTeamEntitlementsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EntitlementList - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27558,8 +27596,8 @@ type GrantTeamEntitlementResponse struct { Body []byte HTTPResponse *http.Response JSON201 *EntitlementResult - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27590,8 +27628,8 @@ type RevokeTeamEntitlementResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EntitlementResult - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27622,8 +27660,8 @@ type UpdateTeamEntitlementResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EntitlementResult - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27653,10 +27691,8 @@ func (r UpdateTeamEntitlementResponse) ContentType() string { type ListTeamInvitationsResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *struct { - Invitations []PendingInvitation `json:"invitations"` - } - JSON403 *ErrorResponse + JSON200 *TeamInvitationList + JSON403 *Error } // Status returns HTTPResponse.Status @@ -27687,7 +27723,7 @@ type CreateTeamInviteLinkResponse struct { Body []byte HTTPResponse *http.Response JSON201 *TeamInviteLinkCreated - JSON403 *ErrorResponse + JSON403 *Error } // Status returns HTTPResponse.Status @@ -27720,7 +27756,7 @@ type DeleteTeamLogoResponse struct { JSON200 *struct { Ok DeleteTeamLogo200JSONResponseBodyOk `json:"ok"` } - JSON403 *ErrorResponse + JSON403 *Error } // Status returns HTTPResponse.Status @@ -27753,11 +27789,11 @@ type SetTeamLogoResponse struct { JSON200 *struct { Url string `json:"url"` } - JSON400 *ErrorResponse - JSON403 *ErrorResponse - JSON413 *ErrorResponse - JSON415 *ErrorResponse - JSON503 *ErrorResponse + JSON400 *Error + JSON403 *Error + JSON413 *Error + JSON415 *Error + JSON503 *Error } // Status returns HTTPResponse.Status @@ -27790,9 +27826,9 @@ type JoinTeamResponse struct { JSON200 *struct { Ok JoinTeam200JSONResponseBodyOk `json:"ok"` } - JSON404 *ErrorResponse - JSON409 *ErrorResponse - JSON410 *ErrorResponse + JSON404 *Error + JSON409 *Error + JSON410 *Error } // Status returns HTTPResponse.Status @@ -27825,7 +27861,7 @@ type EmptyTrashResponse struct { JSON200 *struct { Purged int `json:"purged"` } - JSON400 *ErrorResponse + JSON400 *Error } // Status returns HTTPResponse.Status @@ -27859,8 +27895,8 @@ type DeleteUsersResponse struct { Deleted int `json:"deleted"` Ids []string `json:"ids"` } - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27925,8 +27961,8 @@ type SetUsersStatusResponse struct { Status string `json:"status"` Updated int `json:"updated"` } - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -27991,10 +28027,10 @@ type SetMyAvatarResponse struct { JSON200 *struct { Url string `json:"url"` } - JSON400 *ErrorResponse - JSON413 *ErrorResponse - JSON415 *ErrorResponse - JSON503 *ErrorResponse + JSON400 *Error + JSON413 *Error + JSON415 *Error + JSON503 *Error } // Status returns HTTPResponse.Status @@ -28028,7 +28064,7 @@ type AdminDeleteUserResponse struct { Deleted AdminDeleteUser200JSONResponseBodyDeleted `json:"deleted"` Id string `json:"id"` } - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -28059,8 +28095,8 @@ type GetUserProfileResponse struct { Body []byte HTTPResponse *http.Response JSON200 *UserDetail - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -28094,7 +28130,7 @@ type SetUserStatusResponse struct { Id string `json:"id"` Status string `json:"status"` } - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -28125,8 +28161,8 @@ type ListUserEntitlementsResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EntitlementList - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -28157,8 +28193,8 @@ type GrantUserEntitlementResponse struct { Body []byte HTTPResponse *http.Response JSON201 *EntitlementResult - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -28189,8 +28225,8 @@ type RevokeUserEntitlementResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EntitlementResult - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -28221,8 +28257,8 @@ type UpdateUserEntitlementResponse struct { Body []byte HTTPResponse *http.Response JSON200 *EntitlementResult - JSON400 *ErrorResponse - JSON404 *ErrorResponse + JSON400 *Error + JSON404 *Error } // Status returns HTTPResponse.Status @@ -28256,7 +28292,7 @@ type ListUserObjectsResponse struct { Breadcrumb []*interface{} `json:"breadcrumb"` Items []*interface{} `json:"items"` } - JSON404 *ErrorResponse + JSON404 *Error } // Status returns HTTPResponse.Status @@ -38186,7 +38222,7 @@ func ParseListBackgroundJobsResponse(rsp *http.Response) (*ListBackgroundJobsRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38219,7 +38255,7 @@ func ParseCreateBackgroundJobResponse(rsp *http.Response) (*CreateBackgroundJobR response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38252,7 +38288,7 @@ func ParseGetBackgroundJobResponse(rsp *http.Response) (*GetBackgroundJobRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38285,14 +38321,14 @@ func ParseRetryBackgroundJobResponse(rsp *http.Response) (*RetryBackgroundJobRes response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38325,14 +38361,14 @@ func ParseCancelBackgroundJobResponse(rsp *http.Response) (*CancelBackgroundJobR response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38358,17 +38394,14 @@ func ParseListDownloadersResponse(rsp *http.Response) (*ListDownloadersResponse, switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest struct { - Items []Downloader `json:"items"` - Total int `json:"total"` - } + var dest DownloaderList if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38404,14 +38437,14 @@ func ParseCreateDownloaderResponse(rsp *http.Response) (*CreateDownloaderRespons response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest FeatureGateError + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38444,14 +38477,14 @@ func ParseRecordDownloaderHeartbeatResponse(rsp *http.Response) (*RecordDownload response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38487,7 +38520,7 @@ func ParseDeleteDownloaderResponse(rsp *http.Response) (*DeleteDownloaderRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38520,14 +38553,14 @@ func ParseUpdateDownloaderResponse(rsp *http.Response) (*UpdateDownloaderRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest FeatureGateError + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON402 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38560,7 +38593,7 @@ func ParseListDownloadTasksResponse(rsp *http.Response) (*ListDownloadTasksRespo response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38593,28 +38626,28 @@ func ParseCreateDownloadTaskResponse(rsp *http.Response) (*CreateDownloadTaskRes response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38650,28 +38683,28 @@ func ParseDeleteDownloadTaskResponse(rsp *http.Response) (*DeleteDownloadTaskRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38704,28 +38737,28 @@ func ParseGetDownloadTaskResponse(rsp *http.Response) (*GetDownloadTaskResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38758,28 +38791,28 @@ func ParseUpdateDownloadTaskResponse(rsp *http.Response) (*UpdateDownloadTaskRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38812,28 +38845,28 @@ func ParseRetryDownloadTaskResponse(rsp *http.Response) (*RetryDownloadTaskRespo response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38866,28 +38899,28 @@ func ParseSetDownloadTaskStatusResponse(rsp *http.Response) (*SetDownloadTaskSta response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38913,7 +38946,7 @@ func ParseStreamEventsResponse(rsp *http.Response) (*StreamEventsResponse, error switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38939,7 +38972,7 @@ func ParseDeleteImageHostingConfigResponse(rsp *http.Response) (*DeleteImageHost switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -38972,7 +39005,7 @@ func ParseGetImageHostingConfigResponse(rsp *http.Response) (*GetImageHostingCon response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39005,21 +39038,21 @@ func ParseUpdateImageHostingConfigResponse(rsp *http.Response) (*UpdateImageHost response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39052,14 +39085,14 @@ func ParseListImageHostingsResponse(rsp *http.Response) (*ListImageHostingsRespo response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39092,31 +39125,28 @@ func ParsePresignImageHostingUploadResponse(rsp *http.Response) (*PresignImageHo response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: - var dest struct { - Error string `json:"error"` - MaxBytes int `json:"maxBytes"` - } + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON413 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39142,21 +39172,21 @@ func ParseDeleteImageHostingResponse(rsp *http.Response) (*DeleteImageHostingRes switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39189,21 +39219,21 @@ func ParseGetImageHostingResponse(rsp *http.Response) (*GetImageHostingResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39236,28 +39266,28 @@ func ParseConfirmImageHostingResponse(rsp *http.Response) (*ConfirmImageHostingR response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39365,7 +39395,7 @@ func ParseMarkNotificationReadResponse(rsp *http.Response) (*MarkNotificationRea switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39398,14 +39428,14 @@ func ParseListObjectsResponse(rsp *http.Response) (*ListObjectsResponse, error) response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39455,32 +39485,32 @@ func ParseCreateObjectResponse(rsp *http.Response) (*CreateObjectResponse, error response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON409 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: - var dest ErrorResponse + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON500 = &dest + response.JSON503 = &dest } @@ -39513,21 +39543,21 @@ func ParseDeleteObjectResponse(rsp *http.Response) (*DeleteObjectResponse, error response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39576,32 +39606,28 @@ func ParseGetObjectResponse(rsp *http.Response) (*GetObjectResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest struct { - Code string `json:"code"` - Error string `json:"error"` - Resource string `json:"resource"` - } + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON402 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39634,14 +39660,14 @@ func ParseUpdateObjectResponse(rsp *http.Response) (*UpdateObjectResponse, error response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39674,14 +39700,14 @@ func ParseCopyObjectResponse(rsp *http.Response) (*CopyObjectResponse, error) { response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39714,28 +39740,28 @@ func ParseSetObjectStatusResponse(rsp *http.Response) (*SetObjectStatusResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39768,28 +39794,28 @@ func ParseTransferObjectResponse(rsp *http.Response) (*TransferObjectResponse, e response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39831,28 +39857,28 @@ func ParseCreateObjectUploadSessionResponse(rsp *http.Response) (*CreateObjectUp response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39894,21 +39920,21 @@ func ParseAbortObjectUploadResponse(rsp *http.Response) (*AbortObjectUploadRespo response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -39948,28 +39974,28 @@ func ParsePresignObjectUploadPartsResponse(rsp *http.Response) (*PresignObjectUp response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40011,28 +40037,28 @@ func ParseCompleteObjectUploadResponse(rsp *http.Response) (*CompleteObjectUploa response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40091,7 +40117,7 @@ func ParseGetMyQuotaResponse(rsp *http.Response) (*GetMyQuotaResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40150,14 +40176,14 @@ func ParseCreateShareResponse(rsp *http.Response) (*CreateShareResponse, error) response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40183,14 +40209,14 @@ func ParseRevokeShareResponse(rsp *http.Response) (*RevokeShareResponse, error) switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40223,14 +40249,14 @@ func ParseGetShareResponse(rsp *http.Response) (*GetShareResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 410: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40263,28 +40289,28 @@ func ParseListShareObjectsResponse(rsp *http.Response) (*ListShareObjectsRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 410: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40317,35 +40343,35 @@ func ParseSaveShareResponse(rsp *http.Response) (*SaveShareResponse, error) { response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 410: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40380,14 +40406,14 @@ func ParseVerifySharePasswordResponse(rsp *http.Response) (*VerifySharePasswordR response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40420,7 +40446,7 @@ func ParseListAnnouncementsResponse(rsp *http.Response) (*ListAnnouncementsRespo response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40482,7 +40508,7 @@ func ParseDeleteAnnouncementResponse(rsp *http.Response) (*DeleteAnnouncementRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40515,7 +40541,7 @@ func ParseGetAnnouncementResponse(rsp *http.Response) (*GetAnnouncementResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40548,7 +40574,7 @@ func ParseUpdateAnnouncementResponse(rsp *http.Response) (*UpdateAnnouncementRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40636,7 +40662,7 @@ func ParseDeleteAuthProviderResponse(rsp *http.Response) (*DeleteAuthProviderRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40669,14 +40695,14 @@ func ParseUpsertAuthProviderResponse(rsp *http.Response) (*UpsertAuthProviderRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest FeatureGateError + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40735,35 +40761,35 @@ func ParseUpdateBrandingResponse(rsp *http.Response) (*UpdateBrandingResponse, e response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON413 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 415: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON415 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON422 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40799,7 +40825,7 @@ func ParseResetBrandingFieldResponse(rsp *http.Response) (*ResetBrandingFieldRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -40914,10 +40940,7 @@ func ParseSendTestEmailResponse(rsp *http.Response) (*SendTestEmailResponse, err response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest struct { - Error string `json:"error"` - Success bool `json:"success"` - } + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41002,14 +41025,14 @@ func ParseCreateSiteInvitationResponse(rsp *http.Response) (*CreateSiteInvitatio response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41045,21 +41068,21 @@ func ParseRevokeSiteInvitationResponse(rsp *http.Response) (*RevokeSiteInvitatio response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON401 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41092,14 +41115,14 @@ func ParseResendSiteInvitationResponse(rsp *http.Response) (*ResendSiteInvitatio response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41132,7 +41155,7 @@ func ParseGetSiteInvitationResponse(rsp *http.Response) (*GetSiteInvitationRespo response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41193,7 +41216,7 @@ func ParseGenerateInviteCodesResponse(rsp *http.Response) (*GenerateInviteCodesR response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41258,14 +41281,14 @@ func ParseDeleteInviteCodeResponse(rsp *http.Response) (*DeleteInviteCodeRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41353,11 +41376,7 @@ func ParsePollLicensePairingResponse(rsp *http.Response) (*PollLicensePairingRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest struct { - CloudUnbindError *string `json:"cloud_unbind_error"` - Error string `json:"error"` - Reason string `json:"reason"` - } + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41500,14 +41519,14 @@ func ParseGetSystemOptionResponse(rsp *http.Response) (*GetSystemOptionResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41547,14 +41566,14 @@ func ParseSetSystemOptionResponse(rsp *http.Response) (*SetSystemOptionResponse, response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest FeatureGateError + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41613,7 +41632,7 @@ func ParseCreateStorageResponse(rsp *http.Response) (*CreateStorageResponse, err response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest FeatureGateError + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41649,14 +41668,14 @@ func ParseDeleteStorageResponse(rsp *http.Response) (*DeleteStorageResponse, err response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41689,7 +41708,7 @@ func ParseGetStorageResponse(rsp *http.Response) (*GetStorageResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41722,14 +41741,14 @@ func ParseUpdateStorageResponse(rsp *http.Response) (*UpdateStorageResponse, err response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: - var dest FeatureGateError + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON402 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41762,21 +41781,21 @@ func ParseCreateBillingPortalSessionResponse(rsp *http.Response) (*CreateBilling response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41809,28 +41828,28 @@ func ParseCreateCheckoutResponse(rsp *http.Response) (*CreateCheckoutResponse, e response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON409 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41863,21 +41882,21 @@ func ParseGetCreditBalanceResponse(rsp *http.Response) (*GetCreditBalanceRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41910,21 +41929,21 @@ func ParseGetCreditLedgerResponse(rsp *http.Response) (*GetCreditLedgerResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41957,14 +41976,14 @@ func ParseListCreditProductsResponse(rsp *http.Response) (*ListCreditProductsRes response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -41997,21 +42016,21 @@ func ParseRedeemGiftCardResponse(rsp *http.Response) (*RedeemGiftCardResponse, e response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42044,14 +42063,14 @@ func ParseGetDiscountQuoteResponse(rsp *http.Response) (*GetDiscountQuoteRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42084,21 +42103,21 @@ func ParseListOrdersResponse(rsp *http.Response) (*ListOrdersResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42131,28 +42150,28 @@ func ParseCancelOrderResponse(rsp *http.Response) (*CancelOrderResponse, error) response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42185,28 +42204,28 @@ func ParseContinueOrderPaymentResponse(rsp *http.Response) (*ContinueOrderPaymen response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42239,14 +42258,14 @@ func ParseListStorePackagesResponse(rsp *http.Response) (*ListStorePackagesRespo response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42279,14 +42298,14 @@ func ParseListStoreTargetsResponse(rsp *http.Response) (*ListStoreTargetsRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42345,7 +42364,7 @@ func ParseGetTeamInviteLinkResponse(rsp *http.Response) (*GetTeamInviteLinkRespo response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42378,7 +42397,7 @@ func ParseGetTeamResponse(rsp *http.Response) (*GetTeamResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42411,7 +42430,7 @@ func ParseListTeamActivityResponse(rsp *http.Response) (*ListTeamActivityRespons response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42444,14 +42463,14 @@ func ParseListTeamEntitlementsResponse(rsp *http.Response) (*ListTeamEntitlement response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42484,14 +42503,14 @@ func ParseGrantTeamEntitlementResponse(rsp *http.Response) (*GrantTeamEntitlemen response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42524,14 +42543,14 @@ func ParseRevokeTeamEntitlementResponse(rsp *http.Response) (*RevokeTeamEntitlem response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42564,14 +42583,14 @@ func ParseUpdateTeamEntitlementResponse(rsp *http.Response) (*UpdateTeamEntitlem response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42597,16 +42616,14 @@ func ParseListTeamInvitationsResponse(rsp *http.Response) (*ListTeamInvitationsR switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest struct { - Invitations []PendingInvitation `json:"invitations"` - } + var dest TeamInvitationList if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42639,7 +42656,7 @@ func ParseCreateTeamInviteLinkResponse(rsp *http.Response) (*CreateTeamInviteLin response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42674,7 +42691,7 @@ func ParseDeleteTeamLogoResponse(rsp *http.Response) (*DeleteTeamLogoResponse, e response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42709,35 +42726,35 @@ func ParseSetTeamLogoResponse(rsp *http.Response) (*SetTeamLogoResponse, error) response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON403 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON413 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 415: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON415 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42772,21 +42789,21 @@ func ParseJoinTeamResponse(rsp *http.Response) (*JoinTeamResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON404 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON409 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 410: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42821,7 +42838,7 @@ func ParseEmptyTrashResponse(rsp *http.Response) (*EmptyTrashResponse, error) { response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42857,14 +42874,14 @@ func ParseDeleteUsersResponse(rsp *http.Response) (*DeleteUsersResponse, error) response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42927,14 +42944,14 @@ func ParseSetUsersStatusResponse(rsp *http.Response) (*SetUsersStatusResponse, e response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -42997,28 +43014,28 @@ func ParseSetMyAvatarResponse(rsp *http.Response) (*SetMyAvatarResponse, error) response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON413 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 415: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON415 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43054,7 +43071,7 @@ func ParseAdminDeleteUserResponse(rsp *http.Response) (*AdminDeleteUserResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43087,14 +43104,14 @@ func ParseGetUserProfileResponse(rsp *http.Response) (*GetUserProfileResponse, e response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43130,7 +43147,7 @@ func ParseSetUserStatusResponse(rsp *http.Response) (*SetUserStatusResponse, err response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43163,14 +43180,14 @@ func ParseListUserEntitlementsResponse(rsp *http.Response) (*ListUserEntitlement response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43203,14 +43220,14 @@ func ParseGrantUserEntitlementResponse(rsp *http.Response) (*GrantUserEntitlemen response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43243,14 +43260,14 @@ func ParseRevokeUserEntitlementResponse(rsp *http.Response) (*RevokeUserEntitlem response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43283,14 +43300,14 @@ func ParseUpdateUserEntitlementResponse(rsp *http.Response) (*UpdateUserEntitlem response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -43326,7 +43343,7 @@ func ParseListUserObjectsResponse(rsp *http.Response) (*ListUserObjectsResponse, response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest ErrorResponse + var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } diff --git a/e2e/name-conflict.spec.ts b/e2e/name-conflict.spec.ts index 09b88f1b..727f4f6e 100644 --- a/e2e/name-conflict.spec.ts +++ b/e2e/name-conflict.spec.ts @@ -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 }) diff --git a/server/adapters/repos/api-keys-rate-limit.integration.test.ts b/server/adapters/repos/api-keys-rate-limit.integration.test.ts index 0850541d..06577fdc 100644 --- a/server/adapters/repos/api-keys-rate-limit.integration.test.ts +++ b/server/adapters/repos/api-keys-rate-limit.integration.test.ts @@ -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 () => { diff --git a/server/app.ts b/server/app.ts index 77e9c6fa..ff19699a 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 diff --git a/server/http/background-jobs.integration.test.ts b/server/http/background-jobs.integration.test.ts index 4dae7d80..1d428d51 100644 --- a/server/http/background-jobs.integration.test.ts +++ b/server/http/background-jobs.integration.test.ts @@ -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 }) diff --git a/server/http/background-jobs.ts b/server/http/background-jobs.ts index 4d86009d..bc00c6a6 100644 --- a/server/http/background-jobs.ts +++ b/server/http/background-jobs.ts @@ -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) diff --git a/server/http/downloads/download-tasks.integration.test.ts b/server/http/downloads/download-tasks.integration.test.ts index b0c9c4c5..d18e06cd 100644 --- a/server/http/downloads/download-tasks.integration.test.ts +++ b/server/http/downloads/download-tasks.integration.test.ts @@ -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 - 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 }[] } + } + 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 () => { diff --git a/server/http/downloads/download-tasks.ts b/server/http/downloads/download-tasks.ts index f8d1b429..cd690936 100644 --- a/server/http/downloads/download-tasks.ts +++ b/server/http/downloads/download-tasks.ts @@ -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() 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() } 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() .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() }) .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() ) } 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) }) diff --git a/server/http/downloads/downloaders.ts b/server/http/downloads/downloaders.ts index 2b793a0f..1a333128 100644 --- a/server/http/downloads/downloaders.ts +++ b/server/http/downloads/downloaders.ts @@ -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() .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() 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() export const downloaderSelfRoute = new OpenAPIHono().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) }) diff --git a/server/http/entitlements.ts b/server/http/entitlements.ts index 8d0e0b9e..fcae85c6 100644 --- a/server/http/entitlements.ts +++ b/server/http/entitlements.ts @@ -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 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') diff --git a/server/http/image-hosting/config.ts b/server/http/image-hosting/config.ts index 25a1bcf3..dbdf5055 100644 --- a/server/http/image-hosting/config.ts +++ b/server/http/image-hosting/config.ts @@ -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) }) diff --git a/server/http/image-hosting/images.integration.test.ts b/server/http/image-hosting/images.integration.test.ts index e0aea362..ece94f64 100644 --- a/server/http/image-hosting/images.integration.test.ts +++ b/server/http/image-hosting/images.integration.test.ts @@ -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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - expect(String(body.error)).toContain('Unsupported') + const body = (await res.json()) as { + error: { message: string; details: { reason: string; metadata?: Record }[] } + } + 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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') }) }) diff --git a/server/http/image-hosting/images.ts b/server/http/image-hosting/images.ts index 0757059e..b56cffd5 100644 --- a/server/http/image-hosting/images.ts +++ b/server/http/image-hosting/images.ts @@ -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() // 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 } 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) }) diff --git a/server/http/internal.ts b/server/http/internal.ts index 7e616c6f..eecef880 100644 --- a/server/http/internal.ts +++ b/server/http/internal.ts @@ -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') ? { diff --git a/server/http/notifications.cf-test.ts b/server/http/notifications.cf-test.ts index 7f3686c0..302d28cc 100644 --- a/server/http/notifications.cf-test.ts +++ b/server/http/notifications.cf-test.ts @@ -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 () => { diff --git a/server/http/notifications.integration.test.ts b/server/http/notifications.integration.test.ts index 46a9b313..36076b01 100644 --- a/server/http/notifications.integration.test.ts +++ b/server/http/notifications.integration.test.ts @@ -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 () => { diff --git a/server/http/notifications.ts b/server/http/notifications.ts index 95bea476..b77fc090 100644 --- a/server/http/notifications.ts +++ b/server/http/notifications.ts @@ -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 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)) diff --git a/server/http/objects.cf-test.ts b/server/http/objects.cf-test.ts index d210af6a..0f332d4f 100644 --- a/server/http/objects.cf-test.ts +++ b/server/http/objects.cf-test.ts @@ -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 () => { diff --git a/server/http/objects.integration.test.ts b/server/http/objects.integration.test.ts index c3fd5b3a..502bd1e8 100644 --- a/server/http/objects.integration.test.ts +++ b/server/http/objects.integration.test.ts @@ -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 - 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 }> } + } + 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 - 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 }> } + } + 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 - 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 - 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 - 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 - 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 - 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 - 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>['auth'], + userId: string, +): Promise { + // 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') + }) +}) diff --git a/server/http/objects.ts b/server/http/objects.ts index bb05a48a..02ece7a5 100644 --- a/server/http/objects.ts +++ b/server/http/objects.ts @@ -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(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( diff --git a/server/http/openapi.ts b/server/http/openapi.ts index a4e62d26..454e7337 100644 --- a/server/http/openapi.ts +++ b/server/http/openapi.ts @@ -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 = (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( + c: Context, + 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) +} diff --git a/server/http/quotas.ts b/server/http/quotas.ts index 7c4a3394..11370160 100644 --- a/server/http/quotas.ts +++ b/server/http/quotas.ts @@ -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().openapi(listQuotaOverviewRoute, async (c) => - c.json(await listQuotaOverview(c.get('deps')), 200), -) +const adminQuotas = new OpenAPIHono().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().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) }) diff --git a/server/http/redirect.integration.test.ts b/server/http/redirect.integration.test.ts index 8141abf0..5b2db49c 100644 --- a/server/http/redirect.integration.test.ts +++ b/server/http/redirect.integration.test.ts @@ -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() }) }) diff --git a/server/http/redirect.ts b/server/http/redirect.ts index 3cf192c6..f21a31ef 100644 --- a/server/http/redirect.ts +++ b/server/http/redirect.ts @@ -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, url: string): Response { } function insufficientCredits(c: Context): 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, token: string): Promise { @@ -35,17 +40,20 @@ async function handleDirectShare(c: Context, token: string): Promise, token: string): Promise().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 diff --git a/server/http/shares.integration.test.ts b/server/http/shares.integration.test.ts index 06dd3cb6..bf979914 100644 --- a/server/http/shares.integration.test.ts +++ b/server/http/shares.integration.test.ts @@ -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 - 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 - 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 - 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 - 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 - 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 - 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 () => { diff --git a/server/http/shares.ts b/server/http/shares.ts index 787afb0d..7ee9db37 100644 --- a/server/http/shares.ts +++ b/server/http/shares.ts @@ -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 { } 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' }) } }) diff --git a/server/http/site/announcements.integration.test.ts b/server/http/site/announcements.integration.test.ts index 39ba3c94..3bba94c9 100644 --- a/server/http/site/announcements.integration.test.ts +++ b/server/http/site/announcements.integration.test.ts @@ -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 () => { diff --git a/server/http/site/announcements.ts b/server/http/site/announcements.ts index f146d389..a0ad5195 100644 --- a/server/http/site/announcements.ts +++ b/server/http/site/announcements.ts @@ -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) }) diff --git a/server/http/site/audit.integration.test.ts b/server/http/site/audit.integration.test.ts index 65c9114c..b1ed44a6 100644 --- a/server/http/site/audit.integration.test.ts +++ b/server/http/site/audit.integration.test.ts @@ -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 - 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') }) }) diff --git a/server/http/site/audit.ts b/server/http/site/audit.ts index 8f5b5d64..080f45b2 100644 --- a/server/http/site/audit.ts +++ b/server/http/site/audit.ts @@ -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().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) }) diff --git a/server/http/site/auth-providers.integration.test.ts b/server/http/site/auth-providers.integration.test.ts index 4b61dac3..8a7d02ac 100644 --- a/server/http/site/auth-providers.integration.test.ts +++ b/server/http/site/auth-providers.integration.test.ts @@ -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 - 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 }> } + } + 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 - 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 - 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 () => { diff --git a/server/http/site/auth-providers.ts b/server/http/site/auth-providers.ts index cd4b34e5..aba52196 100644 --- a/server/http/site/auth-providers.ts +++ b/server/http/site/auth-providers.ts @@ -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 => ({ + 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() - .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) }) diff --git a/server/http/site/branding.integration.test.ts b/server/http/site/branding.integration.test.ts index 20af8c91..26d8a540 100644 --- a/server/http/site/branding.integration.test.ts +++ b/server/http/site/branding.integration.test.ts @@ -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 () => { diff --git a/server/http/site/branding.ts b/server/http/site/branding.ts index 08d2b3dd..041203dd 100644 --- a/server/http/site/branding.ts +++ b/server/http/site/branding.ts @@ -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().openapi(readRoute, async (c export const brandingAdmin = new OpenAPIHono() .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() 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')!, diff --git a/server/http/site/email-config.integration.test.ts b/server/http/site/email-config.integration.test.ts index ea17a91d..0c64a760 100644 --- a/server/http/site/email-config.integration.test.ts +++ b/server/http/site/email-config.integration.test.ts @@ -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 - 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 - 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 - 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 () => { diff --git a/server/http/site/email-config.ts b/server/http/site/email-config.ts index 4c3778e4..afcf9e7d 100644 --- a/server/http/site/email-config.ts +++ b/server/http/site/email-config.ts @@ -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 diff --git a/server/http/site/invitations.integration.test.ts b/server/http/site/invitations.integration.test.ts index 7fab0cf4..8c4bbe53 100644 --- a/server/http/site/invitations.integration.test.ts +++ b/server/http/site/invitations.integration.test.ts @@ -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>) { + 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>, email: string): Promise { + 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') + }) }) diff --git a/server/http/site/invitations.ts b/server/http/site/invitations.ts index 37220329..c5e000e2 100644 --- a/server/http/site/invitations.ts +++ b/server/http/site/invitations.ts @@ -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() .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() 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().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) }) diff --git a/server/http/site/invite-codes.integration.test.ts b/server/http/site/invite-codes.integration.test.ts index a59e2591..ce286b94 100644 --- a/server/http/site/invite-codes.integration.test.ts +++ b/server/http/site/invite-codes.integration.test.ts @@ -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 () => { diff --git a/server/http/site/invite-codes.ts b/server/http/site/invite-codes.ts index cedb0f48..9f54877b 100644 --- a/server/http/site/invite-codes.ts +++ b/server/http/site/invite-codes.ts @@ -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() .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() 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().openapi(validateRoute, async (c) => { diff --git a/server/http/site/licensing-admin.integration.test.ts b/server/http/site/licensing-admin.integration.test.ts index 57dd8304..21e7a9c1 100644 --- a/server/http/site/licensing-admin.integration.test.ts +++ b/server/http/site/licensing-admin.integration.test.ts @@ -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() diff --git a/server/http/site/licensing.integration.test.ts b/server/http/site/licensing.integration.test.ts index 99a28153..7487d804 100644 --- a/server/http/site/licensing.integration.test.ts +++ b/server/http/site/licensing.integration.test.ts @@ -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 - 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 - 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' } }) }) }) diff --git a/server/http/site/licensing.ts b/server/http/site/licensing.ts index 5ce22355..ad2f5c71 100644 --- a/server/http/site/licensing.ts +++ b/server/http/site/licensing.ts @@ -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): 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() // 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) diff --git a/server/http/site/storages.cf-test.ts b/server/http/site/storages.cf-test.ts index c9b4453d..a1409ee3 100644 --- a/server/http/site/storages.cf-test.ts +++ b/server/http/site/storages.cf-test.ts @@ -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 - 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 }> } + } + 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 - 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 }> } + } + 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 } diff --git a/server/http/site/storages.integration.test.ts b/server/http/site/storages.integration.test.ts index 8c719980..2c3151c8 100644 --- a/server/http/site/storages.integration.test.ts +++ b/server/http/site/storages.integration.test.ts @@ -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 - 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 }> } + } + 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 () => { diff --git a/server/http/site/storages.ts b/server/http/site/storages.ts index 5e430f82..c54296d3 100644 --- a/server/http/site/storages.ts +++ b/server/http/site/storages.ts @@ -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 => ({ + 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() .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() 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() 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 diff --git a/server/http/site/system.test.ts b/server/http/site/system.test.ts index 19a7a8af..9f55a448 100644 --- a/server/http/site/system.test.ts +++ b/server/http/site/system.test.ts @@ -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') }) }) diff --git a/server/http/site/system.ts b/server/http/site/system.ts index f74d39c8..e2d398ee 100644 --- a/server/http/site/system.ts +++ b/server/http/site/system.ts @@ -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() .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() }) 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) }) diff --git a/server/http/store/store.integration.test.ts b/server/http/store/store.integration.test.ts index 0abc79b4..9fcce04d 100644 --- a/server/http/store/store.integration.test.ts +++ b/server/http/store/store.integration.test.ts @@ -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>['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') + } }) }) diff --git a/server/http/store/storefront.ts b/server/http/store/storefront.ts index d956429e..e4a19e7e 100644 --- a/server/http/store/storefront.ts +++ b/server/http/store/storefront.ts @@ -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) }) diff --git a/server/http/store/traffic-metering.integration.test.ts b/server/http/store/traffic-metering.integration.test.ts index f13ec9da..2aa284cd 100644 --- a/server/http/store/traffic-metering.integration.test.ts +++ b/server/http/store/traffic-metering.integration.test.ts @@ -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() diff --git a/server/http/store/traffic-metering.ts b/server/http/store/traffic-metering.ts index 03c0b453..6f2aa506 100644 --- a/server/http/store/traffic-metering.ts +++ b/server/http/store/traffic-metering.ts @@ -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) => c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT function insufficientCredits(c: Context): 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' }, + }) } /** diff --git a/server/http/store/webhooks.ts b/server/http/store/webhooks.ts index 0e432009..8addbac5 100644 --- a/server/http/store/webhooks.ts +++ b/server/http/store/webhooks.ts @@ -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().use(requireFeature('quota_store')).post('/webhook', async (c) => { @@ -14,7 +15,8 @@ export const cloudStoreWebhooks = new Hono().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) }) diff --git a/server/http/teams.integration.test.ts b/server/http/teams.integration.test.ts index 8a45cb67..1fbcbb14 100644 --- a/server/http/teams.integration.test.ts +++ b/server/http/teams.integration.test.ts @@ -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) }) }) diff --git a/server/http/teams.ts b/server/http/teams.ts index c0d96319..0409ce73 100644 --- a/server/http/teams.ts +++ b/server/http/teams.ts @@ -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().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() - .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() 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() 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() 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) }) diff --git a/server/http/trash.ts b/server/http/trash.ts index 4f368d80..e0134f29 100644 --- a/server/http/trash.ts +++ b/server/http/trash.ts @@ -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) }) diff --git a/server/http/users.integration.test.ts b/server/http/users.integration.test.ts index e7981346..3c14f356 100644 --- a/server/http/users.integration.test.ts +++ b/server/http/users.integration.test.ts @@ -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 - 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 () => { diff --git a/server/http/users.ts b/server/http/users.ts index ba9ef4f3..3c394100 100644 --- a/server/http/users.ts +++ b/server/http/users.ts @@ -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 { 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() .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() 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() 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() 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() 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() 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) }) diff --git a/server/http/webdav.ts b/server/http/webdav.ts index e366497b..c8a62cfd 100644 --- a/server/http/webdav.ts +++ b/server/http/webdav.ts @@ -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 { diff --git a/server/lib/http-errors.test.ts b/server/lib/http-errors.test.ts index 657fee73..d3d8e873 100644 --- a/server/lib/http-errors.test.ts +++ b/server/lib/http-errors.test.ts @@ -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) => 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', () => { diff --git a/server/lib/http-errors.ts b/server/lib/http-errors.ts index c405e845..e9967758 100644 --- a/server/lib/http-errors.ts +++ b/server/lib/http-errors.ts @@ -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 + 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 + /** 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 = { 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 } diff --git a/server/middleware/auth.integration.test.ts b/server/middleware/auth.integration.test.ts index 65a1015e..5b42b8c8 100644 --- a/server/middleware/auth.integration.test.ts +++ b/server/middleware/auth.integration.test.ts @@ -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') }) }) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 8e2fb23c..0fb66124 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -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(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(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(async (c, next) => { export const requireDownloader = createMiddleware(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(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(async (c, next) => { export const requireAdmin = createMiddleware(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') }) } diff --git a/server/middleware/authz.integration.test.ts b/server/middleware/authz.integration.test.ts new file mode 100644 index 00000000..d3b0fd74 --- /dev/null +++ b/server/middleware/authz.integration.test.ts @@ -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> +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, +): Promise { + // 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 { + 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 { + 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 { + 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') + }) +}) diff --git a/server/middleware/authz.ts b/server/middleware/authz.ts index 7f9129cf..8c6da921 100644 --- a/server/middleware/authz.ts +++ b/server/middleware/authz.ts @@ -1,4 +1,5 @@ import { createMiddleware } from 'hono/factory' +import { apiError } from '../http/openapi' import type { Env } from './platform' const ROLE_LEVELS: Record = { @@ -15,35 +16,35 @@ export function requirePermission( ) { return createMiddleware(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') }) } diff --git a/server/middleware/error-handler.test.ts b/server/middleware/error-handler.test.ts new file mode 100644 index 00000000..6fe6a6ce --- /dev/null +++ b/server/middleware/error-handler.test.ts @@ -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> { + let captured!: Context + const app = new Hono() + app.get('/x', (c) => { + c.set('errorLog', null) + captured = c as unknown as Context + 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) + }) +}) diff --git a/server/middleware/error-handler.ts b/server/middleware/error-handler.ts new file mode 100644 index 00000000..5c08b3e4 --- /dev/null +++ b/server/middleware/error-handler.ts @@ -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, 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 +} diff --git a/server/middleware/image-hosting-domain.integration.test.ts b/server/middleware/image-hosting-domain.integration.test.ts index 6ec85f49..402e56b2 100644 --- a/server/middleware/image-hosting-domain.integration.test.ts +++ b/server/middleware/image-hosting-domain.integration.test.ts @@ -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 () => { diff --git a/server/middleware/image-hosting-domain.ts b/server/middleware/image-hosting-domain.ts index d1cad08b..74fd0629 100644 --- a/server/middleware/image-hosting-domain.ts +++ b/server/middleware/image-hosting-domain.ts @@ -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, orgId: string, virtualPath: string): Promise { 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, next: Next): Promise { + const out: Record = {} + 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) { + const app = new Hono() + 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') + }) +}) diff --git a/server/middleware/logger.ts b/server/middleware/logger.ts index 6fb9b60b..1ac12e93 100644 --- a/server/middleware/logger.ts +++ b/server/middleware/logger.ts @@ -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(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, start: number, status: number, error?: unknown) { - const fields = accessLogFields(c, start, status, error) +function writeAccessLog(c: Context, start: number) { + const fields = accessLogFields(c, start) console.log(fields.map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(' ')) } -function accessLogFields( - c: Context, - start: number, - status: number, - error?: unknown, -): Array<[string, string | number]> { +function accessLogFields(c: Context, 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 diff --git a/server/middleware/platform.ts b/server/middleware/platform.ts index ac116167..89a11a4a 100644 --- a/server/middleware/platform.ts +++ b/server/middleware/platform.ts @@ -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() }) diff --git a/server/middleware/require-feature.ts b/server/middleware/require-feature.ts index 182df4fb..eb2fb262 100644 --- a/server/middleware/require-feature.ts +++ b/server/middleware/require-feature.ts @@ -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() }) diff --git a/server/usecases/object.integration.test.ts b/server/usecases/object.integration.test.ts index 5d5c138e..d9b700b2 100644 --- a/server/usecases/object.integration.test.ts +++ b/server/usecases/object.integration.test.ts @@ -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 () => { diff --git a/server/usecases/site/signup-mode.integration.test.ts b/server/usecases/site/signup-mode.integration.test.ts index 6d4d32fd..37de3e32 100644 --- a/server/usecases/site/signup-mode.integration.test.ts +++ b/server/usecases/site/signup-mode.integration.test.ts @@ -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 }[] } + } + 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 () => { diff --git a/shared/schemas/errors.ts b/shared/schemas/errors.ts index d0e9ca51..ac032171 100644 --- a/shared/schemas/errors.ts +++ b/shared/schemas/errors.ts @@ -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 = { + 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 +export type ErrorInfo = z.infer diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index ec17df31..04a191e0 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -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, diff --git a/shared/schemas/notification.ts b/shared/schemas/notification.ts index 5a64de52..e382e356 100644 --- a/shared/schemas/notification.ts +++ b/shared/schemas/notification.ts @@ -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(), }) diff --git a/shared/schemas/pagination.ts b/shared/schemas/pagination.ts new file mode 100644 index 00000000..c12e5636 --- /dev/null +++ b/shared/schemas/pagination.ts @@ -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 = { 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 + +// Build the named `Page` 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 = (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 = { + items: T[] + total: number + page: number + pageSize: number +} diff --git a/src/components/files/hooks/use-conflict-resolver.test.ts b/src/components/files/hooks/use-conflict-resolver.test.ts index 516a100b..b2bc3a9e 100644 --- a/src/components/files/hooks/use-conflict-resolver.test.ts +++ b/src/components/files/hooks/use-conflict-resolver.test.ts @@ -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) diff --git a/src/components/files/hooks/use-conflict-resolver.ts b/src/components/files/hooks/use-conflict-resolver.ts index ad1e9f8e..30e503ee 100644 --- a/src/components/files/hooks/use-conflict-resolver.ts +++ b/src/components/files/hooks/use-conflict-resolver.ts @@ -100,7 +100,7 @@ export async function withConflictRetry( } 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 } diff --git a/src/components/files/transfer-space-dialog.tsx b/src/components/files/transfer-space-dialog.tsx index 211b5b04..454b17b5 100644 --- a/src/components/files/transfer-space-dialog.tsx +++ b/src/components/files/transfer-space-dialog.tsx @@ -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')) diff --git a/src/components/notifications/notification-dropdown.test.ts b/src/components/notifications/notification-dropdown.test.ts index 449a84b0..cd30697e 100644 --- a/src/components/notifications/notification-dropdown.test.ts +++ b/src/components/notifications/notification-dropdown.test.ts @@ -21,7 +21,8 @@ function makeNotification(overrides: Partial = {}): 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 diff --git a/src/components/notifications/notification-dropdown.tsx b/src/components/notifications/notification-dropdown.tsx index b59abf09..393a2c2a 100644 --- a/src/components/notifications/notification-dropdown.tsx +++ b/src/components/notifications/notification-dropdown.tsx @@ -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() diff --git a/src/components/share/save-to-drive-dialog.tsx b/src/components/share/save-to-drive-dialog.tsx index 4419b79c..98a78652 100644 --- a/src/components/share/save-to-drive-dialog.tsx +++ b/src/components/share/save-to-drive-dialog.tsx @@ -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')) diff --git a/src/components/team/invite-dialog.tsx b/src/components/team/invite-dialog.tsx index 65642d7a..8d67f78d 100644 --- a/src/components/team/invite-dialog.tsx +++ b/src/components/team/invite-dialog.tsx @@ -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 }, }) diff --git a/src/components/upload/upload-dropzone.tsx b/src/components/upload/upload-dropzone.tsx index bdb2f15e..add1f74b 100644 --- a/src/components/upload/upload-dropzone.tsx +++ b/src/components/upload/upload-dropzone.tsx @@ -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) } diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 86be79ac..a14668cb 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -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) => ({ + 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) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index a14e0e75..fb226210 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -109,41 +109,72 @@ export type UserQuota = Pick< | 'currentPlan' > +export interface ErrorInfo { + reason: string + domain: string + metadata?: Record +} + 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 | 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(promise: Promise): Promise { 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 } @@ -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(path: string, options: RequestInit): Promise { 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 } @@ -1161,8 +1191,8 @@ async function fetchSession(): Promise { 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 } @@ -1442,8 +1472,8 @@ export async function saveBranding(data: { export async function resetBrandingField(field: BrandingField): Promise { 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)) } } diff --git a/src/routes/_authenticated/storage.test.tsx b/src/routes/_authenticated/storage.test.tsx index b99246c9..39fe6386 100644 --- a/src/routes/_authenticated/storage.test.tsx +++ b/src/routes/_authenticated/storage.test.tsx @@ -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 }> + } + } + readonly reason: string | undefined + readonly metadata: Record | 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({ diff --git a/src/routes/_authenticated/storage.tsx b/src/routes/_authenticated/storage.tsx index 7f5eaa7c..89142374 100644 --- a/src/routes/_authenticated/storage.tsx +++ b/src/routes/_authenticated/storage.tsx @@ -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' + ) } diff --git a/src/routes/_authenticated/teams/invite.tsx b/src/routes/_authenticated/teams/invite.tsx index 41164341..09682064 100644 --- a/src/routes/_authenticated/teams/invite.tsx +++ b/src/routes/_authenticated/teams/invite.tsx @@ -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() }, diff --git a/src/routes/store/checkout.test.tsx b/src/routes/store/checkout.test.tsx index d21f6502..d22bcd11 100644 --- a/src/routes/store/checkout.test.tsx +++ b/src/routes/store/checkout.test.tsx @@ -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 | 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', diff --git a/src/routes/store/checkout.tsx b/src/routes/store/checkout.tsx index 3757f3fc..57e9ac42 100644 --- a/src/routes/store/checkout.tsx +++ b/src/routes/store/checkout.tsx @@ -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).code === 'workspace_plan_exists')) - ) { + if (err instanceof ApiError && err.reason === 'WORKSPACE_PLAN_EXISTS') { const ordersRes = await listCloudOrders() const pendingPlanOrder = ordersRes.items.find( (order) =>