From db98a0d497f8727005acac7e95edf3d7d470bb04 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 17 May 2026 12:55:10 -0400 Subject: [PATCH] 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 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 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). --- .../frontend/src/api/types/error.ts | 32 ++++ .../frontend/src/api/types/event.ts | 52 +++++ .../frontend/src/api/types/index.ts | 4 + .../frontend/src/api/types/token.ts | 178 ++++++++++++++++++ .../frontend/src/core/api/api.config.ts | 17 -- .../frontend/src/core/api/errors.ts | 162 +++++++++------- .../frontend/src/core/api/index.ts | 1 - .../frontend/src/core/api/query.config.ts | 21 ++- 8 files changed, 370 insertions(+), 97 deletions(-) create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/api/types/error.ts create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/api/types/event.ts create mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/api/types/token.ts delete mode 100644 PROJECTS/beginner/canary-token-generator/frontend/src/core/api/api.config.ts diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/error.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/error.ts new file mode 100644 index 00000000..db125a94 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/error.ts @@ -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 + +export const apiErrorEnvelopeSchema = z.object({ + success: z.literal(false), + error: apiErrorPayloadSchema, +}) + +export type ApiErrorEnvelope = z.infer + +export const successEnvelope = (data: T) => + z.object({ + success: z.literal(true), + data, + }) + +export type SuccessEnvelope = { + success: true + data: T +} diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/event.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/event.ts new file mode 100644 index 00000000..ce939ebc --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/event.ts @@ -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 + +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 + +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 + +export const managePageSchema = z.object({ + next_cursor: z.string(), + has_more: z.boolean(), +}) + +export type ManagePage = z.infer + +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 diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/index.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/index.ts index 88da7857..bebc57e2 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/index.ts +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/index.ts @@ -2,3 +2,7 @@ // ©AngelaMos | 2026 // index.ts // =================== + +export * from './error' +export * from './event' +export * from './token' diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/token.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/token.ts new file mode 100644 index 00000000..b97e62f9 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/api/types/token.ts @@ -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 + +export const alertChannelSchema = z.enum(['telegram', 'webhook']) + +export type AlertChannel = z.infer + +export const artifactKindSchema = z.enum([ + 'url', + 'file', + 'text', + 'connection_string', +]) + +export type ArtifactKind = z.infer + +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 + +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 + +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 + +export const typeDescriptorSchema = z.object({ + type: tokenTypeSchema, + name: z.string(), + description: z.string(), + artifact_kind: artifactKindSchema, +}) + +export type TypeDescriptor = z.infer + +export const typeDescriptorListSchema = z.array(typeDescriptorSchema) + +export type TypeDescriptorList = z.infer + +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 +export type CreateTokenInput = z.input + +export const createTokenResponseSchema = z.object({ + token: tokenResponseSchema, + artifact: artifactSchema, +}) + +export type CreateTokenResponse = z.infer + +export const slowredirectMetadataSchema = z.object({ + destination_url: z.url(), +}) + +export type SlowRedirectMetadata = z.infer + +export const envfileIncludeKeySchema = z.enum(['aws', 'stripe', 'github', 'db']) + +export type EnvfileIncludeKey = z.infer + +export const envfileMetadataSchema = z.object({ + include_keys: z.array(envfileIncludeKeySchema), +}) + +export type EnvfileMetadata = z.infer diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/api.config.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/api.config.ts deleted file mode 100644 index 45575e09..00000000 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/api.config.ts +++ /dev/null @@ -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, -}) diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts index 492672f6..809f8eeb 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/errors.ts @@ -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 = { + 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> = { + [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 + readonly fields?: Readonly> constructor( message: string, - code: ApiErrorCode, + code: string, statusCode: number, - details?: Record + fields?: Record ) { super(message) this.name = 'ApiError' this.code = code this.statusCode = statusCode - this.details = details + this.fields = fields } getUserMessage(): string { - const messages: Record = { - [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 } | 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 | undefined { + if (raw === null || typeof raw !== 'object') { + return undefined + } + const out: Record = {} + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === 'string') { + out[k] = v + } + } + return Object.keys(out).length > 0 ? out : undefined } export function transformAxiosError(error: AxiosError): 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 | 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 = { - 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' { diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/index.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/index.ts index aa4ee3cb..c939d9a8 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/index.ts +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/index.ts @@ -3,6 +3,5 @@ // index.ts // =================== -export * from './api.config' export * from './errors' export * from './query.config' diff --git a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/query.config.ts b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/query.config.ts index eb4326f1..a8f970cc 100644 --- a/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/query.config.ts +++ b/PROJECTS/beginner/canary-token-generator/frontend/src/core/api/query.config.ts @@ -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 = new Set([ 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 }