better-ship 0.29.0 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,8 +20,8 @@ export declare class TurnstileVerificationError extends AppError {
20
20
  type TurnstileCheck = {
21
21
  readonly secretKey: string;
22
22
  readonly token: string;
23
- /** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */
24
- readonly remoteIp?: string;
23
+ /** Visitor address from `CF-Connecting-IP`. `null` and omission both mean no address. */
24
+ readonly remoteIp?: string | null;
25
25
  /** Widget action. A present, different action rejects. A missing action does not. */
26
26
  readonly expectedAction?: string;
27
27
  /** Allowed widget hostnames. A testing-key success skips this list. */
@@ -104,7 +104,7 @@ async function requestVerdict(check, idempotencyKey, options) {
104
104
  response: check.token,
105
105
  idempotency_key: idempotencyKey
106
106
  };
107
- const payload = check.remoteIp !== void 0 && check.remoteIp !== "" ? {
107
+ const payload = check.remoteIp !== void 0 && check.remoteIp !== null && check.remoteIp !== "" ? {
108
108
  ...fields,
109
109
  remoteip: check.remoteIp
110
110
  } : fields;
@@ -1 +1 @@
1
- {"version":3,"file":"turnstile.js","names":[],"sources":["../../src/infrastructure/cloudflare/turnstile/turnstile.error.ts","../../src/infrastructure/cloudflare/turnstile/turnstile.ts"],"sourcesContent":["import { AppError, type FailureClassification } from '@/core'\n\n/**\n * Siteverify did not produce a verdict.\n * A rejected token is not this error. The caller logs it once.\n */\nexport class TurnstileVerificationError extends AppError {\n readonly _tag = 'TurnstileVerificationError'\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n\n constructor(args: {\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n readonly cause: unknown\n }) {\n super('Turnstile siteverify could not produce a verdict', { cause: args.cause })\n this.classification = args.classification\n this.errorCodes = args.errorCodes\n }\n}\n","import { Result } from 'better-result'\nimport { z } from 'zod'\n\nimport {\n classifyBoundaryError,\n exponentialEqualJitterMs,\n shouldRetryFailure,\n type FailureClassification,\n} from '@/core'\n\nimport { TurnstileVerificationError } from './turnstile.error.ts'\n\n/**\n * Turnstile siteverify. Ported from Suparelay `verify-turnstile.ts` and Typist `turnstile.ts`.\n * A rejection is a verdict. This module does not log, and one call retries with one idempotency key.\n */\n\nconst SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'\n\n// Cloudflare documents 2048 characters. The testing secret still accepts a longer token.\nconst MAX_TOKEN_LENGTH = 2048\n\n// Cloudflare's validation example stops at 10 seconds.\nconst DEFAULT_TIMEOUT_MS = 10_000\n\n// `times` counts retries after the first attempt, so a call stops at three attempts.\nconst RETRY_TIMES = 2\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'missing-input-secret',\n 'invalid-input-secret',\n 'bad-request',\n])\n\nconst SuccessBody = z.object({\n success: z.literal(true),\n hostname: z.string(),\n challenge_ts: z.string(),\n action: z.string().optional(),\n metadata: z.object({ result_with_testing_key: z.boolean().optional() }).optional(),\n})\n\nconst FailureBody = z.object({\n success: z.literal(false),\n 'error-codes': z.array(z.string()).default([]),\n})\n\ntype SuccessBody = z.infer<typeof SuccessBody>\n\n/** The values siteverify needs. The secret stays in the caller. */\nexport type TurnstileCheck = {\n readonly secretKey: string\n readonly token: string\n /** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */\n readonly remoteIp?: string\n /** Widget action. A present, different action rejects. A missing action does not. */\n readonly expectedAction?: string\n /** Allowed widget hostnames. A testing-key success skips this list. */\n readonly expectedHostnames?: readonly string[]\n /** UUID reused for this call's retries. Omit it and the call mints one. */\n readonly idempotencyKey?: string\n}\n\n/** A solved challenge, or a refusal siteverify already decided. */\nexport type TurnstileVerdict =\n | {\n readonly _tag: 'Accepted'\n readonly hostname: string\n readonly challengeTs: string\n readonly action?: string\n }\n | {\n readonly _tag: 'Rejected'\n readonly errorCodes: readonly string[]\n }\n\n/** Deadline and fetch overrides. Production calls omit this argument. */\nexport type TurnstileVerifyOptions = {\n /** Replaces global `fetch`. A test uses it for a response the testing secret cannot return. */\n readonly fetch?: typeof fetch\n /** Siteverify deadline in milliseconds. The default is 10 seconds. */\n readonly timeoutMs?: number\n}\n\nclass SiteverifyRetry extends Error {\n readonly _tag = 'SiteverifyRetry'\n\n constructor(\n readonly errorCodes: readonly string[],\n cause: unknown,\n ) {\n super('Turnstile siteverify did not answer', { cause })\n }\n}\n\n/**\n * Ask siteverify whether this token is a solved challenge.\n * A rejection is a verdict. No verdict throws `TurnstileVerificationError`.\n * A transient failure retries twice with the same idempotency key.\n */\nexport async function verifyTurnstile(\n check: TurnstileCheck,\n options: TurnstileVerifyOptions = {},\n): Promise<TurnstileVerdict> {\n if (check.token.length > MAX_TOKEN_LENGTH) {\n return { _tag: 'Rejected', errorCodes: ['invalid-input-response'] }\n }\n const idempotencyKey = resolveIdempotencyKey(check.idempotencyKey)\n const result = await Result.tryPromise(\n {\n try: () => requestVerdict(check, idempotencyKey, options),\n catch: (cause) => cause,\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: (_cause, context) => exponentialEqualJitterMs(25, 200, context.attempt),\n shouldRetry: (cause) =>\n cause instanceof SiteverifyRetry &&\n shouldRetryFailure({\n classification: 'transient',\n repeatSafe: true,\n owner: 'immediate',\n }),\n },\n },\n )\n if (result.isOk()) return result.value\n if (result.error instanceof TurnstileVerificationError) throw result.error\n if (result.error instanceof SiteverifyRetry) {\n throw new TurnstileVerificationError({\n classification: 'transient',\n errorCodes: result.error.errorCodes,\n cause: result.error.cause,\n })\n }\n throw new TurnstileVerificationError({\n classification: 'unknown',\n errorCodes: [],\n cause: result.error,\n })\n}\n\nfunction resolveIdempotencyKey(key: string | undefined): string {\n if (key === undefined) return crypto.randomUUID()\n if (z.uuid().safeParse(key).success) return key\n throw new TurnstileVerificationError({\n classification: 'terminal',\n errorCodes: [],\n cause: { idempotencyKey: 'invalid' },\n })\n}\n\nasync function requestVerdict(\n check: TurnstileCheck,\n idempotencyKey: string,\n options: TurnstileVerifyOptions,\n): Promise<TurnstileVerdict> {\n const fetchImpl = options.fetch ?? fetch\n const fields = {\n secret: check.secretKey,\n response: check.token,\n idempotency_key: idempotencyKey,\n }\n const payload =\n check.remoteIp !== undefined && check.remoteIp !== ''\n ? { ...fields, remoteip: check.remoteIp }\n : fields\n\n let response: Response\n try {\n response = await fetchImpl(SITEVERIFY_URL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n })\n } catch (cause) {\n throw transportFailure(cause)\n }\n\n const text = await response.text()\n let parsed: unknown\n try {\n parsed = JSON.parse(text)\n } catch (cause) {\n throw unparsedFailure(response.status, cause)\n }\n\n const success = SuccessBody.safeParse(parsed)\n if (success.success) return decideSuccess(success.data, check)\n const failure = FailureBody.safeParse(parsed)\n if (failure.success) return decideFailure(failure.data['error-codes'], response.status)\n throw unparsedFailure(response.status, { status: response.status })\n}\n\nfunction decideSuccess(body: SuccessBody, check: TurnstileCheck): TurnstileVerdict {\n // A testing secret omits action. Compare only an action the response names.\n if (\n check.expectedAction !== undefined &&\n body.action !== undefined &&\n body.action !== check.expectedAction\n ) {\n return { _tag: 'Rejected', errorCodes: ['action-mismatch'] }\n }\n // A testing secret reports example.com, so the allowlist does not apply to it.\n const testingKey = body.metadata?.result_with_testing_key === true\n if (\n !testingKey &&\n check.expectedHostnames !== undefined &&\n !check.expectedHostnames.includes(body.hostname)\n ) {\n return { _tag: 'Rejected', errorCodes: ['hostname-mismatch'] }\n }\n if (body.action === undefined) {\n return { _tag: 'Accepted', hostname: body.hostname, challengeTs: body.challenge_ts }\n }\n return {\n _tag: 'Accepted',\n hostname: body.hostname,\n challengeTs: body.challenge_ts,\n action: body.action,\n }\n}\n\nfunction decideFailure(errorCodes: readonly string[], status: number): TurnstileVerdict {\n if (errorCodes.some((code) => CONFIGURATION_CODES.has(code))) {\n throw verificationFailure('terminal', errorCodes, status)\n }\n if (errorCodes.includes('internal-error') || status === 429 || status >= 500) {\n throw new SiteverifyRetry(errorCodes, { status })\n }\n return { _tag: 'Rejected', errorCodes }\n}\n\nfunction transportFailure(cause: unknown): Error {\n const classification = classifyBoundaryError({\n cause,\n classifyBoundary: () => undefined,\n includeCloudflare: false,\n })\n if (classification === 'transient') return new SiteverifyRetry([], cause)\n return new TurnstileVerificationError({ classification, errorCodes: [], cause })\n}\n\nfunction unparsedFailure(status: number, cause: unknown): Error {\n if (status === 429 || status >= 500) return new SiteverifyRetry([], cause)\n return verificationFailure('terminal', [], status)\n}\n\nfunction verificationFailure(\n classification: FailureClassification,\n errorCodes: readonly string[],\n status: number,\n): TurnstileVerificationError {\n return new TurnstileVerificationError({\n classification,\n errorCodes,\n cause: { status },\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"turnstile.js","names":[],"sources":["../../src/infrastructure/cloudflare/turnstile/turnstile.error.ts","../../src/infrastructure/cloudflare/turnstile/turnstile.ts"],"sourcesContent":["import { AppError, type FailureClassification } from '@/core'\n\n/**\n * Siteverify did not produce a verdict.\n * A rejected token is not this error. The caller logs it once.\n */\nexport class TurnstileVerificationError extends AppError {\n readonly _tag = 'TurnstileVerificationError'\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n\n constructor(args: {\n readonly classification: FailureClassification\n readonly errorCodes: readonly string[]\n readonly cause: unknown\n }) {\n super('Turnstile siteverify could not produce a verdict', { cause: args.cause })\n this.classification = args.classification\n this.errorCodes = args.errorCodes\n }\n}\n","import { Result } from 'better-result'\nimport { z } from 'zod'\n\nimport {\n classifyBoundaryError,\n exponentialEqualJitterMs,\n shouldRetryFailure,\n type FailureClassification,\n} from '@/core'\n\nimport { TurnstileVerificationError } from './turnstile.error.ts'\n\n/**\n * Turnstile siteverify. Ported from Suparelay `verify-turnstile.ts` and Typist `turnstile.ts`.\n * A rejection is a verdict. This module does not log, and one call retries with one idempotency key.\n */\n\nconst SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'\n\n// Cloudflare documents 2048 characters. The testing secret still accepts a longer token.\nconst MAX_TOKEN_LENGTH = 2048\n\n// Cloudflare's validation example stops at 10 seconds.\nconst DEFAULT_TIMEOUT_MS = 10_000\n\n// `times` counts retries after the first attempt, so a call stops at three attempts.\nconst RETRY_TIMES = 2\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'missing-input-secret',\n 'invalid-input-secret',\n 'bad-request',\n])\n\nconst SuccessBody = z.object({\n success: z.literal(true),\n hostname: z.string(),\n challenge_ts: z.string(),\n action: z.string().optional(),\n metadata: z.object({ result_with_testing_key: z.boolean().optional() }).optional(),\n})\n\nconst FailureBody = z.object({\n success: z.literal(false),\n 'error-codes': z.array(z.string()).default([]),\n})\n\ntype SuccessBody = z.infer<typeof SuccessBody>\n\n/** The values siteverify needs. The secret stays in the caller. */\nexport type TurnstileCheck = {\n readonly secretKey: string\n readonly token: string\n /** Visitor address from `CF-Connecting-IP`. `null` and omission both mean no address. */\n readonly remoteIp?: string | null\n /** Widget action. A present, different action rejects. A missing action does not. */\n readonly expectedAction?: string\n /** Allowed widget hostnames. A testing-key success skips this list. */\n readonly expectedHostnames?: readonly string[]\n /** UUID reused for this call's retries. Omit it and the call mints one. */\n readonly idempotencyKey?: string\n}\n\n/** A solved challenge, or a refusal siteverify already decided. */\nexport type TurnstileVerdict =\n | {\n readonly _tag: 'Accepted'\n readonly hostname: string\n readonly challengeTs: string\n readonly action?: string\n }\n | {\n readonly _tag: 'Rejected'\n readonly errorCodes: readonly string[]\n }\n\n/** Deadline and fetch overrides. Production calls omit this argument. */\nexport type TurnstileVerifyOptions = {\n /** Replaces global `fetch`. A test uses it for a response the testing secret cannot return. */\n readonly fetch?: typeof fetch\n /** Siteverify deadline in milliseconds. The default is 10 seconds. */\n readonly timeoutMs?: number\n}\n\nclass SiteverifyRetry extends Error {\n readonly _tag = 'SiteverifyRetry'\n\n constructor(\n readonly errorCodes: readonly string[],\n cause: unknown,\n ) {\n super('Turnstile siteverify did not answer', { cause })\n }\n}\n\n/**\n * Ask siteverify whether this token is a solved challenge.\n * A rejection is a verdict. No verdict throws `TurnstileVerificationError`.\n * A transient failure retries twice with the same idempotency key.\n */\nexport async function verifyTurnstile(\n check: TurnstileCheck,\n options: TurnstileVerifyOptions = {},\n): Promise<TurnstileVerdict> {\n if (check.token.length > MAX_TOKEN_LENGTH) {\n return { _tag: 'Rejected', errorCodes: ['invalid-input-response'] }\n }\n const idempotencyKey = resolveIdempotencyKey(check.idempotencyKey)\n const result = await Result.tryPromise(\n {\n try: () => requestVerdict(check, idempotencyKey, options),\n catch: (cause) => cause,\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: (_cause, context) => exponentialEqualJitterMs(25, 200, context.attempt),\n shouldRetry: (cause) =>\n cause instanceof SiteverifyRetry &&\n shouldRetryFailure({\n classification: 'transient',\n repeatSafe: true,\n owner: 'immediate',\n }),\n },\n },\n )\n if (result.isOk()) return result.value\n if (result.error instanceof TurnstileVerificationError) throw result.error\n if (result.error instanceof SiteverifyRetry) {\n throw new TurnstileVerificationError({\n classification: 'transient',\n errorCodes: result.error.errorCodes,\n cause: result.error.cause,\n })\n }\n throw new TurnstileVerificationError({\n classification: 'unknown',\n errorCodes: [],\n cause: result.error,\n })\n}\n\nfunction resolveIdempotencyKey(key: string | undefined): string {\n if (key === undefined) return crypto.randomUUID()\n if (z.uuid().safeParse(key).success) return key\n throw new TurnstileVerificationError({\n classification: 'terminal',\n errorCodes: [],\n cause: { idempotencyKey: 'invalid' },\n })\n}\n\nasync function requestVerdict(\n check: TurnstileCheck,\n idempotencyKey: string,\n options: TurnstileVerifyOptions,\n): Promise<TurnstileVerdict> {\n const fetchImpl = options.fetch ?? fetch\n const fields = {\n secret: check.secretKey,\n response: check.token,\n idempotency_key: idempotencyKey,\n }\n const payload =\n check.remoteIp !== undefined && check.remoteIp !== null && check.remoteIp !== ''\n ? { ...fields, remoteip: check.remoteIp }\n : fields\n\n let response: Response\n try {\n response = await fetchImpl(SITEVERIFY_URL, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n })\n } catch (cause) {\n throw transportFailure(cause)\n }\n\n const text = await response.text()\n let parsed: unknown\n try {\n parsed = JSON.parse(text)\n } catch (cause) {\n throw unparsedFailure(response.status, cause)\n }\n\n const success = SuccessBody.safeParse(parsed)\n if (success.success) return decideSuccess(success.data, check)\n const failure = FailureBody.safeParse(parsed)\n if (failure.success) return decideFailure(failure.data['error-codes'], response.status)\n throw unparsedFailure(response.status, { status: response.status })\n}\n\nfunction decideSuccess(body: SuccessBody, check: TurnstileCheck): TurnstileVerdict {\n // A testing secret omits action. Compare only an action the response names.\n if (\n check.expectedAction !== undefined &&\n body.action !== undefined &&\n body.action !== check.expectedAction\n ) {\n return { _tag: 'Rejected', errorCodes: ['action-mismatch'] }\n }\n // A testing secret reports example.com, so the allowlist does not apply to it.\n const testingKey = body.metadata?.result_with_testing_key === true\n if (\n !testingKey &&\n check.expectedHostnames !== undefined &&\n !check.expectedHostnames.includes(body.hostname)\n ) {\n return { _tag: 'Rejected', errorCodes: ['hostname-mismatch'] }\n }\n if (body.action === undefined) {\n return { _tag: 'Accepted', hostname: body.hostname, challengeTs: body.challenge_ts }\n }\n return {\n _tag: 'Accepted',\n hostname: body.hostname,\n challengeTs: body.challenge_ts,\n action: body.action,\n }\n}\n\nfunction decideFailure(errorCodes: readonly string[], status: number): TurnstileVerdict {\n if (errorCodes.some((code) => CONFIGURATION_CODES.has(code))) {\n throw verificationFailure('terminal', errorCodes, status)\n }\n if (errorCodes.includes('internal-error') || status === 429 || status >= 500) {\n throw new SiteverifyRetry(errorCodes, { status })\n }\n return { _tag: 'Rejected', errorCodes }\n}\n\nfunction transportFailure(cause: unknown): Error {\n const classification = classifyBoundaryError({\n cause,\n classifyBoundary: () => undefined,\n includeCloudflare: false,\n })\n if (classification === 'transient') return new SiteverifyRetry([], cause)\n return new TurnstileVerificationError({ classification, errorCodes: [], cause })\n}\n\nfunction unparsedFailure(status: number, cause: unknown): Error {\n if (status === 429 || status >= 500) return new SiteverifyRetry([], cause)\n return verificationFailure('terminal', [], status)\n}\n\nfunction verificationFailure(\n classification: FailureClassification,\n errorCodes: readonly string[],\n status: number,\n): TurnstileVerificationError {\n return new TurnstileVerificationError({\n classification,\n errorCodes,\n cause: { status },\n })\n}\n"],"mappings":"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"}
package/dist/tanstack.js CHANGED
@@ -75,12 +75,30 @@ function createAuthMiddleware(options) {
75
75
  //#endregion
76
76
  //#region src/infrastructure/tanstack/resolve-failure.ts
77
77
  const logger = createLogger("error-boundary");
78
+ const SerializedIssuesSchema = z.array(z.object({
79
+ message: z.string(),
80
+ path: z.array(z.union([z.string(), z.number()]))
81
+ })).min(1);
82
+ function reconstructValidationError(cause) {
83
+ if (cause instanceof z.ZodError) return ValidationError.fromZod(cause);
84
+ if (!(cause instanceof Error)) return null;
85
+ let message;
86
+ try {
87
+ message = JSON.parse(cause.message);
88
+ } catch {
89
+ return null;
90
+ }
91
+ const parsed = SerializedIssuesSchema.safeParse(message);
92
+ return parsed.success ? ValidationError.fromIssues(parsed.data) : null;
93
+ }
78
94
  /**
79
95
  * Resolve one failure through the table or fall back to the generic replacement, and log it once.
96
+ * A thrown ZodError, or issues serialized into an error message, becomes a ValidationError first.
80
97
  * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.
81
98
  */
82
99
  function resolveFailure(table, cause) {
83
- const resolved = table.resolve(cause);
100
+ const failure = reconstructValidationError(cause) ?? cause;
101
+ const resolved = table.resolve(failure);
84
102
  if (resolved !== null) {
85
103
  logger.warn("handled_error", {
86
104
  tag: resolved.payload._tag,
@@ -88,12 +106,12 @@ function resolveFailure(table, cause) {
88
106
  });
89
107
  return resolved;
90
108
  }
91
- const classification = cause instanceof AppError ? cause.classification : "unknown";
109
+ const classification = failure instanceof AppError ? failure.classification : "unknown";
92
110
  logger.error("unexpected_error", {
93
- error: cause,
111
+ error: failure,
94
112
  classification
95
113
  });
96
- return table.fallback(cause);
114
+ return table.fallback(failure);
97
115
  }
98
116
 
99
117
  //#endregion
@@ -154,28 +172,13 @@ function deletionOf(attributes) {
154
172
 
155
173
  //#endregion
156
174
  //#region src/infrastructure/tanstack/to-error-payload.ts
157
- const SerializedIssuesSchema = z.array(z.object({
158
- message: z.string(),
159
- path: z.array(z.union([z.string(), z.number()]))
160
- })).min(1);
161
- function reconstructValidationError(cause) {
162
- if (!(cause instanceof Error)) return null;
163
- let message;
164
- try {
165
- message = JSON.parse(cause.message);
166
- } catch {
167
- return null;
168
- }
169
- const parsed = SerializedIssuesSchema.safeParse(message);
170
- return parsed.success ? ValidationError.fromIssues(parsed.data) : null;
171
- }
172
175
  /**
173
176
  * Build the server-function converter for one error table.
174
177
  * The result is a plain object, because only own data survives serialization.
175
178
  */
176
179
  function createErrorPayloadConverter(table) {
177
180
  return function toErrorPayload(cause) {
178
- return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload;
181
+ return resolveFailure(table, cause).payload;
179
182
  };
180
183
  }
181
184
 
@@ -1 +1 @@
1
- {"version":3,"file":"tanstack.js","names":["logger"],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/resolve-failure.ts","../src/infrastructure/tanstack/http-error-response.ts","../src/infrastructure/tanstack/session-cookies.ts","../src/infrastructure/tanstack/to-error-payload.ts","../src/infrastructure/tanstack/error.middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start'\nimport { getSessionCookie } from 'better-auth/cookies'\n\nimport { AuthenticationError, StaleSessionError, createLogger } from '@/core'\n\nimport type { RequestContext } from './request-context.ts'\n\nconst logger = createLogger('auth-middleware')\n\n/** The fields middleware reads; generic callers retain every additional session and user field. */\nexport type AuthSession = {\n readonly user: { readonly id: string }\n readonly session: { readonly id: string }\n}\n\n/** Authenticated context retains the application's complete user and session types. */\nexport type RequiredAuthContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n}\n\n/** Optional authentication supplies both fields together or neither field. */\nexport type AuthContext<Session extends AuthSession> =\n | RequiredAuthContext<Session>\n | {\n readonly user: undefined\n readonly session: undefined\n }\n\n/** A resolved session and the request context that produced it. */\nexport type AuthenticatedContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n readonly requestContext: RequestContext\n}\n\n/** Select the current request's Better Auth instance from TanStack's registered context. */\nexport type AuthMiddlewareOptions<Session extends AuthSession> = {\n readonly getAuth: (context: RequestContext) => {\n readonly api: {\n readonly getSession: (options: { headers: Headers }) => Promise<Session | null>\n }\n }\n /** Runs after a session resolves and before the handler. A guest or a failed lookup does not call it. */\n readonly onAuthenticated?: (context: AuthenticatedContext<Session>) => void\n}\n\n// The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily inside `.server()`.\nconst requestHeaders = async () => {\n const { getRequestHeaders } = await import('@tanstack/react-start/server')\n return getRequestHeaders()\n}\n\n/**\n * Preserve Porte's function and request middleware contracts with application-owned services.\n * Optional lookup failures log once and continue as guests. Required lookup failures propagate.\n * `onAuthenticated` runs after a session resolves and before the rest of the chain.\n */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n function notifyAuthenticated(requestContext: RequestContext, resolved: Session): void {\n options.onAuthenticated?.({\n user: resolved.user,\n session: resolved.session,\n requestContext,\n })\n }\n const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(\n async ({ next, context }) => {\n let resolved: Session | null\n try {\n const headers = await requestHeaders()\n resolved = await options.getAuth(context).api.getSession({ headers })\n } catch (error) {\n logger.error('get_session_failed', { error })\n resolved = null\n }\n if (resolved === null)\n return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })\n notifyAuthenticated(context, resolved)\n return next<AuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n\n async function resolveRequiredSession(\n context: RequestContext,\n headers: Headers,\n ): Promise<Session> {\n const resolved = await options.getAuth(context).api.getSession({ headers })\n if (resolved === null) {\n if (getSessionCookie(headers)) throw new StaleSessionError()\n throw new AuthenticationError()\n }\n return resolved\n }\n\n const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {\n const resolved = await resolveRequiredSession(context, await requestHeaders())\n notifyAuthenticated(context, resolved)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n })\n const requireAuthRequest = createMiddleware({ type: 'request' }).server(\n async ({ request, next, context }) => {\n const resolved = await resolveRequiredSession(context, request.headers)\n notifyAuthenticated(context, resolved)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n return { extractAuth, requireAuth, requireAuthRequest }\n}\n","import { AppError, createLogger, type ErrorTable, type ResolvedError } from '@/core'\nimport type { ErrorPayloadOf } from '@/core'\n\nconst logger = createLogger('error-boundary')\n\n/**\n * Resolve one failure through the table or fall back to the generic replacement, and log it once.\n * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.\n */\nexport function resolveFailure<Entries>(\n table: ErrorTable<Entries>,\n cause: unknown,\n): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {\n const resolved = table.resolve(cause)\n if (resolved !== null) {\n logger.warn('handled_error', {\n tag: resolved.payload._tag,\n status: resolved.status,\n })\n return resolved\n }\n const classification = cause instanceof AppError ? cause.classification : 'unknown'\n logger.error('unexpected_error', { error: cause, classification })\n return table.fallback(cause)\n}\n","import { getReasonPhrase } from 'http-status-codes'\nimport { z } from 'zod'\n\nimport type { ErrorTable, ProblemDetails } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// A known delay becomes the header; a null delay sends no header.\nconst RetryAfterSchema = z.object({ retryAfter: z.number() })\n\n/**\n * Build the API route converter for one error table.\n * The body is RFC 9457 problem details; public fields become extension members.\n */\nexport function createHttpErrorResponseConverter<Entries>(table: ErrorTable<Entries>) {\n return function toHttpErrorResponse(cause: unknown): Response {\n const { status, payload } = resolveFailure(table, cause)\n const { _tag, message, ...fields } = payload\n const body: ProblemDetails = {\n type: 'about:blank',\n title: getReasonPhrase(status),\n detail: message,\n ...fields,\n }\n const headers = new Headers({ 'Content-Type': 'application/problem+json' })\n const retry = RetryAfterSchema.safeParse(payload)\n if (retry.success) headers.set('Retry-After', String(retry.data.retryAfter))\n return Response.json(body, { status, headers })\n }\n}\n","import type { deleteCookie } from '@tanstack/react-start/server'\nimport type { BetterAuthCookies } from 'better-auth'\nimport { parseCookies } from 'better-auth/cookies'\n\ntype AuthCookie = BetterAuthCookies['sessionToken']\ntype DeleteCookieOptions = NonNullable<Parameters<typeof deleteCookie>[1]>\n\n/**\n * Delete the session, session cache, account cache, and remember-me cookies of one instance.\n * Chunks present on the request go too. Names and attributes come from the instance, so custom prefixes and domain cookies clear.\n */\nexport async function clearStaleSessionCookies(cookies: BetterAuthCookies): Promise<void> {\n // The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily here.\n const server = await import('@tanstack/react-start/server')\n const present = [...parseCookies(server.getRequestHeaders().get('cookie') ?? '').keys()]\n const definitions = [\n cookies.sessionToken,\n cookies.sessionData,\n cookies.accountData,\n cookies.dontRememberToken,\n ]\n for (const cookie of definitions) {\n // Better Auth names a chunk `${name}.${index}`, so a missed chunk would resurrect the value.\n const chunks = present.filter((name) => name.startsWith(`${cookie.name}.`))\n for (const name of new Set([cookie.name, ...chunks]))\n server.deleteCookie(name, deletionOf(cookie.attributes))\n }\n}\n\nfunction deletionOf(attributes: AuthCookie['attributes']): DeleteCookieOptions {\n // Better Auth sets `/` and the secure flag at runtime; the types leave both optional.\n const options: DeleteCookieOptions = {\n path: attributes.path ?? '/',\n secure: attributes.secure ?? false,\n }\n if (attributes.domain !== undefined) options.domain = attributes.domain\n return options\n}\n","import { z } from 'zod'\n\nimport { ValidationError, type ErrorPayloadOf, type ErrorTable } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// TanStack serializes a validator's issues into `Error.message` as JSON.\nconst SerializedIssuesSchema = z\n .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))\n .min(1)\n\nfunction reconstructValidationError(cause: unknown): ValidationError | null {\n if (!(cause instanceof Error)) return null\n let message: unknown\n try {\n message = JSON.parse(cause.message)\n } catch {\n return null\n }\n const parsed = SerializedIssuesSchema.safeParse(message)\n return parsed.success ? ValidationError.fromIssues(parsed.data) : null\n}\n\n/**\n * Build the server-function converter for one error table.\n * The result is a plain object, because only own data survives serialization.\n */\nexport function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {\n return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {\n return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload\n }\n}\n","import { isNotFound, isRedirect, redirect } from '@tanstack/react-router'\nimport { createMiddleware } from '@tanstack/react-start'\nimport type { BetterAuthCookies } from 'better-auth'\n\nimport { StaleSessionError, type ErrorTable } from '@/core'\n\nimport { createHttpErrorResponseConverter } from './http-error-response.ts'\nimport type { RequestContext } from './request-context.ts'\nimport { clearStaleSessionCookies } from './session-cookies.ts'\nimport { createErrorPayloadConverter } from './to-error-payload.ts'\n\n/** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */\nexport type AuthCookieSource = {\n readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>\n}\n\n/** The application's error table, its stale-session redirect, and its Better Auth instance. */\nexport type ErrorMiddlewareOptions<Entries> = {\n readonly errors: ErrorTable<Entries>\n /** The path a server function redirects to for a stale session, with any search string. */\n readonly signIn: string\n /** Select the request's Better Auth instance; its cookie definitions say what to clear. */\n readonly getAuth: (context: RequestContext) => AuthCookieSource\n}\n\n/**\n * Build both transport boundaries from one error table.\n * Register `functionErrorMiddleware` globally. Put `routeErrorMiddleware` first in an API route's `server.middleware`.\n */\nexport function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<Entries>) {\n const toErrorPayload = createErrorPayloadConverter(options.errors)\n const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors)\n\n async function clearSession(context: RequestContext): Promise<void> {\n const { authCookies } = await options.getAuth(context).$context\n await clearStaleSessionCookies(authCookies)\n }\n\n async function handleFunctionError(cause: unknown, context: RequestContext): Promise<never> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) {\n await clearSession(context)\n // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.\n throw redirect({ href: options.signIn })\n }\n // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.\n throw toErrorPayload(cause)\n }\n\n // A route answers 401 instead of a redirect because fetch would follow it into the HTML shell.\n async function handleRouteError(cause: unknown, context: RequestContext): Promise<Response> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) await clearSession(context)\n return toHttpErrorResponse(cause)\n }\n\n const functionErrorMiddleware = createMiddleware({ type: 'function' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleFunctionError(cause, context)\n }\n },\n )\n\n const routeErrorMiddleware = createMiddleware({ type: 'request' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleRouteError(cause, context)\n }\n },\n )\n\n return { functionErrorMiddleware, routeErrorMiddleware, toErrorPayload, toHttpErrorResponse }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tanstack.js","names":["logger"],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/resolve-failure.ts","../src/infrastructure/tanstack/http-error-response.ts","../src/infrastructure/tanstack/session-cookies.ts","../src/infrastructure/tanstack/to-error-payload.ts","../src/infrastructure/tanstack/error.middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start'\nimport { getSessionCookie } from 'better-auth/cookies'\n\nimport { AuthenticationError, StaleSessionError, createLogger } from '@/core'\n\nimport type { RequestContext } from './request-context.ts'\n\nconst logger = createLogger('auth-middleware')\n\n/** The fields middleware reads; generic callers retain every additional session and user field. */\nexport type AuthSession = {\n readonly user: { readonly id: string }\n readonly session: { readonly id: string }\n}\n\n/** Authenticated context retains the application's complete user and session types. */\nexport type RequiredAuthContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n}\n\n/** Optional authentication supplies both fields together or neither field. */\nexport type AuthContext<Session extends AuthSession> =\n | RequiredAuthContext<Session>\n | {\n readonly user: undefined\n readonly session: undefined\n }\n\n/** A resolved session and the request context that produced it. */\nexport type AuthenticatedContext<Session extends AuthSession> = {\n readonly user: Session['user']\n readonly session: Session['session']\n readonly requestContext: RequestContext\n}\n\n/** Select the current request's Better Auth instance from TanStack's registered context. */\nexport type AuthMiddlewareOptions<Session extends AuthSession> = {\n readonly getAuth: (context: RequestContext) => {\n readonly api: {\n readonly getSession: (options: { headers: Headers }) => Promise<Session | null>\n }\n }\n /** Runs after a session resolves and before the handler. A guest or a failed lookup does not call it. */\n readonly onAuthenticated?: (context: AuthenticatedContext<Session>) => void\n}\n\n// The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily inside `.server()`.\nconst requestHeaders = async () => {\n const { getRequestHeaders } = await import('@tanstack/react-start/server')\n return getRequestHeaders()\n}\n\n/**\n * Preserve Porte's function and request middleware contracts with application-owned services.\n * Optional lookup failures log once and continue as guests. Required lookup failures propagate.\n * `onAuthenticated` runs after a session resolves and before the rest of the chain.\n */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n function notifyAuthenticated(requestContext: RequestContext, resolved: Session): void {\n options.onAuthenticated?.({\n user: resolved.user,\n session: resolved.session,\n requestContext,\n })\n }\n const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(\n async ({ next, context }) => {\n let resolved: Session | null\n try {\n const headers = await requestHeaders()\n resolved = await options.getAuth(context).api.getSession({ headers })\n } catch (error) {\n logger.error('get_session_failed', { error })\n resolved = null\n }\n if (resolved === null)\n return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })\n notifyAuthenticated(context, resolved)\n return next<AuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n\n async function resolveRequiredSession(\n context: RequestContext,\n headers: Headers,\n ): Promise<Session> {\n const resolved = await options.getAuth(context).api.getSession({ headers })\n if (resolved === null) {\n if (getSessionCookie(headers)) throw new StaleSessionError()\n throw new AuthenticationError()\n }\n return resolved\n }\n\n const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {\n const resolved = await resolveRequiredSession(context, await requestHeaders())\n notifyAuthenticated(context, resolved)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n })\n const requireAuthRequest = createMiddleware({ type: 'request' }).server(\n async ({ request, next, context }) => {\n const resolved = await resolveRequiredSession(context, request.headers)\n notifyAuthenticated(context, resolved)\n return next<RequiredAuthContext<Session>>({\n context: { user: resolved.user, session: resolved.session },\n })\n },\n )\n return { extractAuth, requireAuth, requireAuthRequest }\n}\n","import { z } from 'zod'\n\nimport {\n AppError,\n ValidationError,\n createLogger,\n type ErrorTable,\n type ResolvedError,\n} from '@/core'\nimport type { ErrorPayloadOf } from '@/core'\n\nconst logger = createLogger('error-boundary')\n\n// TanStack serializes a validator's issues into `Error.message` as JSON.\nconst SerializedIssuesSchema = z\n .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))\n .min(1)\n\n// A live ZodError keeps `.issues`. TanStack leaves only JSON in `Error.message`.\nfunction reconstructValidationError(cause: unknown): ValidationError | null {\n if (cause instanceof z.ZodError) return ValidationError.fromZod(cause)\n if (!(cause instanceof Error)) return null\n let message: unknown\n try {\n message = JSON.parse(cause.message)\n } catch {\n return null\n }\n const parsed = SerializedIssuesSchema.safeParse(message)\n return parsed.success ? ValidationError.fromIssues(parsed.data) : null\n}\n\n/**\n * Resolve one failure through the table or fall back to the generic replacement, and log it once.\n * A thrown ZodError, or issues serialized into an error message, becomes a ValidationError first.\n * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.\n */\nexport function resolveFailure<Entries>(\n table: ErrorTable<Entries>,\n cause: unknown,\n): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {\n const failure = reconstructValidationError(cause) ?? cause\n const resolved = table.resolve(failure)\n if (resolved !== null) {\n logger.warn('handled_error', {\n tag: resolved.payload._tag,\n status: resolved.status,\n })\n return resolved\n }\n const classification = failure instanceof AppError ? failure.classification : 'unknown'\n logger.error('unexpected_error', { error: failure, classification })\n return table.fallback(failure)\n}\n","import { getReasonPhrase } from 'http-status-codes'\nimport { z } from 'zod'\n\nimport type { ErrorTable, ProblemDetails } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n// A known delay becomes the header; a null delay sends no header.\nconst RetryAfterSchema = z.object({ retryAfter: z.number() })\n\n/**\n * Build the API route converter for one error table.\n * The body is RFC 9457 problem details; public fields become extension members.\n */\nexport function createHttpErrorResponseConverter<Entries>(table: ErrorTable<Entries>) {\n return function toHttpErrorResponse(cause: unknown): Response {\n const { status, payload } = resolveFailure(table, cause)\n const { _tag, message, ...fields } = payload\n const body: ProblemDetails = {\n type: 'about:blank',\n title: getReasonPhrase(status),\n detail: message,\n ...fields,\n }\n const headers = new Headers({ 'Content-Type': 'application/problem+json' })\n const retry = RetryAfterSchema.safeParse(payload)\n if (retry.success) headers.set('Retry-After', String(retry.data.retryAfter))\n return Response.json(body, { status, headers })\n }\n}\n","import type { deleteCookie } from '@tanstack/react-start/server'\nimport type { BetterAuthCookies } from 'better-auth'\nimport { parseCookies } from 'better-auth/cookies'\n\ntype AuthCookie = BetterAuthCookies['sessionToken']\ntype DeleteCookieOptions = NonNullable<Parameters<typeof deleteCookie>[1]>\n\n/**\n * Delete the session, session cache, account cache, and remember-me cookies of one instance.\n * Chunks present on the request go too. Names and attributes come from the instance, so custom prefixes and domain cookies clear.\n */\nexport async function clearStaleSessionCookies(cookies: BetterAuthCookies): Promise<void> {\n // The server entry loads react-dom/server; the browser evaluates this module, so it loads lazily here.\n const server = await import('@tanstack/react-start/server')\n const present = [...parseCookies(server.getRequestHeaders().get('cookie') ?? '').keys()]\n const definitions = [\n cookies.sessionToken,\n cookies.sessionData,\n cookies.accountData,\n cookies.dontRememberToken,\n ]\n for (const cookie of definitions) {\n // Better Auth names a chunk `${name}.${index}`, so a missed chunk would resurrect the value.\n const chunks = present.filter((name) => name.startsWith(`${cookie.name}.`))\n for (const name of new Set([cookie.name, ...chunks]))\n server.deleteCookie(name, deletionOf(cookie.attributes))\n }\n}\n\nfunction deletionOf(attributes: AuthCookie['attributes']): DeleteCookieOptions {\n // Better Auth sets `/` and the secure flag at runtime; the types leave both optional.\n const options: DeleteCookieOptions = {\n path: attributes.path ?? '/',\n secure: attributes.secure ?? false,\n }\n if (attributes.domain !== undefined) options.domain = attributes.domain\n return options\n}\n","import type { ErrorPayloadOf, ErrorTable } from '@/core'\n\nimport { resolveFailure } from './resolve-failure.ts'\n\n/**\n * Build the server-function converter for one error table.\n * The result is a plain object, because only own data survives serialization.\n */\nexport function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {\n return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {\n return resolveFailure(table, cause).payload\n }\n}\n","import { isNotFound, isRedirect, redirect } from '@tanstack/react-router'\nimport { createMiddleware } from '@tanstack/react-start'\nimport type { BetterAuthCookies } from 'better-auth'\n\nimport { StaleSessionError, type ErrorTable } from '@/core'\n\nimport { createHttpErrorResponseConverter } from './http-error-response.ts'\nimport type { RequestContext } from './request-context.ts'\nimport { clearStaleSessionCookies } from './session-cookies.ts'\nimport { createErrorPayloadConverter } from './to-error-payload.ts'\n\n/** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */\nexport type AuthCookieSource = {\n readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>\n}\n\n/** The application's error table, its stale-session redirect, and its Better Auth instance. */\nexport type ErrorMiddlewareOptions<Entries> = {\n readonly errors: ErrorTable<Entries>\n /** The path a server function redirects to for a stale session, with any search string. */\n readonly signIn: string\n /** Select the request's Better Auth instance; its cookie definitions say what to clear. */\n readonly getAuth: (context: RequestContext) => AuthCookieSource\n}\n\n/**\n * Build both transport boundaries from one error table.\n * Register `functionErrorMiddleware` globally. Put `routeErrorMiddleware` first in an API route's `server.middleware`.\n */\nexport function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<Entries>) {\n const toErrorPayload = createErrorPayloadConverter(options.errors)\n const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors)\n\n async function clearSession(context: RequestContext): Promise<void> {\n const { authCookies } = await options.getAuth(context).$context\n await clearStaleSessionCookies(authCookies)\n }\n\n async function handleFunctionError(cause: unknown, context: RequestContext): Promise<never> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) {\n await clearSession(context)\n // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.\n throw redirect({ href: options.signIn })\n }\n // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.\n throw toErrorPayload(cause)\n }\n\n // A route answers 401 instead of a redirect because fetch would follow it into the HTML shell.\n async function handleRouteError(cause: unknown, context: RequestContext): Promise<Response> {\n // oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.\n if (isNotFound(cause) || isRedirect(cause)) throw cause\n if (cause instanceof StaleSessionError) await clearSession(context)\n return toHttpErrorResponse(cause)\n }\n\n const functionErrorMiddleware = createMiddleware({ type: 'function' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleFunctionError(cause, context)\n }\n },\n )\n\n const routeErrorMiddleware = createMiddleware({ type: 'request' }).server(\n async ({ next, context }) => {\n try {\n return await next()\n } catch (cause) {\n return handleRouteError(cause, context)\n }\n },\n )\n\n return { functionErrorMiddleware, routeErrorMiddleware, toErrorPayload, toHttpErrorResponse }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.29.0",
4
+ "version": "0.29.2",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -51,8 +51,8 @@ type SuccessBody = z.infer<typeof SuccessBody>
51
51
  export type TurnstileCheck = {
52
52
  readonly secretKey: string
53
53
  readonly token: string
54
- /** Visitor address from `CF-Connecting-IP`. Omit it when the request has none. */
55
- readonly remoteIp?: string
54
+ /** Visitor address from `CF-Connecting-IP`. `null` and omission both mean no address. */
55
+ readonly remoteIp?: string | null
56
56
  /** Widget action. A present, different action rejects. A missing action does not. */
57
57
  readonly expectedAction?: string
58
58
  /** Allowed widget hostnames. A testing-key success skips this list. */
@@ -163,7 +163,7 @@ async function requestVerdict(
163
163
  idempotency_key: idempotencyKey,
164
164
  }
165
165
  const payload =
166
- check.remoteIp !== undefined && check.remoteIp !== ''
166
+ check.remoteIp !== undefined && check.remoteIp !== null && check.remoteIp !== ''
167
167
  ? { ...fields, remoteip: check.remoteIp }
168
168
  : fields
169
169
 
@@ -1,17 +1,46 @@
1
- import { AppError, createLogger, type ErrorTable, type ResolvedError } from '@/core'
1
+ import { z } from 'zod'
2
+
3
+ import {
4
+ AppError,
5
+ ValidationError,
6
+ createLogger,
7
+ type ErrorTable,
8
+ type ResolvedError,
9
+ } from '@/core'
2
10
  import type { ErrorPayloadOf } from '@/core'
3
11
 
4
12
  const logger = createLogger('error-boundary')
5
13
 
14
+ // TanStack serializes a validator's issues into `Error.message` as JSON.
15
+ const SerializedIssuesSchema = z
16
+ .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))
17
+ .min(1)
18
+
19
+ // A live ZodError keeps `.issues`. TanStack leaves only JSON in `Error.message`.
20
+ function reconstructValidationError(cause: unknown): ValidationError | null {
21
+ if (cause instanceof z.ZodError) return ValidationError.fromZod(cause)
22
+ if (!(cause instanceof Error)) return null
23
+ let message: unknown
24
+ try {
25
+ message = JSON.parse(cause.message)
26
+ } catch {
27
+ return null
28
+ }
29
+ const parsed = SerializedIssuesSchema.safeParse(message)
30
+ return parsed.success ? ValidationError.fromIssues(parsed.data) : null
31
+ }
32
+
6
33
  /**
7
34
  * Resolve one failure through the table or fall back to the generic replacement, and log it once.
35
+ * A thrown ZodError, or issues serialized into an error message, becomes a ValidationError first.
8
36
  * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.
9
37
  */
10
38
  export function resolveFailure<Entries>(
11
39
  table: ErrorTable<Entries>,
12
40
  cause: unknown,
13
41
  ): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {
14
- const resolved = table.resolve(cause)
42
+ const failure = reconstructValidationError(cause) ?? cause
43
+ const resolved = table.resolve(failure)
15
44
  if (resolved !== null) {
16
45
  logger.warn('handled_error', {
17
46
  tag: resolved.payload._tag,
@@ -19,7 +48,7 @@ export function resolveFailure<Entries>(
19
48
  })
20
49
  return resolved
21
50
  }
22
- const classification = cause instanceof AppError ? cause.classification : 'unknown'
23
- logger.error('unexpected_error', { error: cause, classification })
24
- return table.fallback(cause)
51
+ const classification = failure instanceof AppError ? failure.classification : 'unknown'
52
+ logger.error('unexpected_error', { error: failure, classification })
53
+ return table.fallback(failure)
25
54
  }
@@ -1,32 +1,13 @@
1
- import { z } from 'zod'
2
-
3
- import { ValidationError, type ErrorPayloadOf, type ErrorTable } from '@/core'
1
+ import type { ErrorPayloadOf, ErrorTable } from '@/core'
4
2
 
5
3
  import { resolveFailure } from './resolve-failure.ts'
6
4
 
7
- // TanStack serializes a validator's issues into `Error.message` as JSON.
8
- const SerializedIssuesSchema = z
9
- .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))
10
- .min(1)
11
-
12
- function reconstructValidationError(cause: unknown): ValidationError | null {
13
- if (!(cause instanceof Error)) return null
14
- let message: unknown
15
- try {
16
- message = JSON.parse(cause.message)
17
- } catch {
18
- return null
19
- }
20
- const parsed = SerializedIssuesSchema.safeParse(message)
21
- return parsed.success ? ValidationError.fromIssues(parsed.data) : null
22
- }
23
-
24
5
  /**
25
6
  * Build the server-function converter for one error table.
26
7
  * The result is a plain object, because only own data survives serialization.
27
8
  */
28
9
  export function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {
29
10
  return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {
30
- return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload
11
+ return resolveFailure(table, cause).payload
31
12
  }
32
13
  }