feat(canary): Zod schemas + canary envelope error normalization

- Add Zod 4 schemas mirroring backend handler shapes:
  - api/types/token.ts: tokenResponseSchema, manageTokenViewSchema,
    artifactSchema (discriminatedUnion on kind), typeDescriptorSchema,
    createTokenRequestSchema (with superRefine for telegram/webhook
    conditional required), per-type metadata helpers.
  - api/types/event.ts: eventResponseSchema with GeoView, NotifyStatus,
    manageResponseSchema with cursor pagination.
  - api/types/error.ts: apiErrorEnvelopeSchema (canary {success,error}
    envelope) + successEnvelope<T> factory for typed unwrap.
- Refactor core/api/errors.ts: ApiError now carries canary error codes
  (VALIDATION_ERROR, TURNSTILE_FAILED, BAD_CURSOR, etc.) and a
  fields: Record<string,string> map. transformAxiosError parses the
  canary envelope first, falls back to HTTP status code if envelope
  shape is missing.
- Update core/api/query.config.ts no-retry list for canary codes
  (user-error codes plus RATE_LIMITED to avoid burning rate budget).
- Remove obsolete template apiClient at core/api/api.config.ts; the
  new canary client lives at api/client.ts (Phase 14 next commit).
This commit is contained in:
CarterPerez-dev 2026-05-17 12:55:10 -04:00
parent 4d0d151569
commit db98a0d497
8 changed files with 370 additions and 97 deletions

View File

@ -0,0 +1,32 @@
// ===================
// ©AngelaMos | 2026
// error.ts
// ===================
import { type ZodType, z } from 'zod'
export const apiErrorPayloadSchema = z.object({
code: z.string(),
message: z.string(),
fields: z.record(z.string(), z.string()).optional(),
})
export type ApiErrorPayload = z.infer<typeof apiErrorPayloadSchema>
export const apiErrorEnvelopeSchema = z.object({
success: z.literal(false),
error: apiErrorPayloadSchema,
})
export type ApiErrorEnvelope = z.infer<typeof apiErrorEnvelopeSchema>
export const successEnvelope = <T extends ZodType>(data: T) =>
z.object({
success: z.literal(true),
data,
})
export type SuccessEnvelope<T> = {
success: true
data: T
}

View File

@ -0,0 +1,52 @@
// ===================
// ©AngelaMos | 2026
// event.ts
// ===================
import { z } from 'zod'
import { manageTokenViewSchema } from './token'
export const notifyStatusSchema = z.enum(['pending', 'sent', 'failed', 'deduped'])
export type NotifyStatus = z.infer<typeof notifyStatusSchema>
export const geoViewSchema = z.object({
country: z.string().nullable(),
region: z.string().nullable(),
city: z.string().nullable(),
asn: z.number().int().nullable(),
asn_org: z.string().nullable(),
})
export type GeoView = z.infer<typeof geoViewSchema>
export const eventResponseSchema = z.object({
id: z.number().int().nonnegative(),
triggered_at: z.iso.datetime(),
source_ip: z.string(),
user_agent: z.string().nullable(),
referer: z.string().nullable(),
geo: geoViewSchema,
extra: z.unknown(),
notify_status: notifyStatusSchema,
notified_at: z.iso.datetime().nullable(),
})
export type EventResponse = z.infer<typeof eventResponseSchema>
export const managePageSchema = z.object({
next_cursor: z.string(),
has_more: z.boolean(),
})
export type ManagePage = z.infer<typeof managePageSchema>
export const manageResponseSchema = z.object({
token: manageTokenViewSchema,
events: z.array(eventResponseSchema),
events_total: z.number().int().nonnegative(),
events_silenced_active: z.number().int().nonnegative(),
page: managePageSchema,
})
export type ManageResponse = z.infer<typeof manageResponseSchema>

View File

@ -2,3 +2,7 @@
// ©AngelaMos | 2026
// index.ts
// ===================
export * from './error'
export * from './event'
export * from './token'

View File

@ -0,0 +1,178 @@
// ===================
// ©AngelaMos | 2026
// token.ts
// ===================
import { z } from 'zod'
export const tokenTypeSchema = z.enum([
'webbug',
'slowredirect',
'docx',
'pdf',
'kubeconfig',
'envfile',
'mysql',
])
export type TokenType = z.infer<typeof tokenTypeSchema>
export const alertChannelSchema = z.enum(['telegram', 'webhook'])
export type AlertChannel = z.infer<typeof alertChannelSchema>
export const artifactKindSchema = z.enum([
'url',
'file',
'text',
'connection_string',
])
export type ArtifactKind = z.infer<typeof artifactKindSchema>
export const tokenResponseSchema = z.object({
id: z.string(),
manage_id: z.string(),
type: tokenTypeSchema,
memo: z.string(),
filename: z.string().nullable(),
alert_channel: alertChannelSchema,
created_at: z.iso.datetime(),
trigger_count: z.number().int().nonnegative(),
last_triggered: z.iso.datetime().nullable(),
enabled: z.boolean(),
trigger_url: z.string(),
manage_url: z.string(),
metadata: z.unknown().optional(),
})
export type TokenResponse = z.infer<typeof tokenResponseSchema>
export const manageTokenViewSchema = z.object({
id: z.string(),
type: tokenTypeSchema,
memo: z.string(),
filename: z.string().nullable(),
alert_channel: alertChannelSchema,
created_at: z.iso.datetime(),
trigger_count: z.number().int().nonnegative(),
last_triggered: z.iso.datetime().nullable(),
enabled: z.boolean(),
trigger_url: z.string(),
})
export type ManageTokenView = z.infer<typeof manageTokenViewSchema>
const artifactUrlSchema = z.object({
kind: z.literal('url'),
url: z.string().optional(),
destination_url: z.string().optional(),
})
const artifactFileSchema = z.object({
kind: z.literal('file'),
filename: z.string().optional(),
content_type: z.string().optional(),
content_b64: z.string().optional(),
})
const artifactTextSchema = z.object({
kind: z.literal('text'),
filename: z.string().optional(),
content_type: z.string().optional(),
content: z.string().optional(),
})
const artifactConnectionStringSchema = z.object({
kind: z.literal('connection_string'),
connection_string: z.string().optional(),
})
export const artifactSchema = z.discriminatedUnion('kind', [
artifactUrlSchema,
artifactFileSchema,
artifactTextSchema,
artifactConnectionStringSchema,
])
export type Artifact = z.infer<typeof artifactSchema>
export const typeDescriptorSchema = z.object({
type: tokenTypeSchema,
name: z.string(),
description: z.string(),
artifact_kind: artifactKindSchema,
})
export type TypeDescriptor = z.infer<typeof typeDescriptorSchema>
export const typeDescriptorListSchema = z.array(typeDescriptorSchema)
export type TypeDescriptorList = z.infer<typeof typeDescriptorListSchema>
const MEMO_MAX_LENGTH = 256
const FILENAME_MAX_LENGTH = 128
export const createTokenRequestSchema = z
.object({
type: tokenTypeSchema,
memo: z.string().max(MEMO_MAX_LENGTH).default(''),
filename: z.string().max(FILENAME_MAX_LENGTH).optional(),
alert_channel: alertChannelSchema,
telegram_bot: z.string().optional(),
telegram_chat: z.string().optional(),
webhook_url: z.url().optional(),
metadata: z.unknown().optional(),
cf_turnstile_response: z.string().optional(),
})
.superRefine((value, ctx) => {
if (value.alert_channel === 'telegram') {
if (!value.telegram_bot) {
ctx.addIssue({
code: 'custom',
path: ['telegram_bot'],
message: 'required when alert_channel is telegram',
})
}
if (!value.telegram_chat) {
ctx.addIssue({
code: 'custom',
path: ['telegram_chat'],
message: 'required when alert_channel is telegram',
})
}
}
if (value.alert_channel === 'webhook' && !value.webhook_url) {
ctx.addIssue({
code: 'custom',
path: ['webhook_url'],
message: 'required when alert_channel is webhook',
})
}
})
export type CreateTokenRequest = z.infer<typeof createTokenRequestSchema>
export type CreateTokenInput = z.input<typeof createTokenRequestSchema>
export const createTokenResponseSchema = z.object({
token: tokenResponseSchema,
artifact: artifactSchema,
})
export type CreateTokenResponse = z.infer<typeof createTokenResponseSchema>
export const slowredirectMetadataSchema = z.object({
destination_url: z.url(),
})
export type SlowRedirectMetadata = z.infer<typeof slowredirectMetadataSchema>
export const envfileIncludeKeySchema = z.enum(['aws', 'stripe', 'github', 'db'])
export type EnvfileIncludeKey = z.infer<typeof envfileIncludeKeySchema>
export const envfileMetadataSchema = z.object({
include_keys: z.array(envfileIncludeKeySchema),
})
export type EnvfileMetadata = z.infer<typeof envfileMetadataSchema>

View File

@ -1,17 +0,0 @@
// ===================
// ©AngelaMos | 2026
// api.config.ts
// ===================
import axios, { type AxiosInstance } from 'axios'
const getBaseURL = (): string => {
return import.meta.env.VITE_API_URL ?? '/api'
}
export const apiClient: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
})

View File

@ -1,110 +1,132 @@
/**
* ©AngelaMos | 2026
* errors.ts
*/
// ===================
// ©AngelaMos | 2026
// errors.ts
// ===================
import type { AxiosError } from 'axios'
export const ApiErrorCode = {
NETWORK_ERROR: 'NETWORK_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
RATE_LIMITED: 'RATE_LIMITED',
SERVER_ERROR: 'SERVER_ERROR',
PARSE_ERROR: 'PARSE_ERROR',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
BAD_JSON: 'BAD_JSON',
BAD_CURSOR: 'BAD_CURSOR',
BAD_PARAM: 'BAD_PARAM',
UNKNOWN_TYPE: 'UNKNOWN_TYPE',
GENERATE_FAILED: 'GENERATE_FAILED',
TURNSTILE_FAILED: 'TURNSTILE_FAILED',
NOT_FOUND: 'NOT_FOUND',
RATE_LIMITED: 'RATE_LIMITED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
} as const
export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode]
const STATUS_FALLBACK_CODE: Record<number, ApiErrorCode> = {
400: ApiErrorCode.VALIDATION_ERROR,
404: ApiErrorCode.NOT_FOUND,
410: ApiErrorCode.NOT_FOUND,
429: ApiErrorCode.RATE_LIMITED,
500: ApiErrorCode.INTERNAL_ERROR,
502: ApiErrorCode.SERVICE_UNAVAILABLE,
503: ApiErrorCode.SERVICE_UNAVAILABLE,
504: ApiErrorCode.SERVICE_UNAVAILABLE,
}
const USER_FACING_COPY: Partial<Record<string, string>> = {
[ApiErrorCode.NETWORK_ERROR]:
'Unable to reach the server. Check your connection.',
[ApiErrorCode.PARSE_ERROR]: 'Server response did not match expected shape.',
[ApiErrorCode.UNKNOWN_ERROR]: 'An unexpected error occurred.',
[ApiErrorCode.RATE_LIMITED]: 'Too many requests. Wait a moment, then retry.',
[ApiErrorCode.SERVICE_UNAVAILABLE]:
'Service is temporarily unavailable. Try again shortly.',
}
export class ApiError extends Error {
readonly code: ApiErrorCode
readonly code: string
readonly statusCode: number
readonly details?: Record<string, string[]>
readonly fields?: Readonly<Record<string, string>>
constructor(
message: string,
code: ApiErrorCode,
code: string,
statusCode: number,
details?: Record<string, string[]>
fields?: Record<string, string>
) {
super(message)
this.name = 'ApiError'
this.code = code
this.statusCode = statusCode
this.details = details
this.fields = fields
}
getUserMessage(): string {
const messages: Record<ApiErrorCode, string> = {
[ApiErrorCode.NETWORK_ERROR]:
'Unable to connect. Please check your internet connection.',
[ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.',
[ApiErrorCode.AUTHENTICATION_ERROR]:
'Your session has expired. Please log in again.',
[ApiErrorCode.AUTHORIZATION_ERROR]:
'You do not have permission to perform this action.',
[ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.',
[ApiErrorCode.CONFLICT]:
'This operation conflicts with an existing resource.',
[ApiErrorCode.RATE_LIMITED]:
'Too many requests. Please wait a moment and try again.',
[ApiErrorCode.SERVER_ERROR]:
'Something went wrong on our end. Please try again later.',
[ApiErrorCode.UNKNOWN_ERROR]:
'An unexpected error occurred. Please try again.',
if (this.message.length > 0) {
return this.message
}
return messages[this.code]
return USER_FACING_COPY[this.code] ?? 'An unexpected error occurred.'
}
}
interface ApiErrorResponse {
detail?: string | { msg: string; type: string }[]
message?: string
interface EnvelopeErrorShape {
success?: unknown
error?: {
code?: unknown
message?: unknown
fields?: unknown
}
}
function parseEnvelopeError(
data: unknown
): { code: string; message: string; fields?: Record<string, string> } | null {
if (data === null || typeof data !== 'object') {
return null
}
const envelope = data as EnvelopeErrorShape
if (envelope.success !== false || envelope.error == null) {
return null
}
const { code, message, fields } = envelope.error
if (typeof code !== 'string' || typeof message !== 'string') {
return null
}
return {
code,
message,
fields: parseFields(fields),
}
}
function parseFields(raw: unknown): Record<string, string> | undefined {
if (raw === null || typeof raw !== 'object') {
return undefined
}
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof v === 'string') {
out[k] = v
}
}
return Object.keys(out).length > 0 ? out : undefined
}
export function transformAxiosError(error: AxiosError<unknown>): ApiError {
if (!error.response) {
return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0)
}
const { status, data } = error.response
const { status } = error.response
const data = error.response.data as ApiErrorResponse | undefined
let message = 'An error occurred'
let details: Record<string, string[]> | undefined
if (data?.detail) {
if (typeof data.detail === 'string') {
message = data.detail
} else if (Array.isArray(data.detail)) {
details = { validation: [] }
data.detail.forEach((err) => {
details?.validation.push(err.msg)
})
message = 'Validation error'
}
} else if (data?.message) {
message = data.message
const envelope = parseEnvelopeError(data)
if (envelope) {
return new ApiError(envelope.message, envelope.code, status, envelope.fields)
}
const codeMap: Record<number, ApiErrorCode> = {
400: ApiErrorCode.VALIDATION_ERROR,
401: ApiErrorCode.AUTHENTICATION_ERROR,
403: ApiErrorCode.AUTHORIZATION_ERROR,
404: ApiErrorCode.NOT_FOUND,
409: ApiErrorCode.CONFLICT,
429: ApiErrorCode.RATE_LIMITED,
500: ApiErrorCode.SERVER_ERROR,
502: ApiErrorCode.SERVER_ERROR,
503: ApiErrorCode.SERVER_ERROR,
504: ApiErrorCode.SERVER_ERROR,
}
const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR
return new ApiError(message, code, status, details)
const fallbackCode = STATUS_FALLBACK_CODE[status] ?? ApiErrorCode.UNKNOWN_ERROR
return new ApiError('Request failed', fallbackCode, status)
}
declare module '@tanstack/react-query' {

View File

@ -3,6 +3,5 @@
// index.ts
// ===================
export * from './api.config'
export * from './errors'
export * from './query.config'

View File

@ -8,18 +8,21 @@ import { toast } from 'sonner'
import { QUERY_CONFIG } from '@/config'
import { ApiError, ApiErrorCode } from './errors'
const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [
ApiErrorCode.AUTHENTICATION_ERROR,
ApiErrorCode.AUTHORIZATION_ERROR,
ApiErrorCode.NOT_FOUND,
const NO_RETRY_ERROR_CODES: ReadonlySet<string> = new Set<string>([
ApiErrorCode.VALIDATION_ERROR,
] as const
ApiErrorCode.BAD_JSON,
ApiErrorCode.BAD_CURSOR,
ApiErrorCode.BAD_PARAM,
ApiErrorCode.UNKNOWN_TYPE,
ApiErrorCode.NOT_FOUND,
ApiErrorCode.TURNSTILE_FAILED,
ApiErrorCode.RATE_LIMITED,
ApiErrorCode.PARSE_ERROR,
])
const shouldRetryQuery = (failureCount: number, error: Error): boolean => {
if (error instanceof ApiError) {
if (NO_RETRY_ERROR_CODES.includes(error.code)) {
return false
}
if (error instanceof ApiError && NO_RETRY_ERROR_CODES.has(error.code)) {
return false
}
return failureCount < QUERY_CONFIG.RETRY.DEFAULT
}