better-ship 0.36.0 → 0.38.0

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.
Files changed (118) hide show
  1. package/dist/{application-B5hnNUdI.js → application-Dsz-o9Aj.js} +8 -5
  2. package/dist/{application-B5hnNUdI.js.map → application-Dsz-o9Aj.js.map} +1 -1
  3. package/dist/application.d.ts +3 -3
  4. package/dist/application.js +3 -2
  5. package/dist/{auth-secondary-storage-b9ptwPSB.d.ts → auth-secondary-storage-CcNkkEk0.d.ts} +2 -2
  6. package/dist/{auth-secondary-storage-b9ptwPSB.d.ts.map → auth-secondary-storage-CcNkkEk0.d.ts.map} +1 -1
  7. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  8. package/dist/better-auth/apple-client-secret.js +1 -1
  9. package/dist/better-auth/auth-client.js +1 -1
  10. package/dist/better-auth/rate-limit.d.ts +1 -1
  11. package/dist/better-auth/rate-limit.js +1 -1
  12. package/dist/better-auth/secondary-storage.d.ts +1 -1
  13. package/dist/better-auth.js +1 -1
  14. package/dist/billing/client.d.ts +1 -1
  15. package/dist/billing/client.js +1 -1
  16. package/dist/browser-support/floor.d.ts +1 -1
  17. package/dist/browser-support/floor.js +1 -1
  18. package/dist/browser-support/oxlint.js +1 -1
  19. package/dist/browser-user.d.ts +14 -0
  20. package/dist/browser-user.d.ts.map +1 -0
  21. package/dist/browser-user.js +25 -0
  22. package/dist/browser-user.js.map +1 -0
  23. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  24. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  25. package/dist/cloudflare/durable-object-client.js +2 -2
  26. package/dist/cloudflare/durable-object-errors.d.ts +1 -1
  27. package/dist/cloudflare/durable-object-errors.js +1 -1
  28. package/dist/cloudflare/kv-client.js +2 -2
  29. package/dist/cloudflare/kv-errors.d.ts +1 -1
  30. package/dist/cloudflare/kv-errors.js +1 -1
  31. package/dist/cloudflare/queue.d.ts +2 -2
  32. package/dist/cloudflare/queue.js +2 -2
  33. package/dist/cloudflare/r2-client.js +2 -2
  34. package/dist/cloudflare/r2-errors.d.ts +1 -1
  35. package/dist/cloudflare/r2-errors.js +1 -1
  36. package/dist/cloudflare/rate-limiter.d.ts +1 -1
  37. package/dist/cloudflare/rate-limiter.js +1 -1
  38. package/dist/cloudflare/scheduled.d.ts +1 -1
  39. package/dist/cloudflare/scheduled.js +1 -1
  40. package/dist/cloudflare/turnstile.d.ts +1 -1
  41. package/dist/cloudflare/turnstile.js +1 -1
  42. package/dist/cloudflare.d.ts +3 -3
  43. package/dist/cloudflare.js +3 -3
  44. package/dist/{core-BiN6S_cm.js → core-DOBZeYSX.js} +2 -7
  45. package/dist/core-DOBZeYSX.js.map +1 -0
  46. package/dist/core.d.ts +2 -2
  47. package/dist/core.js +2 -2
  48. package/dist/{durable-object-errors-DkMFXQnK.js → durable-object-errors-CcfTu0PQ.js} +2 -2
  49. package/dist/{durable-object-errors-DkMFXQnK.js.map → durable-object-errors-CcfTu0PQ.js.map} +1 -1
  50. package/dist/{floor-Cuw8EIhg.js → floor-BTBtPI9_.js} +2 -2
  51. package/dist/{floor-Cuw8EIhg.js.map → floor-BTBtPI9_.js.map} +1 -1
  52. package/dist/http.d.ts +2 -2
  53. package/dist/http.js +2 -2
  54. package/dist/{index-CbvNd665.d.ts → index-BMY7ylgk.d.ts} +25 -7
  55. package/dist/index-BMY7ylgk.d.ts.map +1 -0
  56. package/dist/{index-DUFS0SJ_.d.ts → index-CtIdbnEZ.d.ts} +17 -11
  57. package/dist/index-CtIdbnEZ.d.ts.map +1 -0
  58. package/dist/{kv-errors-BUTpFy0t.js → kv-errors-B2k_2lyY.js} +2 -2
  59. package/dist/{kv-errors-BUTpFy0t.js.map → kv-errors-B2k_2lyY.js.map} +1 -1
  60. package/dist/{metrics-DXhrc6ii.d.ts → metrics-CdTF_lWR.d.ts} +2 -2
  61. package/dist/{metrics-DXhrc6ii.d.ts.map → metrics-CdTF_lWR.d.ts.map} +1 -1
  62. package/dist/{outbox-repository-options-BNpCWTht.js → outbox-repository-options-Ca67c3c9.js} +2 -2
  63. package/dist/{outbox-repository-options-BNpCWTht.js.map → outbox-repository-options-Ca67c3c9.js.map} +1 -1
  64. package/dist/{outbox-repository-options-gk3JOjLB.d.ts → outbox-repository-options-q-CGUoCr.d.ts} +3 -3
  65. package/dist/{outbox-repository-options-gk3JOjLB.d.ts.map → outbox-repository-options-q-CGUoCr.d.ts.map} +1 -1
  66. package/dist/{payment-service.error-CqF7GkNp.js → payment-service.error-BR10zqxk.js} +2 -2
  67. package/dist/{payment-service.error-CqF7GkNp.js.map → payment-service.error-BR10zqxk.js.map} +1 -1
  68. package/dist/postgres.d.ts +3 -3
  69. package/dist/postgres.js +3 -3
  70. package/dist/posthog/metrics.d.ts +1 -1
  71. package/dist/posthog/server.d.ts +6 -6
  72. package/dist/posthog/server.d.ts.map +1 -1
  73. package/dist/posthog/server.js +15 -13
  74. package/dist/posthog/server.js.map +1 -1
  75. package/dist/{r2-errors-rk7n7rvk.js → r2-errors-BF49eqU_.js} +2 -2
  76. package/dist/{r2-errors-rk7n7rvk.js.map → r2-errors-BF49eqU_.js.map} +1 -1
  77. package/dist/{request-4vJEWxyZ.js → request-BYQodKDU.js} +2 -2
  78. package/dist/{request-4vJEWxyZ.js.map → request-BYQodKDU.js.map} +1 -1
  79. package/dist/request-user-B4rh1c2-.js +19 -0
  80. package/dist/request-user-B4rh1c2-.js.map +1 -0
  81. package/dist/resend.d.ts +3 -3
  82. package/dist/resend.js +2 -2
  83. package/dist/sentry/client.d.ts +1 -1
  84. package/dist/sentry/client.js +1 -1
  85. package/dist/sentry/server.d.ts +6 -3
  86. package/dist/sentry/server.d.ts.map +1 -1
  87. package/dist/sentry/server.js +9 -1
  88. package/dist/sentry/server.js.map +1 -1
  89. package/dist/sentry/tanstack.d.ts +1 -1
  90. package/dist/stripe/payment-service.error.d.ts +1 -1
  91. package/dist/stripe/payment-service.error.js +1 -1
  92. package/dist/stripe/payment-service.js +1 -1
  93. package/dist/stripe/payment-webhook.d.ts +1 -1
  94. package/dist/stripe/payment-webhook.js +1 -1
  95. package/dist/stripe/request.js +1 -1
  96. package/dist/tanstack.d.ts +2 -2
  97. package/dist/tanstack.d.ts.map +1 -1
  98. package/dist/tanstack.js +4 -2
  99. package/dist/tanstack.js.map +1 -1
  100. package/dist/testing/outbox-repository.d.ts +2 -2
  101. package/dist/testing/payment-service.js +1 -1
  102. package/dist/{webhook-verification-lWso44-q.js → webhook-verification-C4R6OO7f.js} +2 -2
  103. package/dist/{webhook-verification-lWso44-q.js.map → webhook-verification-C4R6OO7f.js.map} +1 -1
  104. package/dist/{webhook-verification-D1c7BY4q.d.ts → webhook-verification-DL4L3zbu.d.ts} +2 -2
  105. package/dist/{webhook-verification-D1c7BY4q.d.ts.map → webhook-verification-DL4L3zbu.d.ts.map} +1 -1
  106. package/package.json +2 -1
  107. package/src/application/analytics-service.ts +35 -8
  108. package/src/application/index.ts +6 -1
  109. package/src/application/request-user.ts +18 -0
  110. package/src/core/analytics-event.ts +17 -13
  111. package/src/core/index.ts +1 -3
  112. package/src/infrastructure/observability/browser-user.ts +24 -0
  113. package/src/infrastructure/posthog/server.ts +28 -17
  114. package/src/infrastructure/sentry/server.ts +11 -1
  115. package/src/infrastructure/tanstack/auth.middleware.ts +3 -1
  116. package/dist/core-BiN6S_cm.js.map +0 -1
  117. package/dist/index-CbvNd665.d.ts.map +0 -1
  118. package/dist/index-DUFS0SJ_.d.ts.map +0 -1
@@ -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","../src/infrastructure/tanstack/function-tracing.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","import { createMiddleware } from '@tanstack/react-start'\n\nimport { withSpan } from '../cloudflare/tracing.ts'\nimport type { RequestContext } from './request-context.ts'\n\n/**\n * Run one server function call in a child span named for that function.\n * Ported from Suparelay `tracing.middleware.ts`. `getTracing` runs on each request.\n * A handler rejection is not a span exception, and this middleware does not log.\n */\nexport function createFunctionTracingMiddleware(getTracing: (context: RequestContext) => Tracing) {\n return createMiddleware({ type: 'function' }).server(({ context, serverFnMeta, next }) =>\n withSpan(\n getTracing(context),\n `server_fn ${serverFnMeta.name}`,\n {\n 'code.function.name': serverFnMeta.name,\n 'code.file.path': serverFnMeta.filename,\n },\n () => next(),\n ),\n )\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","../src/infrastructure/tanstack/function-tracing.middleware.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start'\nimport { getSessionCookie } from 'better-auth/cookies'\n\nimport { AuthenticationError, StaleSessionError, createLogger } from '@/core'\n\nimport { applyObservabilityUser } from '../../application/request-user.ts'\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 * A resolved session applies `{ id }` on the observability hook, then runs `onAuthenticated`, before the handler.\n */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n function notifyAuthenticated(requestContext: RequestContext, resolved: Session): void {\n applyObservabilityUser({ id: resolved.user.id })\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","import { createMiddleware } from '@tanstack/react-start'\n\nimport { withSpan } from '../cloudflare/tracing.ts'\nimport type { RequestContext } from './request-context.ts'\n\n/**\n * Run one server function call in a child span named for that function.\n * Ported from Suparelay `tracing.middleware.ts`. `getTracing` runs on each request.\n * A handler rejection is not a span exception, and this middleware does not log.\n */\nexport function createFunctionTracingMiddleware(getTracing: (context: RequestContext) => Tracing) {\n return createMiddleware({ type: 'function' }).server(({ context, serverFnMeta, next }) =>\n withSpan(\n getTracing(context),\n `server_fn ${serverFnMeta.name}`,\n {\n 'code.function.name': serverFnMeta.name,\n 'code.file.path': serverFnMeta.filename,\n },\n () => next(),\n ),\n )\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
- import { W as DomainEvent } from "../index-DUFS0SJ_.js";
2
- import { A as IOutboxRepository } from "../index-CbvNd665.js";
1
+ import { W as DomainEvent } from "../index-CtIdbnEZ.js";
2
+ import { A as IOutboxRepository } from "../index-BMY7ylgk.js";
3
3
  //#region src/infrastructure/testing/outbox-repository-contract.d.ts
4
4
  /** Stored fields observed by the adapter contract, independent of a database library. */
5
5
  export type OutboxContractRow = {
@@ -1,4 +1,4 @@
1
- import { t as PaymentServiceError } from "../payment-service.error-CqF7GkNp.js";
1
+ import { t as PaymentServiceError } from "../payment-service.error-BR10zqxk.js";
2
2
  import { t as stripeFixture } from "../stripe-fixture-yFUknTJO.js";
3
3
  import Stripe from "stripe";
4
4
  import { deepStrictEqual, equal, ok, rejects } from "node:assert/strict";
@@ -1,4 +1,4 @@
1
- import { nt as AppError } from "./core-BiN6S_cm.js";
1
+ import { tt as AppError } from "./core-DOBZeYSX.js";
2
2
 
3
3
  //#region src/infrastructure/http/webhook-verification.ts
4
4
  /** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
@@ -15,4 +15,4 @@ var WebhookVerificationError = class extends AppError {
15
15
 
16
16
  //#endregion
17
17
  export { WebhookVerificationError as t };
18
- //# sourceMappingURL=webhook-verification-lWso44-q.js.map
18
+ //# sourceMappingURL=webhook-verification-C4R6OO7f.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-lWso44-q.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Verified> {\n /**\n * Verify the exact body before parsing it. Return null for an ignored webhook.\n * @throws WebhookVerificationError when the request cannot be trusted or parsed.\n */\n verify(body: string, headers: Headers): Promise<Verified | null>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
1
+ {"version":3,"file":"webhook-verification-C4R6OO7f.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Verified> {\n /**\n * Verify the exact body before parsing it. Return null for an ignored webhook.\n * @throws WebhookVerificationError when the request cannot be trusted or parsed.\n */\n verify(body: string, headers: Headers): Promise<Verified | null>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
@@ -1,4 +1,4 @@
1
- import { Xt as AppError } from "./index-DUFS0SJ_.js";
1
+ import { Jt as AppError } from "./index-CtIdbnEZ.js";
2
2
  //#region src/infrastructure/http/webhook-verification.d.ts
3
3
  /** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
4
4
  declare class WebhookVerificationError extends AppError {
@@ -18,4 +18,4 @@ interface WebhookVerifier<Verified> {
18
18
  }
19
19
  //#endregion
20
20
  export { WebhookVerifier as n, WebhookVerificationError as t };
21
- //# sourceMappingURL=webhook-verification-D1c7BY4q.d.ts.map
21
+ //# sourceMappingURL=webhook-verification-DL4L3zbu.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-D1c7BY4q.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;UAOa,gBAAgB;;;;;EAK/B,OAAO,cAAc,SAAS,UAAU,QAAQ"}
1
+ {"version":3,"file":"webhook-verification-DL4L3zbu.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;UAOa,gBAAgB;;;;;EAK/B,OAAO,cAAc,SAAS,UAAU,QAAQ"}
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.36.0",
4
+ "version": "0.38.0",
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": {
@@ -31,6 +31,7 @@
31
31
  "./browser-support/oxlint": "./dist/browser-support/oxlint.js",
32
32
  "./browser-support/polyfills": "./dist/browser-support/polyfills.js",
33
33
  "./browser-support/update-screen": "./dist/browser-support/update-screen.js",
34
+ "./browser-user": "./dist/browser-user.js",
34
35
  "./cloudflare": "./dist/cloudflare.js",
35
36
  "./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
36
37
  "./cloudflare/durable-object-client": "./dist/cloudflare/durable-object-client.js",
@@ -1,4 +1,12 @@
1
- import type { AnalyticsEventIdentity, AnalyticsEventInput, AnalyticsProperties } from '@/core'
1
+ import type { AnalyticsEventInput, AnalyticsProperties, AnalyticsTarget } from '@/core'
2
+
3
+ /** The properties of one event name; an untyped service accepts any properties. */
4
+ export type AnalyticsPropertiesOf<
5
+ TEvent extends AnalyticsEventInput,
6
+ TName extends TEvent['name'],
7
+ > = string extends TEvent['name']
8
+ ? TEvent['properties']
9
+ : Extract<TEvent, { readonly name: TName }>['properties']
2
10
 
3
11
  /** Product analytics without an SDK dependency, ported from Typist and Porte. */
4
12
  export interface AnalyticsService<TEvent extends AnalyticsEventInput = AnalyticsEventInput> {
@@ -7,15 +15,34 @@ export interface AnalyticsService<TEvent extends AnalyticsEventInput = Analytics
7
15
  /** Merge a previous ID into this person; the application must establish that both identify the same person. */
8
16
  alias(distinctId: string, previousDistinctId: string): void
9
17
  /** Schedule an event for a person; returning does not confirm remote acceptance. */
10
- track(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void
18
+ track<TName extends TEvent['name']>(
19
+ name: TName,
20
+ properties: AnalyticsPropertiesOf<TEvent, TName>,
21
+ target: AnalyticsTarget,
22
+ ): void
11
23
  /** Schedule an event without creating a person profile for its distinct ID. */
12
- trackAnonymous(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void
24
+ trackAnonymous<TName extends TEvent['name']>(
25
+ name: TName,
26
+ properties: AnalyticsPropertiesOf<TEvent, TName>,
27
+ target: AnalyticsTarget,
28
+ ): void
13
29
  }
14
30
 
15
- /** Reports to nobody. The composition root selects this service when analytics is disabled. */
16
- export const NULL_ANALYTICS: AnalyticsService = {
31
+ /**
32
+ * Reports to nobody. The composition root selects this service when analytics is disabled.
33
+ * `satisfies` keeps its own type, so it serves an `AnalyticsService` of any event map.
34
+ */
35
+ export const NULL_ANALYTICS = {
17
36
  identify: () => {},
18
37
  alias: () => {},
19
- track: () => {},
20
- trackAnonymous: () => {},
21
- }
38
+ track: (
39
+ _name: string,
40
+ _properties: AnalyticsEventInput['properties'],
41
+ _target: AnalyticsTarget,
42
+ ) => {},
43
+ trackAnonymous: (
44
+ _name: string,
45
+ _properties: AnalyticsEventInput['properties'],
46
+ _target: AnalyticsTarget,
47
+ ) => {},
48
+ } satisfies AnalyticsService
@@ -1,5 +1,9 @@
1
1
  export type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
2
- export { NULL_ANALYTICS, type AnalyticsService } from './analytics-service.ts'
2
+ export {
3
+ NULL_ANALYTICS,
4
+ type AnalyticsPropertiesOf,
5
+ type AnalyticsService,
6
+ } from './analytics-service.ts'
3
7
  export {
4
8
  NULL_METRICS_SERVICE,
5
9
  type MetricsService,
@@ -12,6 +16,7 @@ export type {
12
16
  ObservabilityScope,
13
17
  ObservabilityUser,
14
18
  } from './observability-service.ts'
19
+ export { applyObservabilityUser, setObservabilityUserHook } from './request-user.ts'
15
20
  export {
16
21
  RecordingEmailService,
17
22
  type IEmailService,
@@ -0,0 +1,18 @@
1
+ import type { ObservabilityUser } from './observability-service.ts'
2
+
3
+ /**
4
+ * Process-wide request user hook.
5
+ * The Sentry server entry sets it once. Auth middleware calls it after a session resolves.
6
+ * The hook does not store a user id. The adapter writes the id on the current isolation scope.
7
+ */
8
+ let observabilityUserHook: ((user: ObservabilityUser) => void) | null = null
9
+
10
+ /** Set or clear the process-wide request user hook. */
11
+ export function setObservabilityUserHook(hook: ((user: ObservabilityUser) => void) | null): void {
12
+ observabilityUserHook = hook
13
+ }
14
+
15
+ /** Attribute the current execution to this user. No hook means the call does nothing. */
16
+ export function applyObservabilityUser(user: ObservabilityUser): void {
17
+ observabilityUserHook?.(user)
18
+ }
@@ -1,5 +1,3 @@
1
- import { z } from 'zod'
2
-
3
1
  /** An event name and its application-owned properties, ported from Typist's analytics/types.ts. */
4
2
  export interface AnalyticsEventInput<
5
3
  TName extends string = string,
@@ -24,14 +22,20 @@ export interface AnalyticsProperties {
24
22
  readonly [name: string]: AnalyticsProperty
25
23
  }
26
24
 
27
- /** Parse a persisted analytics event UUID; the SDK generates ordinary event UUIDs itself. */
28
- export const AnalyticsEventIdSchema = z.uuid().brand<'AnalyticsEventId'>()
29
-
30
- /** The identity of one analytics event across repeated application deliveries. */
31
- export type AnalyticsEventId = z.infer<typeof AnalyticsEventIdSchema>
32
-
33
- /** Supply both original values when replaying one event; never share them across different occurrences. */
34
- export interface AnalyticsEventIdentity {
35
- readonly uuid: AnalyticsEventId
36
- readonly timestamp: Date
37
- }
25
+ /**
26
+ * Who an event belongs to, and, for code that can run twice, which occurrence it is.
27
+ * Pass `eventId` and `occurredAt` together: the same pair always sends the same event,
28
+ * so the analytics provider drops a repeat. Any stable string is an event ID.
29
+ */
30
+ export type AnalyticsTarget =
31
+ | {
32
+ readonly distinctId: string
33
+ readonly eventId?: never
34
+ readonly occurredAt?: never
35
+ }
36
+ | {
37
+ readonly distinctId: string
38
+ readonly eventId: string
39
+ /** The business time of the occurrence, as a `Date` or an ISO 8601 string. */
40
+ readonly occurredAt: Date | string
41
+ }
package/src/core/index.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  export { AppError } from './app-error.ts'
2
2
  export { MissingConfigurationError } from './missing-configuration.error.ts'
3
3
  export {
4
- AnalyticsEventIdSchema,
5
- type AnalyticsEventId,
6
- type AnalyticsEventIdentity,
7
4
  type AnalyticsEventInput,
8
5
  type AnalyticsProperties,
9
6
  type AnalyticsProperty,
7
+ type AnalyticsTarget,
10
8
  } from './analytics-event.ts'
11
9
  export { EmailAddressSchema, type EmailAddress } from './email-address.ts'
12
10
  export {
@@ -0,0 +1,24 @@
1
+ import * as Sentry from '@sentry/tanstackstart-react'
2
+ import { posthog } from 'posthog-js'
3
+
4
+ /**
5
+ * Identify the signed-in user in PostHog and Sentry.
6
+ * Sends the id only. A client that is not initialized does nothing.
7
+ * Call it when the session user id is present. A loading session does not call it.
8
+ */
9
+ export function identifyObservabilityUser(userId: string): void {
10
+ // identify() warns when the SDK is not loaded. A disabled client must stay silent.
11
+ // oxlint-disable-next-line no-underscore-dangle -- PostHog's public loaded flag. There is no other initialized check.
12
+ if (posthog.__loaded) posthog.identify(userId)
13
+ if (Sentry.isEnabled()) Sentry.setUser({ id: userId })
14
+ }
15
+
16
+ /**
17
+ * Clear the Sentry user and reset PostHog on sign-out.
18
+ * A disabled client does nothing. Call this once from the application's sign-out path.
19
+ */
20
+ export function clearObservabilityUser(): void {
21
+ // oxlint-disable-next-line no-underscore-dangle -- Same loaded flag as identifyObservabilityUser.
22
+ if (posthog.__loaded) posthog.reset()
23
+ if (Sentry.isEnabled()) Sentry.setUser(null)
24
+ }
@@ -1,15 +1,19 @@
1
1
  import { PostHog, type PostHogOptions } from 'posthog-node'
2
+ import { validate as isUuid, v5 as uuidv5 } from 'uuid'
2
3
 
3
4
  import type { AnalyticsService } from '@/application'
4
5
  import {
5
6
  MissingConfigurationError,
6
- type AnalyticsEventIdentity,
7
7
  type AnalyticsEventInput,
8
8
  type AnalyticsProperties,
9
+ type AnalyticsTarget,
9
10
  } from '@/core'
10
11
 
11
12
  import { PostHogMetricsService } from './metrics.ts'
12
13
 
14
+ // Fixed forever: changing it changes every derived UUID, so PostHog would keep a repeat.
15
+ const EVENT_ID_NAMESPACE = '53d7af30-65cb-4176-b2c9-f1ed42367ab1'
16
+
13
17
  /** Deployment values and SDK options; the runtime owns delivery promises. */
14
18
  export interface PostHogAnalyticsConfig {
15
19
  readonly apiKey: string
@@ -70,37 +74,44 @@ export class PostHogAnalyticsService implements AnalyticsService {
70
74
  this.waitUntil(this.posthog.aliasImmediate({ distinctId, alias: previousDistinctId }))
71
75
  }
72
76
 
73
- /** Schedule an event; omit identity for SDK-generated UUID and capture time. */
74
- track(event: AnalyticsEventInput, distinctId: string, identity?: AnalyticsEventIdentity): void {
75
- this.capture(event, distinctId, 'person', identity)
77
+ /** Schedule an event; without an event ID the SDK sets a new UUID and the capture time. */
78
+ track(
79
+ name: string,
80
+ properties: AnalyticsEventInput['properties'],
81
+ target: AnalyticsTarget,
82
+ ): void {
83
+ this.capture(name, properties, target, 'person')
76
84
  }
77
85
 
78
86
  /** Schedule an event without creating a person profile for an attempt or resource ID. */
79
87
  trackAnonymous(
80
- event: AnalyticsEventInput,
81
- distinctId: string,
82
- identity?: AnalyticsEventIdentity,
88
+ name: string,
89
+ properties: AnalyticsEventInput['properties'],
90
+ target: AnalyticsTarget,
83
91
  ): void {
84
- this.capture(event, distinctId, 'anonymous', identity)
92
+ this.capture(name, properties, target, 'anonymous')
85
93
  }
86
94
 
87
95
  private capture(
88
- event: AnalyticsEventInput,
89
- distinctId: string,
96
+ name: string,
97
+ eventProperties: AnalyticsEventInput['properties'],
98
+ target: AnalyticsTarget,
90
99
  profile: 'person' | 'anonymous',
91
- identity?: AnalyticsEventIdentity,
92
100
  ): void {
93
- const properties = { ...event.properties }
101
+ const properties = { ...eventProperties }
94
102
  const payload: Parameters<PostHog['captureImmediate']>[0] = {
95
- distinctId,
96
- event: event.name,
103
+ distinctId: target.distinctId,
104
+ event: name,
97
105
  properties,
98
106
  }
99
107
  if (profile === 'anonymous')
100
108
  payload.properties = { ...properties, $process_person_profile: false }
101
- if (identity !== undefined) {
102
- payload.uuid = identity.uuid
103
- payload.timestamp = identity.timestamp
109
+ if (target.eventId !== undefined) {
110
+ // PostHog drops a repeat only when its uuid and timestamp match the first event.
111
+ payload.uuid = isUuid(target.eventId)
112
+ ? target.eventId
113
+ : uuidv5(target.eventId, EVENT_ID_NAMESPACE)
114
+ payload.timestamp = new Date(target.occurredAt)
104
115
  }
105
116
  this.waitUntil(this.posthog.captureImmediate(payload))
106
117
  }
@@ -3,6 +3,8 @@ import * as Sentry from '@sentry/cloudflare'
3
3
  import type { ObservabilityScope, ObservabilityService, ObservabilityUser } from '@/application'
4
4
  import type { LoggerErrorHook } from '@/core'
5
5
 
6
+ import { setObservabilityUserHook } from '../../application/request-user.ts'
7
+
6
8
  /** Application-owned deployment settings with the full Cloudflare SDK option contract. */
7
9
  export type SentryServerOptions = Sentry.CloudflareOptions & {
8
10
  readonly dsn: string
@@ -10,8 +12,16 @@ export type SentryServerOptions = Sentry.CloudflareOptions & {
10
12
  readonly enabled: boolean
11
13
  }
12
14
 
13
- /** Pass application options to Workers, Durable Objects, and Workflows with the SDK defaults. */
15
+ /**
16
+ * Pass application options to Workers, Durable Objects, and Workflows with the SDK defaults.
17
+ * Registers the request user hook so auth middleware can set `{ id }` on the current isolation scope.
18
+ */
14
19
  export function createSentryOptions(options: SentryServerOptions): Sentry.CloudflareOptions {
20
+ // Auth middleware cannot import this SDK. The hook writes `{ id }` only when Sentry is enabled.
21
+ setObservabilityUserHook((user) => {
22
+ if (!Sentry.isEnabled()) return
23
+ Sentry.setUser({ id: user.id })
24
+ })
15
25
  return options
16
26
  }
17
27
 
@@ -3,6 +3,7 @@ import { getSessionCookie } from 'better-auth/cookies'
3
3
 
4
4
  import { AuthenticationError, StaleSessionError, createLogger } from '@/core'
5
5
 
6
+ import { applyObservabilityUser } from '../../application/request-user.ts'
6
7
  import type { RequestContext } from './request-context.ts'
7
8
 
8
9
  const logger = createLogger('auth-middleware')
@@ -54,12 +55,13 @@ const requestHeaders = async () => {
54
55
  /**
55
56
  * Preserve Porte's function and request middleware contracts with application-owned services.
56
57
  * Optional lookup failures log once and continue as guests. Required lookup failures propagate.
57
- * `onAuthenticated` runs after a session resolves and before the rest of the chain.
58
+ * A resolved session applies `{ id }` on the observability hook, then runs `onAuthenticated`, before the handler.
58
59
  */
59
60
  export function createAuthMiddleware<Session extends AuthSession>(
60
61
  options: AuthMiddlewareOptions<Session>,
61
62
  ) {
62
63
  function notifyAuthenticated(requestContext: RequestContext, resolved: Session): void {
64
+ applyObservabilityUser({ id: resolved.user.id })
63
65
  options.onAuthenticated?.({
64
66
  user: resolved.user,
65
67
  session: resolved.session,