better-ship 0.10.0 → 0.12.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.
- package/dist/{application-DYfcAy-Q.js → application-CIxo_BpK.js} +32 -3
- package/dist/application-CIxo_BpK.js.map +1 -0
- package/dist/application.d.ts +3 -3
- package/dist/application.js +2 -2
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/queue.d.ts +1 -1
- package/dist/cloudflare/queue.js +2 -2
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +1 -1
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{core-DC_4pOrI.js → core-DG_cC8YT.js} +110 -2
- package/dist/{core-DC_4pOrI.js.map → core-DG_cC8YT.js.map} +1 -1
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +19 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +35 -0
- package/dist/http.js.map +1 -0
- package/dist/{index-6XMPJu7u.d.ts → index-Bl0K1RQW.d.ts} +90 -2
- package/dist/index-Bl0K1RQW.d.ts.map +1 -0
- package/dist/{index-_Cihfq1J.d.ts → index-DwWxIdQP.d.ts} +137 -55
- package/dist/index-DwWxIdQP.d.ts.map +1 -0
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/resend.d.ts +73 -0
- package/dist/resend.d.ts.map +1 -0
- package/dist/resend.js +284 -0
- package/dist/resend.js.map +1 -0
- package/dist/tanstack/server.d.ts +8 -0
- package/dist/tanstack/server.d.ts.map +1 -0
- package/dist/tanstack/server.js +16 -0
- package/dist/tanstack/server.js.map +1 -0
- package/dist/tanstack.d.ts +1 -8
- package/dist/tanstack.d.ts.map +1 -1
- package/dist/tanstack.js +15 -21
- package/dist/tanstack.js.map +1 -1
- package/dist/{unit-of-work-context-BFCmU92Z.js → unit-of-work-context-BPE1In2f.js} +2 -2
- package/dist/{unit-of-work-context-BFCmU92Z.js.map → unit-of-work-context-BPE1In2f.js.map} +1 -1
- package/dist/{unit-of-work-context-BWTJfYPW.d.ts → unit-of-work-context-D5ytJIa-.d.ts} +3 -3
- package/dist/{unit-of-work-context-BWTJfYPW.d.ts.map → unit-of-work-context-D5ytJIa-.d.ts.map} +1 -1
- package/dist/webhook-verification-BQ9qZnVK.js +18 -0
- package/dist/webhook-verification-BQ9qZnVK.js.map +1 -0
- package/dist/webhook-verification-sZtwTI5n.d.ts +29 -0
- package/dist/webhook-verification-sZtwTI5n.d.ts.map +1 -0
- package/package.json +10 -1
- package/src/application/email-service.ts +57 -0
- package/src/application/index.ts +16 -0
- package/src/application/record-email-event.ts +53 -0
- package/src/core/email-address.ts +7 -0
- package/src/core/email-deliverability.ts +31 -0
- package/src/core/email-idempotency-key.ts +34 -0
- package/src/core/email-service.error.ts +51 -0
- package/src/core/index.ts +18 -0
- package/src/core/record-email-event.ts +23 -0
- package/src/infrastructure/http/index.ts +6 -0
- package/src/infrastructure/http/webhook-handler.ts +35 -0
- package/src/infrastructure/http/webhook-verification.ts +27 -0
- package/src/infrastructure/resend/email-event.ts +142 -0
- package/src/infrastructure/resend/index.ts +3 -0
- package/src/infrastructure/resend/resend-email-service.ts +125 -0
- package/src/infrastructure/resend/resend-webhook.ts +70 -0
- package/src/infrastructure/tanstack/auth.middleware.ts +9 -3
- package/src/infrastructure/tanstack/error.middleware.ts +1 -1
- package/src/infrastructure/tanstack/index.ts +0 -1
- package/src/infrastructure/tanstack/server.ts +7 -0
- package/src/infrastructure/tanstack/session-cookies.ts +6 -4
- package/dist/application-DYfcAy-Q.js.map +0 -1
- package/dist/index-6XMPJu7u.d.ts.map +0 -1
- package/dist/index-_Cihfq1J.d.ts.map +0 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/infrastructure/tanstack/last-login-method.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Read the method this browser last signed in with from the current request, for the sign-in loader on the server.
|
|
4
|
+
* Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getLastUsedLoginMethod(cookieName?: string): string | null;
|
|
7
|
+
//#endregion
|
|
8
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","names":[],"sources":["../../src/infrastructure/tanstack/last-login-method.ts"],"mappings":";;;;;wBAUgB,uBAAuB"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { parseCookies } from "better-auth/cookies";
|
|
2
|
+
import { getRequestHeaders } from "@tanstack/react-start/server";
|
|
3
|
+
|
|
4
|
+
//#region src/infrastructure/tanstack/last-login-method.ts
|
|
5
|
+
const DEFAULT_COOKIE_NAME = "better-auth.last_used_login_method";
|
|
6
|
+
/**
|
|
7
|
+
* Read the method this browser last signed in with from the current request, for the sign-in loader on the server.
|
|
8
|
+
* Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.
|
|
9
|
+
*/
|
|
10
|
+
function getLastUsedLoginMethod(cookieName = DEFAULT_COOKIE_NAME) {
|
|
11
|
+
return parseCookies(getRequestHeaders().get("cookie") ?? "").get(cookieName) ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { getLastUsedLoginMethod };
|
|
16
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","names":[],"sources":["../../src/infrastructure/tanstack/last-login-method.ts"],"sourcesContent":["import { getRequestHeaders } from '@tanstack/react-start/server'\nimport { parseCookies } from 'better-auth/cookies'\n\n// Better Auth keeps the plugin default private; this is the value LastLoginMethodOptions.cookieName documents.\nconst DEFAULT_COOKIE_NAME = 'better-auth.last_used_login_method'\n\n/**\n * Read the method this browser last signed in with from the current request, for the sign-in loader on the server.\n * Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.\n */\nexport function getLastUsedLoginMethod(cookieName = DEFAULT_COOKIE_NAME): string | null {\n return parseCookies(getRequestHeaders().get('cookie') ?? '').get(cookieName) ?? null\n}\n"],"mappings":";;;;AAIA,MAAM,sBAAsB;;;;;AAM5B,SAAgB,uBAAuB,aAAa,qBAAoC;CACtF,OAAO,aAAa,kBAAkB,CAAC,CAAC,IAAI,QAAQ,KAAK,EAAE,CAAC,CAAC,IAAI,UAAU,KAAK;AAClF"}
|
package/dist/tanstack.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ot as ErrorTable } from "./index-
|
|
1
|
+
import { ot as ErrorTable } from "./index-Bl0K1RQW.js";
|
|
2
2
|
import { Register } from "@tanstack/react-start";
|
|
3
3
|
import { BetterAuthCookies } from "better-auth";
|
|
4
4
|
//#region src/infrastructure/tanstack/request-context.d.ts
|
|
@@ -88,12 +88,5 @@ export declare function createErrorMiddleware<Entries>(options: ErrorMiddlewareO
|
|
|
88
88
|
toHttpErrorResponse: (cause: unknown) => Response;
|
|
89
89
|
};
|
|
90
90
|
//#endregion
|
|
91
|
-
//#region src/infrastructure/tanstack/last-login-method.d.ts
|
|
92
|
-
/**
|
|
93
|
-
* Read the method this browser last signed in with from the current request, for the sign-in loader on the server.
|
|
94
|
-
* Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.
|
|
95
|
-
*/
|
|
96
|
-
export declare function getLastUsedLoginMethod(cookieName?: string): string | null;
|
|
97
|
-
//#endregion
|
|
98
91
|
export type { AuthContext, AuthCookieSource, AuthMiddlewareOptions, AuthSession, ErrorMiddlewareOptions, RequiredAuthContext };
|
|
99
92
|
//# sourceMappingURL=tanstack.d.ts.map
|
package/dist/tanstack.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tanstack.d.ts","names":[],"sources":["../src/infrastructure/tanstack/request-context.ts","../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/error.middleware.ts"
|
|
1
|
+
{"version":3,"file":"tanstack.d.ts","names":[],"sources":["../src/infrastructure/tanstack/request-context.ts","../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/error.middleware.ts"],"mappings":";;;;;;;;;;KAQY,iBAAiB;EAAmB;IAAU,sBAAsB;;IAC5E;;;;KCCQ;WACD;aAAiB;;WACjB;aAAoB;;;;KAInB,oBAAoB,gBAAgB;WACrC,MAAM;WACN,SAAS;;;KAIR,YAAY,gBAAgB,eACpC,oBAAoB;WAET;WACA;;;KAIH,sBAAsB,gBAAgB;WACvC,UAAU,SAAS;aACjB;eACE,aAAa;QAAW,SAAS;YAAc,QAAQ;;;;;;;;wBAetD,qBAAqB,gBAAgB,aACnD,SAAS,sBAAsB;;;;;;;;KCrCrB;WACD,UAAU;aAAmB,aAAa;;;;KAIzC,uBAAuB;WACxB,QAAQ,WAAW;;WAEnB;;WAEA,UAAU,SAAS,mBAAmB;;;;;;wBAOjC,sBAAsB,SAAS,SAAS,uBAAuB"}
|
package/dist/tanstack.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { B as StaleSessionError, H as AuthenticationError, I as ValidationError,
|
|
1
|
+
import { B as StaleSessionError, H as AuthenticationError, I as ValidationError, Q as AppError, a as createLogger } from "./core-DG_cC8YT.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { getReasonPhrase } from "http-status-codes";
|
|
3
4
|
import { createMiddleware } from "@tanstack/react-start";
|
|
4
|
-
import { deleteCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
|
5
5
|
import { getSessionCookie, parseCookies } from "better-auth/cookies";
|
|
6
6
|
import { isNotFound, isRedirect, redirect } from "@tanstack/react-router";
|
|
7
|
-
import { getReasonPhrase } from "http-status-codes";
|
|
8
7
|
|
|
9
8
|
//#region src/infrastructure/tanstack/auth.middleware.ts
|
|
10
9
|
const logger$1 = createLogger("auth-middleware");
|
|
10
|
+
const requestHeaders = async () => {
|
|
11
|
+
const { getRequestHeaders } = await import("@tanstack/react-start/server");
|
|
12
|
+
return getRequestHeaders();
|
|
13
|
+
};
|
|
11
14
|
/**
|
|
12
15
|
* Preserve Porte's function and request middleware contracts with application-owned services.
|
|
13
16
|
* Optional lookup failures log once and continue as guests. Required lookup failures propagate.
|
|
@@ -16,7 +19,8 @@ function createAuthMiddleware(options) {
|
|
|
16
19
|
const extractAuth = createMiddleware({ type: "function" }).server(async ({ next, context }) => {
|
|
17
20
|
let resolved;
|
|
18
21
|
try {
|
|
19
|
-
|
|
22
|
+
const headers = await requestHeaders();
|
|
23
|
+
resolved = await options.getAuth(context).api.getSession({ headers });
|
|
20
24
|
} catch (error) {
|
|
21
25
|
logger$1.error("get_session_failed", { error });
|
|
22
26
|
resolved = null;
|
|
@@ -41,7 +45,7 @@ function createAuthMiddleware(options) {
|
|
|
41
45
|
return {
|
|
42
46
|
extractAuth,
|
|
43
47
|
requireAuth: createMiddleware({ type: "function" }).server(async ({ next, context }) => {
|
|
44
|
-
const resolved = await resolveRequiredSession(context,
|
|
48
|
+
const resolved = await resolveRequiredSession(context, await requestHeaders());
|
|
45
49
|
return next({ context: {
|
|
46
50
|
user: resolved.user,
|
|
47
51
|
session: resolved.session
|
|
@@ -114,8 +118,9 @@ function createHttpErrorResponseConverter(table) {
|
|
|
114
118
|
* Delete the session, session cache, account cache, and remember-me cookies of one instance.
|
|
115
119
|
* Chunks present on the request go too. Names and attributes come from the instance, so custom prefixes and domain cookies clear.
|
|
116
120
|
*/
|
|
117
|
-
function clearStaleSessionCookies(cookies) {
|
|
118
|
-
const
|
|
121
|
+
async function clearStaleSessionCookies(cookies) {
|
|
122
|
+
const server = await import("@tanstack/react-start/server");
|
|
123
|
+
const present = [...parseCookies(server.getRequestHeaders().get("cookie") ?? "").keys()];
|
|
119
124
|
const definitions = [
|
|
120
125
|
cookies.sessionToken,
|
|
121
126
|
cookies.sessionData,
|
|
@@ -124,7 +129,7 @@ function clearStaleSessionCookies(cookies) {
|
|
|
124
129
|
];
|
|
125
130
|
for (const cookie of definitions) {
|
|
126
131
|
const chunks = present.filter((name) => name.startsWith(`${cookie.name}.`));
|
|
127
|
-
for (const name of /* @__PURE__ */ new Set([cookie.name, ...chunks])) deleteCookie(name, deletionOf(cookie.attributes));
|
|
132
|
+
for (const name of /* @__PURE__ */ new Set([cookie.name, ...chunks])) server.deleteCookie(name, deletionOf(cookie.attributes));
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
function deletionOf(attributes) {
|
|
@@ -174,7 +179,7 @@ function createErrorMiddleware(options) {
|
|
|
174
179
|
const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors);
|
|
175
180
|
async function clearSession(context) {
|
|
176
181
|
const { authCookies } = await options.getAuth(context).$context;
|
|
177
|
-
clearStaleSessionCookies(authCookies);
|
|
182
|
+
await clearStaleSessionCookies(authCookies);
|
|
178
183
|
}
|
|
179
184
|
async function handleFunctionError(cause, context) {
|
|
180
185
|
if (isNotFound(cause) || isRedirect(cause)) throw cause;
|
|
@@ -210,16 +215,5 @@ function createErrorMiddleware(options) {
|
|
|
210
215
|
}
|
|
211
216
|
|
|
212
217
|
//#endregion
|
|
213
|
-
|
|
214
|
-
const DEFAULT_COOKIE_NAME = "better-auth.last_used_login_method";
|
|
215
|
-
/**
|
|
216
|
-
* Read the method this browser last signed in with from the current request, for the sign-in loader on the server.
|
|
217
|
-
* Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.
|
|
218
|
-
*/
|
|
219
|
-
function getLastUsedLoginMethod(cookieName = DEFAULT_COOKIE_NAME) {
|
|
220
|
-
return parseCookies(getRequestHeaders().get("cookie") ?? "").get(cookieName) ?? null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
//#endregion
|
|
224
|
-
export { createAuthMiddleware, createErrorMiddleware, getLastUsedLoginMethod };
|
|
218
|
+
export { createAuthMiddleware, createErrorMiddleware };
|
|
225
219
|
//# sourceMappingURL=tanstack.js.map
|
package/dist/tanstack.js.map
CHANGED
|
@@ -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/last-login-method.ts"],"sourcesContent":["import { createMiddleware } from '@tanstack/react-start'\nimport { getRequestHeaders } from '@tanstack/react-start/server'\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/** 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}\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 */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\n) {\n const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(\n async ({ next, context }) => {\n let resolved: Session | null\n try {\n resolved = await options.getAuth(context).api.getSession({ headers: getRequestHeaders() })\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 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, getRequestHeaders())\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 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 details: { tag: resolved.payload._tag, status: resolved.status },\n })\n return resolved\n }\n const classification = cause instanceof AppError ? cause.classification : 'unknown'\n logger.error('unexpected_error', { error: cause, details: { 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 { deleteCookie, getRequestHeaders } 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 function clearStaleSessionCookies(cookies: BetterAuthCookies): void {\n const present = [...parseCookies(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 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 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 { getRequestHeaders } from '@tanstack/react-start/server'\nimport { parseCookies } from 'better-auth/cookies'\n\n// Better Auth keeps the plugin default private; this is the value LastLoginMethodOptions.cookieName documents.\nconst DEFAULT_COOKIE_NAME = 'better-auth.last_used_login_method'\n\n/**\n * Read the method this browser last signed in with from the current request, for the sign-in loader on the server.\n * Pass `cookieName` only when `integrations.lastLoginMethod.cookieName` is set. Returns `null` when the cookie is absent.\n */\nexport function getLastUsedLoginMethod(cookieName = DEFAULT_COOKIE_NAME): string | null {\n return parseCookies(getRequestHeaders().get('cookie') ?? '').get(cookieName) ?? null\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/** 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}\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 */\nexport function createAuthMiddleware<Session extends AuthSession>(\n options: AuthMiddlewareOptions<Session>,\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 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 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 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 details: { tag: resolved.payload._tag, status: resolved.status },\n })\n return resolved\n }\n const classification = cause instanceof AppError ? cause.classification : 'unknown'\n logger.error('unexpected_error', { error: cause, details: { 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,4 +1,4 @@
|
|
|
1
|
-
import { t as UnitOfWorkAlreadyActiveError } from "./application-
|
|
1
|
+
import { t as UnitOfWorkAlreadyActiveError } from "./application-CIxo_BpK.js";
|
|
2
2
|
import { panic } from "better-result";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
|
|
@@ -45,4 +45,4 @@ function currentContext() {
|
|
|
45
45
|
|
|
46
46
|
//#endregion
|
|
47
47
|
export { stageD1Statement as i, eventCollector as n, runWithUnitOfWorkContext as r, collectD1Statements as t };
|
|
48
|
-
//# sourceMappingURL=unit-of-work-context-
|
|
48
|
+
//# sourceMappingURL=unit-of-work-context-BPE1In2f.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-of-work-context-
|
|
1
|
+
{"version":3,"file":"unit-of-work-context-BPE1In2f.js","names":[],"sources":["../src/infrastructure/unit-of-work-context.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\n\nimport { panic } from 'better-result'\nimport type { BatchItem } from 'drizzle-orm/batch'\n\nimport { UnitOfWorkAlreadyActiveError, type IEventCollector } from '@/application'\nimport type { DomainEvent } from '@/core'\n\ntype UnitOfWorkContext = { readonly events: DomainEvent[] } & (\n | { readonly database: 'd1'; readonly statements: BatchItem<'sqlite'>[] }\n | { readonly database: 'postgres' }\n)\n\n// One async scope isolates events and D1 statements and rejects nesting across both adapters.\nconst storage = new AsyncLocalStorage<UnitOfWorkContext>()\n\n/** Reject nested transactions before database work starts; preserve independent concurrent execution. */\nexport async function runWithUnitOfWorkContext<T>(\n database: UnitOfWorkContext['database'],\n work: () => Promise<T>,\n): Promise<T> {\n if (storage.getStore() !== undefined) throw new UnitOfWorkAlreadyActiveError()\n const context: UnitOfWorkContext =\n database === 'd1' ? { database, events: [], statements: [] } : { database, events: [] }\n return storage.run(context, work)\n}\n\n/** Inject this shared scope into the unit of work and expose its emitter to application handlers. */\nexport const eventCollector: IEventCollector = {\n emit(event) {\n currentContext().events.push(event)\n },\n collect() {\n return [...currentContext().events]\n },\n}\n\n/** Queue one native D1 statement for the active batch; other contexts cannot accept D1 writes. */\nexport function stageD1Statement(statement: BatchItem<'sqlite'>): void {\n d1Context().statements.push(statement)\n}\n\n/** Read the active D1 batch in staging order for commit. */\nexport function collectD1Statements(): ReadonlyArray<BatchItem<'sqlite'>> {\n return [...d1Context().statements]\n}\n\nfunction d1Context(): Extract<UnitOfWorkContext, { database: 'd1' }> {\n const context = currentContext()\n if (context.database !== 'd1') return panic('D1 statements require a D1 unit of work')\n return context\n}\n\nfunction currentContext(): UnitOfWorkContext {\n return storage.getStore() ?? panic('operation requires an active unit of work')\n}\n"],"mappings":";;;;;AAcA,MAAM,UAAU,IAAI,kBAAqC;;AAGzD,eAAsB,yBACpB,UACA,MACY;CACZ,IAAI,QAAQ,SAAS,MAAM,QAAW,MAAM,IAAI,6BAA6B;CAC7E,MAAM,UACJ,aAAa,OAAO;EAAE;EAAU,QAAQ,CAAC;EAAG,YAAY,CAAC;CAAE,IAAI;EAAE;EAAU,QAAQ,CAAC;CAAE;CACxF,OAAO,QAAQ,IAAI,SAAS,IAAI;AAClC;;AAGA,MAAa,iBAAkC;CAC7C,KAAK,OAAO;EACV,eAAe,CAAC,CAAC,OAAO,KAAK,KAAK;CACpC;CACA,UAAU;EACR,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC,MAAM;CACpC;AACF;;AAGA,SAAgB,iBAAiB,WAAsC;CACrE,UAAU,CAAC,CAAC,WAAW,KAAK,SAAS;AACvC;;AAGA,SAAgB,sBAA0D;CACxE,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,UAAU;AACnC;AAEA,SAAS,YAA4D;CACnE,MAAM,UAAU,eAAe;CAC/B,IAAI,QAAQ,aAAa,MAAM,OAAO,MAAM,yCAAyC;CACrF,OAAO;AACT;AAEA,SAAS,iBAAoC;CAC3C,OAAO,QAAQ,SAAS,KAAK,MAAM,2CAA2C;AAChF"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import "./index-
|
|
2
|
-
import {
|
|
1
|
+
import "./index-Bl0K1RQW.js";
|
|
2
|
+
import { w as IEventCollector } from "./index-DwWxIdQP.js";
|
|
3
3
|
import { BatchItem } from "drizzle-orm/batch";
|
|
4
4
|
//#region src/infrastructure/drizzle-schema.d.ts
|
|
5
5
|
/**
|
|
@@ -13,4 +13,4 @@ type DrizzleSchema = Record<string, unknown>;
|
|
|
13
13
|
declare const eventCollector: IEventCollector;
|
|
14
14
|
//#endregion
|
|
15
15
|
export { DrizzleSchema as n, eventCollector as t };
|
|
16
|
-
//# sourceMappingURL=unit-of-work-context-
|
|
16
|
+
//# sourceMappingURL=unit-of-work-context-D5ytJIa-.d.ts.map
|
package/dist/{unit-of-work-context-BWTJfYPW.d.ts.map → unit-of-work-context-D5ytJIa-.d.ts.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-of-work-context-
|
|
1
|
+
{"version":3,"file":"unit-of-work-context-D5ytJIa-.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Q as AppError } from "./core-DG_cC8YT.js";
|
|
2
|
+
|
|
3
|
+
//#region src/infrastructure/http/webhook-verification.ts
|
|
4
|
+
/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
|
|
5
|
+
var WebhookVerificationError = class extends AppError {
|
|
6
|
+
reason;
|
|
7
|
+
_tag = "WebhookVerificationError";
|
|
8
|
+
classification = "terminal";
|
|
9
|
+
/** Keep secrets and payload details out of the public message. */
|
|
10
|
+
constructor(reason, cause) {
|
|
11
|
+
super(`Webhook rejected: ${reason}`, { cause });
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
export { WebhookVerificationError as t };
|
|
18
|
+
//# sourceMappingURL=webhook-verification-BQ9qZnVK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-verification-BQ9qZnVK.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/** A verified event, an ignored event, or an invalid request. */\nexport type WebhookVerification<Event> =\n | { readonly outcome: 'event'; readonly event: Event }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Event> {\n /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */\n verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>\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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Ut as AppError } from "./index-Bl0K1RQW.js";
|
|
2
|
+
//#region src/infrastructure/http/webhook-verification.d.ts
|
|
3
|
+
/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
|
|
4
|
+
declare class WebhookVerificationError extends AppError {
|
|
5
|
+
readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload';
|
|
6
|
+
readonly _tag = "WebhookVerificationError";
|
|
7
|
+
readonly classification = "terminal";
|
|
8
|
+
/** Keep secrets and payload details out of the public message. */
|
|
9
|
+
constructor(reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload', cause?: unknown);
|
|
10
|
+
}
|
|
11
|
+
/** A verified event, an ignored event, or an invalid request. */
|
|
12
|
+
type WebhookVerification<Event> = {
|
|
13
|
+
readonly outcome: 'event';
|
|
14
|
+
readonly event: Event;
|
|
15
|
+
} | {
|
|
16
|
+
readonly outcome: 'ignored';
|
|
17
|
+
readonly type: string;
|
|
18
|
+
} | {
|
|
19
|
+
readonly outcome: 'rejected';
|
|
20
|
+
readonly error: WebhookVerificationError;
|
|
21
|
+
};
|
|
22
|
+
/** The provider owns signature verification, timestamp tolerance, and payload parsing. */
|
|
23
|
+
interface WebhookVerifier<Event> {
|
|
24
|
+
/** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */
|
|
25
|
+
verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>;
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { WebhookVerificationError as n, WebhookVerifier as r, WebhookVerification as t };
|
|
29
|
+
//# sourceMappingURL=webhook-verification-sZtwTI5n.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-verification-sZtwTI5n.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;KAOQ,oBAAoB;WACjB;WAA2B,OAAO;;WAClC;WAA6B;;WAC7B;WAA8B,OAAO;;;UAGnC,gBAAgB;;EAE/B,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB"}
|
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.
|
|
4
|
+
"version": "0.12.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": {
|
|
@@ -27,8 +27,11 @@
|
|
|
27
27
|
"./cloudflare/queue": "./dist/cloudflare/queue.js",
|
|
28
28
|
"./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
|
|
29
29
|
"./core": "./dist/core.js",
|
|
30
|
+
"./http": "./dist/http.js",
|
|
30
31
|
"./postgres": "./dist/postgres.js",
|
|
32
|
+
"./resend": "./dist/resend.js",
|
|
31
33
|
"./tanstack": "./dist/tanstack.js",
|
|
34
|
+
"./tanstack/server": "./dist/tanstack/server.js",
|
|
32
35
|
"./ui": "./dist/ui.js",
|
|
33
36
|
"./package.json": "./package.json"
|
|
34
37
|
},
|
|
@@ -55,6 +58,8 @@
|
|
|
55
58
|
"drizzle-kit": "^0.31.10",
|
|
56
59
|
"drizzle-orm": "^0.45.2",
|
|
57
60
|
"postgres": "^3.4.9",
|
|
61
|
+
"resend": "^6.28.0",
|
|
62
|
+
"standardwebhooks": "^1.1.1",
|
|
58
63
|
"stripe": "^22.6.2",
|
|
59
64
|
"tsdown": "^0.23.0",
|
|
60
65
|
"typescript": "^7.0.2",
|
|
@@ -67,9 +72,13 @@
|
|
|
67
72
|
"@tanstack/react-start": "^1.168.52",
|
|
68
73
|
"better-auth": "^1.7.4",
|
|
69
74
|
"drizzle-orm": "^0.45.2",
|
|
75
|
+
"resend": "^6.28.0",
|
|
70
76
|
"stripe": "^22.6.2"
|
|
71
77
|
},
|
|
72
78
|
"peerDependenciesMeta": {
|
|
79
|
+
"resend": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
73
82
|
"@better-auth/stripe": {
|
|
74
83
|
"optional": true
|
|
75
84
|
},
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { EmailIdempotencyKey } from '@/core'
|
|
2
|
+
|
|
3
|
+
/** A media type such as `application/pdf`. */
|
|
4
|
+
export type MimeType = `${string}/${string}`
|
|
5
|
+
|
|
6
|
+
/** A file to attach. Binary content is bytes; the adapter encodes it for the provider. */
|
|
7
|
+
export type EmailAttachment = {
|
|
8
|
+
readonly filename: string
|
|
9
|
+
readonly content: string | Uint8Array
|
|
10
|
+
/** Optional MIME metadata; Resend accepts filename and content without it. */
|
|
11
|
+
readonly contentType?: MimeType
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One transactional email. Resend accepts 50 recipients, 75 tags with ASCII names and values,
|
|
16
|
+
* and 40 MB of attachments after base64. Sources: https://resend.com/docs/api-reference/emails/send-email
|
|
17
|
+
* and https://resend.com/docs/dashboard/emails/tags
|
|
18
|
+
*/
|
|
19
|
+
export type OutboundEmail = {
|
|
20
|
+
/** Override the default for this email, for example "Display Name <address@domain.com>". */
|
|
21
|
+
readonly from?: string
|
|
22
|
+
readonly to: string | readonly string[]
|
|
23
|
+
readonly subject: string
|
|
24
|
+
readonly html: string
|
|
25
|
+
/** The stable identity of this send. Resend deduplicates it for 24 hours. */
|
|
26
|
+
readonly idempotencyKey: EmailIdempotencyKey
|
|
27
|
+
/** Resend generates plain text from html when omitted. An empty string disables that generation. */
|
|
28
|
+
readonly text?: string
|
|
29
|
+
readonly replyTo?: string
|
|
30
|
+
/** Labels the provider stores with the email and returns on every delivery event. */
|
|
31
|
+
readonly tags?: Readonly<Record<string, string>>
|
|
32
|
+
readonly attachments?: readonly EmailAttachment[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The provider accepted the email and assigned its id. */
|
|
36
|
+
export type EmailAccepted = { readonly providerId: string }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The adapter supplies the default sender and maps provider errors. Each email can override the sender.
|
|
40
|
+
* A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export interface IEmailService {
|
|
43
|
+
/** Resolve after provider acceptance; this does not confirm delivery to the recipient. */
|
|
44
|
+
sendEmail(email: OutboundEmail): Promise<EmailAccepted>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */
|
|
48
|
+
export class RecordingEmailService implements IEmailService {
|
|
49
|
+
/** Sends in call order, including repeated idempotency keys. */
|
|
50
|
+
readonly sent: OutboundEmail[] = []
|
|
51
|
+
|
|
52
|
+
/** Record the send before returning its provider id. */
|
|
53
|
+
async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {
|
|
54
|
+
this.sent.push(email)
|
|
55
|
+
return { providerId: `recorded-${this.sent.length}` }
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/application/index.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
|
|
2
|
+
export {
|
|
3
|
+
RecordingEmailService,
|
|
4
|
+
type IEmailService,
|
|
5
|
+
type OutboundEmail,
|
|
6
|
+
type EmailAttachment,
|
|
7
|
+
type MimeType,
|
|
8
|
+
type EmailAccepted,
|
|
9
|
+
} from './email-service.ts'
|
|
10
|
+
export {
|
|
11
|
+
recordEmailEvent,
|
|
12
|
+
type EmailBounceRecord,
|
|
13
|
+
type IEmailDeliverabilityStore,
|
|
14
|
+
type RecordEmailEventRepos,
|
|
15
|
+
type RecordEmailEventDeps,
|
|
16
|
+
type RecordEmailEventCommand,
|
|
17
|
+
} from './record-email-event.ts'
|
|
2
18
|
export type { IMessageStore } from './message-store.ts'
|
|
3
19
|
export type { DomainCommand, DomainEvent, DomainMessage, DomainQuery, MessageId } from '@/core'
|
|
4
20
|
export { MessageBus, type IMessageBus } from './message-bus.ts'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { BounceType, DomainCommand, EmailAddress, RecordEmailEventPayload } from '@/core'
|
|
2
|
+
|
|
3
|
+
import type { IUnitOfWork } from './unit-of-work.ts'
|
|
4
|
+
|
|
5
|
+
/** What a bounce records for one address. */
|
|
6
|
+
export type EmailBounceRecord = {
|
|
7
|
+
readonly type: BounceType
|
|
8
|
+
readonly subType: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The application's deliverability state per address. An implementation keeps the higher bounce
|
|
13
|
+
* severity with `shouldUpdateBounce`, and treats an unknown address as nothing to record.
|
|
14
|
+
*/
|
|
15
|
+
export interface IEmailDeliverabilityStore {
|
|
16
|
+
/** Keep the higher severity atomically. Equal severity permits replacement; the store owns timestamp ordering. */
|
|
17
|
+
recordBounce(address: EmailAddress, bounce: EmailBounceRecord): Promise<void>
|
|
18
|
+
/** Record a complaint without clearing existing suppression state. */
|
|
19
|
+
recordComplaint(address: EmailAddress): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The repositories the handler needs from the unit of work. */
|
|
23
|
+
export type RecordEmailEventRepos = { readonly emailDeliverability: IEmailDeliverabilityStore }
|
|
24
|
+
|
|
25
|
+
/** The container slice the handler reads. */
|
|
26
|
+
export type RecordEmailEventDeps = { readonly uow: IUnitOfWork<RecordEmailEventRepos> }
|
|
27
|
+
|
|
28
|
+
/** The command, under whatever name the application registered the payload. */
|
|
29
|
+
export type RecordEmailEventCommand = DomainCommand & RecordEmailEventPayload
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record a bounce or a complaint for every recipient inside the command's unit of work.
|
|
33
|
+
* The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.
|
|
34
|
+
*/
|
|
35
|
+
export async function recordEmailEvent(
|
|
36
|
+
command: RecordEmailEventCommand,
|
|
37
|
+
deps: RecordEmailEventDeps,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
await deps.uow.run(command.id, async ({ emailDeliverability }) => {
|
|
40
|
+
for (const address of command.recipients) {
|
|
41
|
+
if (command.event.type === 'bounced') {
|
|
42
|
+
// oxlint-disable-next-line no-await-in-loop -- Finish each transaction write before starting another; a failure must leave no pending writes.
|
|
43
|
+
await emailDeliverability.recordBounce(address, {
|
|
44
|
+
type: command.event.bounceType,
|
|
45
|
+
subType: command.event.bounceSubType,
|
|
46
|
+
})
|
|
47
|
+
} else {
|
|
48
|
+
// oxlint-disable-next-line no-await-in-loop -- A rejected write must stop the transaction before another write starts.
|
|
49
|
+
await emailDeliverability.recordComplaint(address)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** A parsed email address for message payloads and webhook recipients. Ported from Typist's `EmailSchema`. */
|
|
4
|
+
export const EmailAddressSchema = z.email().brand<'EmailAddress'>()
|
|
5
|
+
|
|
6
|
+
/** An address the schema accepted. */
|
|
7
|
+
export type EmailAddress = z.infer<typeof EmailAddressSchema>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** The bounce classes Resend reports, ported from Typist's comm-eligibility.ts. */
|
|
4
|
+
export const BounceTypeSchema = z.enum(['Permanent', 'Transient', 'Undetermined'])
|
|
5
|
+
|
|
6
|
+
/** One bounce class. */
|
|
7
|
+
export type BounceType = z.infer<typeof BounceTypeSchema>
|
|
8
|
+
|
|
9
|
+
/** Parse a provider's bounce type. An unknown value is `Undetermined`, which suppresses. */
|
|
10
|
+
export function parseBounceType(value: string | undefined): BounceType {
|
|
11
|
+
const parsed = BounceTypeSchema.safeParse(value)
|
|
12
|
+
return parsed.success ? parsed.data : 'Undetermined'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BOUNCE_SEVERITY = {
|
|
16
|
+
Transient: 1,
|
|
17
|
+
Undetermined: 2,
|
|
18
|
+
Permanent: 3,
|
|
19
|
+
} satisfies Record<BounceType, number>
|
|
20
|
+
|
|
21
|
+
/** Keep the higher severity. Equal severity permits replacement; callers own timestamp ordering. */
|
|
22
|
+
export function shouldUpdateBounce(incoming: BounceType, current: BounceType | null): boolean {
|
|
23
|
+
if (current === null) return true
|
|
24
|
+
return BOUNCE_SEVERITY[incoming] >= BOUNCE_SEVERITY[current]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Typist's send policy: no complaint, and either no bounce or a transient one. Delivery is not guaranteed. */
|
|
28
|
+
export function isEmailDeliverable(bounceType: BounceType | null, complained: boolean): boolean {
|
|
29
|
+
if (complained) return false
|
|
30
|
+
return bounceType === null || bounceType === 'Transient'
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { AppError } from './app-error.ts'
|
|
4
|
+
|
|
5
|
+
/** A key only `emailIdempotencyKey` mints, so every send carries one built by its rules. */
|
|
6
|
+
export type EmailIdempotencyKey = string & z.core.$brand<'EmailIdempotencyKey'>
|
|
7
|
+
|
|
8
|
+
// Resend accepts 1 to 256 characters. Source: https://resend.com/docs/dashboard/emails/idempotency-keys
|
|
9
|
+
const MAX_KEY_LENGTH = 256
|
|
10
|
+
|
|
11
|
+
/** A key outside 1 to 256 characters. Keys come from stable ids, so this is a defect, not a retry. */
|
|
12
|
+
export class InvalidEmailIdempotencyKeyError extends AppError {
|
|
13
|
+
readonly _tag = 'InvalidEmailIdempotencyKeyError'
|
|
14
|
+
readonly classification = 'terminal'
|
|
15
|
+
|
|
16
|
+
constructor(readonly length: number) {
|
|
17
|
+
super(`Email idempotency key must have 1 to ${MAX_KEY_LENGTH} characters`)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `<scope>/<id>` for one logical send. Resend deduplicates it for 24 hours.
|
|
23
|
+
* Derive `id` from a stable identity: a message id, a job id, a user id with a template name.
|
|
24
|
+
* The same key must map to an identical payload; a changed body is rejected as terminal.
|
|
25
|
+
* A random `id` means every request is a deliberate new send, as a sign-in code resend is.
|
|
26
|
+
* Source: https://resend.com/docs/dashboard/emails/idempotency-keys
|
|
27
|
+
*/
|
|
28
|
+
export function emailIdempotencyKey(scope: string, id: string): EmailIdempotencyKey {
|
|
29
|
+
const key = `${scope}/${id}`
|
|
30
|
+
if (scope.length === 0 || id.length === 0 || key.length > MAX_KEY_LENGTH)
|
|
31
|
+
throw new InvalidEmailIdempotencyKeyError(key.length)
|
|
32
|
+
// SAFETY: the brand is a compile-time mark; the checks above are the whole rule.
|
|
33
|
+
return key as EmailIdempotencyKey
|
|
34
|
+
}
|