better-ship 0.8.0 → 0.10.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.
@@ -1,6 +1,9 @@
1
1
  import { kt as AppError } from "../index-6XMPJu7u.js";
2
2
  //#region src/infrastructure/better-auth/apple-client-secret.d.ts
3
- /** Apple sends its OAuth callback from this origin. Include it in trustedOrigins when enabling Apple. */
3
+ /**
4
+ * Apple's issuer and OAuth callback origin, and the `aud` claim of the client secret. Include it in
5
+ * trustedOrigins when enabling Apple. Source: the same Apple page as `SECRET_LIFETIME_SECONDS`.
6
+ */
4
7
  export declare const APPLE_ORIGIN = "https://appleid.apple.com";
5
8
  /** Apple credentials used to sign a client secret. Ported from Porte's apple-client-secret.ts. */
6
9
  export type AppleKeyConfig = {
@@ -15,7 +18,11 @@ export declare class AppleClientSecretError extends AppError {
15
18
  readonly classification = "terminal";
16
19
  constructor(cause: unknown);
17
20
  }
18
- /** Sign Apple's six-month client secret. Invalid private keys return an empty secret and log once per isolate, as in Typist. */
19
- export declare function generateAppleClientSecret(config: AppleKeyConfig): Promise<string>;
21
+ /**
22
+ * Sign Apple's six-month client secret synchronously, so Better Auth options can be built without awaiting it.
23
+ * Signing uses `node:crypto` as Typist does, so the Worker needs the `nodejs_compat` flag.
24
+ * Invalid private keys return an empty secret and log once per isolate, as in Typist.
25
+ */
26
+ export declare function generateAppleClientSecret(config: AppleKeyConfig): string;
20
27
  //#endregion
21
28
  //# sourceMappingURL=apple-client-secret.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"apple-client-secret.d.ts","names":[],"sources":["../../src/infrastructure/better-auth/apple-client-secret.ts"],"mappings":";;;qBASa;;YAGD;WACD;WACA;WACA;WACA;;;qBAIE,+BAA+B;WACjC;WACA;EAET,YAAY;;;wBAMQ,0BAA0B,QAAQ,iBAAiB"}
1
+ {"version":3,"file":"apple-client-secret.d.ts","names":[],"sources":["../../src/infrastructure/better-auth/apple-client-secret.ts"],"mappings":";;;;;;qBAkBa;;YAGD;WACD;WACA;WACA;WACA;;;qBAIE,+BAA+B;WACjC;WACA;EAET,YAAY;;;;;;;wBAiBE,0BAA0B,QAAQ"}
@@ -1,10 +1,19 @@
1
1
  import { U as AppError, a as createLogger } from "../core-DC_4pOrI.js";
2
- import { SignJWT, importPKCS8 } from "jose";
2
+ import { Buffer } from "node:buffer";
3
+ import { createPrivateKey, sign } from "node:crypto";
3
4
 
4
5
  //#region src/infrastructure/better-auth/apple-client-secret.ts
5
6
  const logger = createLogger("auth");
6
7
  let appleKeyFailureLogged = false;
7
- /** Apple sends its OAuth callback from this origin. Include it in trustedOrigins when enabling Apple. */
8
+ /**
9
+ * Apple caps `exp` at 15,777,000 seconds after `iat`; 180 days stays under it. Source:
10
+ * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
11
+ */
12
+ const SECRET_LIFETIME_SECONDS = 15552e3;
13
+ /**
14
+ * Apple's issuer and OAuth callback origin, and the `aud` claim of the client secret. Include it in
15
+ * trustedOrigins when enabling Apple. Source: the same Apple page as `SECRET_LIFETIME_SECONDS`.
16
+ */
8
17
  const APPLE_ORIGIN = "https://appleid.apple.com";
9
18
  /** Apple signing failed. The application's error boundary owns reporting. */
10
19
  var AppleClientSecretError = class extends AppError {
@@ -14,11 +23,18 @@ var AppleClientSecretError = class extends AppError {
14
23
  super("Apple client secret signing failed", { cause });
15
24
  }
16
25
  };
17
- /** Sign Apple's six-month client secret. Invalid private keys return an empty secret and log once per isolate, as in Typist. */
18
- async function generateAppleClientSecret(config) {
19
- let key;
26
+ function base64UrlJson(segment) {
27
+ return Buffer.from(JSON.stringify(segment)).toString("base64url");
28
+ }
29
+ /**
30
+ * Sign Apple's six-month client secret synchronously, so Better Auth options can be built without awaiting it.
31
+ * Signing uses `node:crypto` as Typist does, so the Worker needs the `nodejs_compat` flag.
32
+ * Invalid private keys return an empty secret and log once per isolate, as in Typist.
33
+ */
34
+ function generateAppleClientSecret(config) {
35
+ const key = config.applePrivateKey.replace(/\\n/g, "\n");
20
36
  try {
21
- key = await importPKCS8(config.applePrivateKey.replace(/\\n/g, "\n"), "ES256");
37
+ createPrivateKey(key);
22
38
  } catch (error) {
23
39
  if (!appleKeyFailureLogged) {
24
40
  appleKeyFailureLogged = true;
@@ -29,11 +45,23 @@ async function generateAppleClientSecret(config) {
29
45
  }
30
46
  return "";
31
47
  }
48
+ const issuedAt = Math.floor(Date.now() / 1e3);
49
+ const input = `${base64UrlJson({
50
+ alg: "ES256",
51
+ kid: config.appleKeyId
52
+ })}.${base64UrlJson({
53
+ iss: config.appleTeamId,
54
+ sub: config.appleClientId,
55
+ aud: APPLE_ORIGIN,
56
+ iat: issuedAt,
57
+ exp: issuedAt + SECRET_LIFETIME_SECONDS
58
+ })}`;
32
59
  try {
33
- return await new SignJWT({}).setProtectedHeader({
34
- alg: "ES256",
35
- kid: config.appleKeyId
36
- }).setIssuer(config.appleTeamId).setSubject(config.appleClientId).setAudience(APPLE_ORIGIN).setIssuedAt().setExpirationTime("180d").sign(key);
60
+ const signature = sign("sha256", Buffer.from(input), {
61
+ key,
62
+ dsaEncoding: "ieee-p1363"
63
+ });
64
+ return `${input}.${Buffer.from(signature).toString("base64url")}`;
37
65
  } catch (cause) {
38
66
  throw new AppleClientSecretError(cause);
39
67
  }
@@ -1 +1 @@
1
- {"version":3,"file":"apple-client-secret.js","names":[],"sources":["../../src/infrastructure/better-auth/apple-client-secret.ts"],"sourcesContent":["import { importPKCS8, SignJWT } from 'jose'\n\nimport { AppError, createLogger } from '@/core'\n\nconst logger = createLogger('auth')\n// Typist reports an invalid key once per isolate because session checks repeatedly construct auth options.\nlet appleKeyFailureLogged = false\n\n/** Apple sends its OAuth callback from this origin. Include it in trustedOrigins when enabling Apple. */\nexport const APPLE_ORIGIN = 'https://appleid.apple.com'\n\n/** Apple credentials used to sign a client secret. Ported from Porte's apple-client-secret.ts. */\nexport type AppleKeyConfig = {\n readonly appleClientId: string\n readonly appleTeamId: string\n readonly appleKeyId: string\n readonly applePrivateKey: string\n}\n\n/** Apple signing failed. The application's error boundary owns reporting. */\nexport class AppleClientSecretError extends AppError {\n readonly _tag = 'AppleClientSecretError'\n readonly classification = 'terminal'\n\n constructor(cause: unknown) {\n super('Apple client secret signing failed', { cause })\n }\n}\n\n/** Sign Apple's six-month client secret. Invalid private keys return an empty secret and log once per isolate, as in Typist. */\nexport async function generateAppleClientSecret(config: AppleKeyConfig): Promise<string> {\n let key: Awaited<ReturnType<typeof importPKCS8>>\n try {\n key = await importPKCS8(config.applePrivateKey.replace(/\\\\n/g, '\\n'), 'ES256')\n } catch (error) {\n if (!appleKeyFailureLogged) {\n appleKeyFailureLogged = true\n logger.error('apple_private_key_invalid', { error, details: { keyId: config.appleKeyId } })\n }\n return ''\n }\n try {\n return await new SignJWT({})\n .setProtectedHeader({ alg: 'ES256', kid: config.appleKeyId })\n .setIssuer(config.appleTeamId)\n .setSubject(config.appleClientId)\n .setAudience(APPLE_ORIGIN)\n .setIssuedAt()\n .setExpirationTime('180d')\n .sign(key)\n } catch (cause) {\n throw new AppleClientSecretError(cause)\n }\n}\n"],"mappings":";;;;AAIA,MAAM,SAAS,aAAa,MAAM;AAElC,IAAI,wBAAwB;;AAG5B,MAAa,eAAe;;AAW5B,IAAa,yBAAb,cAA4C,SAAS;CACnD,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAE1B,YAAY,OAAgB;EAC1B,MAAM,sCAAsC,EAAE,MAAM,CAAC;CACvD;AACF;;AAGA,eAAsB,0BAA0B,QAAyC;CACvF,IAAI;CACJ,IAAI;EACF,MAAM,MAAM,YAAY,OAAO,gBAAgB,QAAQ,QAAQ,IAAI,GAAG,OAAO;CAC/E,SAAS,OAAO;EACd,IAAI,CAAC,uBAAuB;GAC1B,wBAAwB;GACxB,OAAO,MAAM,6BAA6B;IAAE;IAAO,SAAS,EAAE,OAAO,OAAO,WAAW;GAAE,CAAC;EAC5F;EACA,OAAO;CACT;CACA,IAAI;EACF,OAAO,MAAM,IAAI,QAAQ,CAAC,CAAC,CAAC,CACzB,mBAAmB;GAAE,KAAK;GAAS,KAAK,OAAO;EAAW,CAAC,CAAC,CAC5D,UAAU,OAAO,WAAW,CAAC,CAC7B,WAAW,OAAO,aAAa,CAAC,CAChC,YAAY,YAAY,CAAC,CACzB,YAAY,CAAC,CACb,kBAAkB,MAAM,CAAC,CACzB,KAAK,GAAG;CACb,SAAS,OAAO;EACd,MAAM,IAAI,uBAAuB,KAAK;CACxC;AACF"}
1
+ {"version":3,"file":"apple-client-secret.js","names":[],"sources":["../../src/infrastructure/better-auth/apple-client-secret.ts"],"sourcesContent":["import { Buffer } from 'node:buffer'\nimport { createPrivateKey, sign } from 'node:crypto'\n\nimport { AppError, createLogger } from '@/core'\n\nconst logger = createLogger('auth')\n// Typist reports an invalid key once per isolate because session checks repeatedly construct auth options.\nlet appleKeyFailureLogged = false\n/**\n * Apple caps `exp` at 15,777,000 seconds after `iat`; 180 days stays under it. Source:\n * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret\n */\nconst SECRET_LIFETIME_SECONDS = 180 * 24 * 60 * 60\n\n/**\n * Apple's issuer and OAuth callback origin, and the `aud` claim of the client secret. Include it in\n * trustedOrigins when enabling Apple. Source: the same Apple page as `SECRET_LIFETIME_SECONDS`.\n */\nexport const APPLE_ORIGIN = 'https://appleid.apple.com'\n\n/** Apple credentials used to sign a client secret. Ported from Porte's apple-client-secret.ts. */\nexport type AppleKeyConfig = {\n readonly appleClientId: string\n readonly appleTeamId: string\n readonly appleKeyId: string\n readonly applePrivateKey: string\n}\n\n/** Apple signing failed. The application's error boundary owns reporting. */\nexport class AppleClientSecretError extends AppError {\n readonly _tag = 'AppleClientSecretError'\n readonly classification = 'terminal'\n\n constructor(cause: unknown) {\n super('Apple client secret signing failed', { cause })\n }\n}\n\n/** One JWS segment before encoding: the protected header or the claims. */\ntype JwsSegment = Readonly<Record<string, string | number>>\n\nfunction base64UrlJson(segment: JwsSegment): string {\n return Buffer.from(JSON.stringify(segment)).toString('base64url')\n}\n\n/**\n * Sign Apple's six-month client secret synchronously, so Better Auth options can be built without awaiting it.\n * Signing uses `node:crypto` as Typist does, so the Worker needs the `nodejs_compat` flag.\n * Invalid private keys return an empty secret and log once per isolate, as in Typist.\n */\nexport function generateAppleClientSecret(config: AppleKeyConfig): string {\n const key = config.applePrivateKey.replace(/\\\\n/g, '\\n')\n // Validation only: workerd's sign() takes the PEM string, not the KeyObject.\n try {\n createPrivateKey(key)\n } catch (error) {\n if (!appleKeyFailureLogged) {\n appleKeyFailureLogged = true\n logger.error('apple_private_key_invalid', { error, details: { keyId: config.appleKeyId } })\n }\n return ''\n }\n const issuedAt = Math.floor(Date.now() / 1000)\n const header = base64UrlJson({ alg: 'ES256', kid: config.appleKeyId })\n const claims = base64UrlJson({\n iss: config.appleTeamId,\n sub: config.appleClientId,\n aud: APPLE_ORIGIN,\n iat: issuedAt,\n exp: issuedAt + SECRET_LIFETIME_SECONDS,\n })\n const input = `${header}.${claims}`\n try {\n // A JWS carries the raw r||s signature; OpenSSL emits DER unless told otherwise.\n const signature = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' })\n // TypeScript 7 types the returned buffer without an encoding parameter on toString.\n return `${input}.${Buffer.from(signature).toString('base64url')}`\n } catch (cause) {\n throw new AppleClientSecretError(cause)\n }\n}\n"],"mappings":";;;;;AAKA,MAAM,SAAS,aAAa,MAAM;AAElC,IAAI,wBAAwB;;;;;AAK5B,MAAM,0BAA0B;;;;;AAMhC,MAAa,eAAe;;AAW5B,IAAa,yBAAb,cAA4C,SAAS;CACnD,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAE1B,YAAY,OAAgB;EAC1B,MAAM,sCAAsC,EAAE,MAAM,CAAC;CACvD;AACF;AAKA,SAAS,cAAc,SAA6B;CAClD,OAAO,OAAO,KAAK,KAAK,UAAU,OAAO,CAAC,CAAC,CAAC,SAAS,WAAW;AAClE;;;;;;AAOA,SAAgB,0BAA0B,QAAgC;CACxE,MAAM,MAAM,OAAO,gBAAgB,QAAQ,QAAQ,IAAI;CAEvD,IAAI;EACF,iBAAiB,GAAG;CACtB,SAAS,OAAO;EACd,IAAI,CAAC,uBAAuB;GAC1B,wBAAwB;GACxB,OAAO,MAAM,6BAA6B;IAAE;IAAO,SAAS,EAAE,OAAO,OAAO,WAAW;GAAE,CAAC;EAC5F;EACA,OAAO;CACT;CACA,MAAM,WAAW,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;CAS7C,MAAM,QAAQ,GARC,cAAc;EAAE,KAAK;EAAS,KAAK,OAAO;CAAW,CAQ9C,EAAE,GAPT,cAAc;EAC3B,KAAK,OAAO;EACZ,KAAK,OAAO;EACZ,KAAK;EACL,KAAK;EACL,KAAK,WAAW;CAClB,CACgC;CAChC,IAAI;EAEF,MAAM,YAAY,KAAK,UAAU,OAAO,KAAK,KAAK,GAAG;GAAE;GAAK,aAAa;EAAa,CAAC;EAEvF,OAAO,GAAG,MAAM,GAAG,OAAO,KAAK,SAAS,CAAC,CAAC,SAAS,WAAW;CAChE,SAAS,OAAO;EACd,MAAM,IAAI,uBAAuB,KAAK;CACxC;AACF"}
@@ -1,12 +1,31 @@
1
1
  import { kt as AppError } from "../index-6XMPJu7u.js";
2
- import { BetterAuthRateLimitStorage } from "better-auth";
2
+ import { BetterAuthOptions, BetterAuthRateLimitStorage } from "better-auth";
3
3
  //#region src/infrastructure/better-auth/rate-limit.d.ts
4
+ /** A rule Better Auth passes to storage: a window in seconds and the requests it allows. */
5
+ export type AuthRateLimitRule = {
6
+ readonly window: number;
7
+ readonly max: number;
8
+ };
4
9
  /** Each rule must match its binding's deployed limit and period. Cloudflare accepts only 10 or 60 seconds. */
5
10
  export type AuthRateLimitBinding = {
6
11
  readonly binding: Pick<RateLimit, 'limit'>;
7
12
  readonly window: 10 | 60;
8
13
  readonly max: number;
9
14
  };
15
+ /**
16
+ * Better Auth's built-in rules, which it does not export. Sign-in, sign-up, change-password, and
17
+ * change-email paths get 3 requests per 10 seconds. Password reset, verification email, forget-password,
18
+ * and email OTP send paths get 3 per 60 seconds. A package test pins both against the installed version.
19
+ * Source: `getDefaultSpecialRules` in
20
+ * https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/api/rate-limiter/index.ts
21
+ */
22
+ export declare const BUILT_IN_AUTH_RATE_LIMIT_RULES: readonly AuthRateLimitRule[];
23
+ /**
24
+ * Every rule the configured instance can pass to storage, deduplicated: the general rule, the built-in
25
+ * rules, each plugin's rules, and static custom rules. Function-valued custom rules decide per request
26
+ * and are not listed. Disabled rate limiting returns no rules.
27
+ */
28
+ export declare function authRateLimitRules(options: Pick<BetterAuthOptions, 'rateLimit' | 'plugins'>): readonly AuthRateLimitRule[];
10
29
  /** A binding failure with no known retry classification. The cause preserves the original failure. */
11
30
  export declare class AuthRateLimitError extends AppError {
12
31
  readonly _tag = "AuthRateLimitError";
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.d.ts","names":[],"sources":["../../src/infrastructure/better-auth/rate-limit.ts"],"mappings":";;;;YAKY;WACD,SAAS,KAAK;WACd;WACA;;;qBAIE,2BAA2B;WAC7B;WACA;EAET,YAAY;;;qBAMD,0CAA0C;WAK1C;WACA;WALF;WACA;EAET,YACW,gBACA;;;YAOD;WACD;;;;;;wBAOK,2BACd,mBAAmB,0BACjB,gBAAyB,uBAC1B"}
1
+ {"version":3,"file":"rate-limit.d.ts","names":[],"sources":["../../src/infrastructure/better-auth/rate-limit.ts"],"mappings":";;;;YAKY;WACD;WACA;;;YAIC;WACD,SAAS,KAAK;WACd;WACA;;;;;;;;;qBAUE,yCAAyC;;;;;;wBAUtC,mBACd,SAAS,KAAK,uDACJ;;qBAmBC,2BAA2B;WAC7B;WACA;EAET,YAAY;;;qBAMD,0CAA0C;WAK1C;WACA;WALF;WACA;EAET,YACW,gBACA;;;YAOD;WACD;;;;;;wBAOK,2BACd,mBAAmB,0BACjB,gBAAyB,uBAC1B"}
@@ -1,6 +1,42 @@
1
1
  import { U as AppError, a as createLogger } from "../core-DC_4pOrI.js";
2
2
 
3
3
  //#region src/infrastructure/better-auth/rate-limit.ts
4
+ /**
5
+ * Better Auth's built-in rules, which it does not export. Sign-in, sign-up, change-password, and
6
+ * change-email paths get 3 requests per 10 seconds. Password reset, verification email, forget-password,
7
+ * and email OTP send paths get 3 per 60 seconds. A package test pins both against the installed version.
8
+ * Source: `getDefaultSpecialRules` in
9
+ * https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/api/rate-limiter/index.ts
10
+ */
11
+ const BUILT_IN_AUTH_RATE_LIMIT_RULES = [{
12
+ window: 10,
13
+ max: 3
14
+ }, {
15
+ window: 60,
16
+ max: 3
17
+ }];
18
+ /**
19
+ * Every rule the configured instance can pass to storage, deduplicated: the general rule, the built-in
20
+ * rules, each plugin's rules, and static custom rules. Function-valued custom rules decide per request
21
+ * and are not listed. Disabled rate limiting returns no rules.
22
+ */
23
+ function authRateLimitRules(options) {
24
+ const rateLimit = options.rateLimit;
25
+ if (rateLimit?.enabled === false) return [];
26
+ const rules = [{
27
+ window: rateLimit?.window ?? 10,
28
+ max: rateLimit?.max ?? 100
29
+ }, ...BUILT_IN_AUTH_RATE_LIMIT_RULES];
30
+ for (const plugin of options.plugins ?? []) for (const rule of plugin.rateLimit ?? []) rules.push({
31
+ window: rule.window,
32
+ max: rule.max
33
+ });
34
+ for (const rule of Object.values(rateLimit?.customRules ?? {})) if (rule !== false && !(rule instanceof Function)) rules.push({
35
+ window: rule.window,
36
+ max: rule.max
37
+ });
38
+ return [...new Map(rules.map((rule) => [`${rule.window}:${rule.max}`, rule])).values()];
39
+ }
4
40
  /** A binding failure with no known retry classification. The cause preserves the original failure. */
5
41
  var AuthRateLimitError = class extends AppError {
6
42
  _tag = "AuthRateLimitError";
@@ -55,5 +91,5 @@ function createAuthRateLimitStorage(bindings, { failureMode = "allow" } = {}) {
55
91
  }
56
92
 
57
93
  //#endregion
58
- export { AuthRateLimitError, UnsupportedAuthRateLimitRuleError, createAuthRateLimitStorage };
94
+ export { AuthRateLimitError, BUILT_IN_AUTH_RATE_LIMIT_RULES, UnsupportedAuthRateLimitRuleError, authRateLimitRules, createAuthRateLimitStorage };
59
95
  //# sourceMappingURL=rate-limit.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.js","names":[],"sources":["../../src/infrastructure/better-auth/rate-limit.ts"],"sourcesContent":["import type { BetterAuthRateLimitStorage } from 'better-auth'\n\nimport { AppError, createLogger } from '@/core'\n\n/** Each rule must match its binding's deployed limit and period. Cloudflare accepts only 10 or 60 seconds. */\nexport type AuthRateLimitBinding = {\n readonly binding: Pick<RateLimit, 'limit'>\n readonly window: 10 | 60\n readonly max: number\n}\n\n/** A binding failure with no known retry classification. The cause preserves the original failure. */\nexport class AuthRateLimitError extends AppError {\n readonly _tag = 'AuthRateLimitError'\n readonly classification = 'unknown'\n\n constructor(cause: unknown) {\n super('Authentication rate limiter is unavailable', { cause })\n }\n}\n\n/** No configured binding matches this Better Auth rule. Outage policy cannot bypass a configuration error. */\nexport class UnsupportedAuthRateLimitRuleError extends AppError {\n readonly _tag = 'UnsupportedAuthRateLimitRuleError'\n readonly classification = 'terminal'\n\n constructor(\n readonly window: number,\n readonly max: number,\n ) {\n super('No Cloudflare binding supports the authentication rate limit rule')\n }\n}\n\n/** Typist allows requests during binding outages. Select block to propagate an error instead. */\nexport type AuthRateLimitOptions = {\n readonly failureMode?: 'allow' | 'block'\n}\n\n/**\n * Port Typist and Porte's binding adapters with explicit rule matching and configurable outage behavior.\n * Bindings enforce approximate limits per Cloudflare location; retryAfter is a conservative full window.\n */\nexport function createAuthRateLimitStorage(\n bindings: readonly AuthRateLimitBinding[],\n { failureMode = 'allow' }: AuthRateLimitOptions = {},\n): BetterAuthRateLimitStorage {\n const logger = createLogger('auth-rate-limit')\n return {\n async consume(key, rule) {\n const configured = bindings.find(\n (entry) => entry.window === rule.window && entry.max === rule.max,\n )\n if (configured === undefined)\n throw new UnsupportedAuthRateLimitRuleError(rule.window, rule.max)\n try {\n const { success } = await configured.binding.limit({ key })\n return { allowed: success, retryAfter: success ? null : rule.window }\n } catch (cause) {\n if (failureMode === 'block') throw new AuthRateLimitError(cause)\n logger.error('rate_limit_binding_failed', {\n error: cause,\n details: { window: rule.window, max: rule.max, failureMode },\n })\n return { allowed: true, retryAfter: null }\n }\n },\n }\n}\n"],"mappings":";;;;AAYA,IAAa,qBAAb,cAAwC,SAAS;CAC/C,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAE1B,YAAY,OAAgB;EAC1B,MAAM,8CAA8C,EAAE,MAAM,CAAC;CAC/D;AACF;;AAGA,IAAa,oCAAb,cAAuD,SAAS;CAKnD;CACA;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAE1B,YACE,AAAS,QACT,AAAS,KACT;EACA,MAAM,mEAAmE;EAHhE;EACA;CAGX;AACF;;;;;AAWA,SAAgB,2BACd,UACA,EAAE,cAAc,YAAkC,CAAC,GACvB;CAC5B,MAAM,SAAS,aAAa,iBAAiB;CAC7C,OAAO,EACL,MAAM,QAAQ,KAAK,MAAM;EACvB,MAAM,aAAa,SAAS,MACzB,UAAU,MAAM,WAAW,KAAK,UAAU,MAAM,QAAQ,KAAK,GAChE;EACA,IAAI,eAAe,QACjB,MAAM,IAAI,kCAAkC,KAAK,QAAQ,KAAK,GAAG;EACnE,IAAI;GACF,MAAM,EAAE,YAAY,MAAM,WAAW,QAAQ,MAAM,EAAE,IAAI,CAAC;GAC1D,OAAO;IAAE,SAAS;IAAS,YAAY,UAAU,OAAO,KAAK;GAAO;EACtE,SAAS,OAAO;GACd,IAAI,gBAAgB,SAAS,MAAM,IAAI,mBAAmB,KAAK;GAC/D,OAAO,MAAM,6BAA6B;IACxC,OAAO;IACP,SAAS;KAAE,QAAQ,KAAK;KAAQ,KAAK,KAAK;KAAK;IAAY;GAC7D,CAAC;GACD,OAAO;IAAE,SAAS;IAAM,YAAY;GAAK;EAC3C;CACF,EACF;AACF"}
1
+ {"version":3,"file":"rate-limit.js","names":[],"sources":["../../src/infrastructure/better-auth/rate-limit.ts"],"sourcesContent":["import type { BetterAuthOptions, BetterAuthRateLimitStorage } from 'better-auth'\n\nimport { AppError, createLogger } from '@/core'\n\n/** A rule Better Auth passes to storage: a window in seconds and the requests it allows. */\nexport type AuthRateLimitRule = {\n readonly window: number\n readonly max: number\n}\n\n/** Each rule must match its binding's deployed limit and period. Cloudflare accepts only 10 or 60 seconds. */\nexport type AuthRateLimitBinding = {\n readonly binding: Pick<RateLimit, 'limit'>\n readonly window: 10 | 60\n readonly max: number\n}\n\n/**\n * Better Auth's built-in rules, which it does not export. Sign-in, sign-up, change-password, and\n * change-email paths get 3 requests per 10 seconds. Password reset, verification email, forget-password,\n * and email OTP send paths get 3 per 60 seconds. A package test pins both against the installed version.\n * Source: `getDefaultSpecialRules` in\n * https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/api/rate-limiter/index.ts\n */\nexport const BUILT_IN_AUTH_RATE_LIMIT_RULES: readonly AuthRateLimitRule[] = [\n { window: 10, max: 3 },\n { window: 60, max: 3 },\n]\n\n/**\n * Every rule the configured instance can pass to storage, deduplicated: the general rule, the built-in\n * rules, each plugin's rules, and static custom rules. Function-valued custom rules decide per request\n * and are not listed. Disabled rate limiting returns no rules.\n */\nexport function authRateLimitRules(\n options: Pick<BetterAuthOptions, 'rateLimit' | 'plugins'>,\n): readonly AuthRateLimitRule[] {\n const rateLimit = options.rateLimit\n if (rateLimit?.enabled === false) return []\n // Better Auth's own defaults when the general rule is not set: window 10, max 100. Source:\n // https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/context/create-context.ts\n const rules: AuthRateLimitRule[] = [\n { window: rateLimit?.window ?? 10, max: rateLimit?.max ?? 100 },\n ...BUILT_IN_AUTH_RATE_LIMIT_RULES,\n ]\n for (const plugin of options.plugins ?? [])\n for (const rule of plugin.rateLimit ?? []) rules.push({ window: rule.window, max: rule.max })\n for (const rule of Object.values(rateLimit?.customRules ?? {}))\n if (rule !== false && !(rule instanceof Function))\n rules.push({ window: rule.window, max: rule.max })\n const unique = new Map(rules.map((rule) => [`${rule.window}:${rule.max}`, rule]))\n return [...unique.values()]\n}\n\n/** A binding failure with no known retry classification. The cause preserves the original failure. */\nexport class AuthRateLimitError extends AppError {\n readonly _tag = 'AuthRateLimitError'\n readonly classification = 'unknown'\n\n constructor(cause: unknown) {\n super('Authentication rate limiter is unavailable', { cause })\n }\n}\n\n/** No configured binding matches this Better Auth rule. Outage policy cannot bypass a configuration error. */\nexport class UnsupportedAuthRateLimitRuleError extends AppError {\n readonly _tag = 'UnsupportedAuthRateLimitRuleError'\n readonly classification = 'terminal'\n\n constructor(\n readonly window: number,\n readonly max: number,\n ) {\n super('No Cloudflare binding supports the authentication rate limit rule')\n }\n}\n\n/** Typist allows requests during binding outages. Select block to propagate an error instead. */\nexport type AuthRateLimitOptions = {\n readonly failureMode?: 'allow' | 'block'\n}\n\n/**\n * Port Typist and Porte's binding adapters with explicit rule matching and configurable outage behavior.\n * Bindings enforce approximate limits per Cloudflare location; retryAfter is a conservative full window.\n */\nexport function createAuthRateLimitStorage(\n bindings: readonly AuthRateLimitBinding[],\n { failureMode = 'allow' }: AuthRateLimitOptions = {},\n): BetterAuthRateLimitStorage {\n const logger = createLogger('auth-rate-limit')\n return {\n async consume(key, rule) {\n const configured = bindings.find(\n (entry) => entry.window === rule.window && entry.max === rule.max,\n )\n if (configured === undefined)\n throw new UnsupportedAuthRateLimitRuleError(rule.window, rule.max)\n try {\n const { success } = await configured.binding.limit({ key })\n return { allowed: success, retryAfter: success ? null : rule.window }\n } catch (cause) {\n if (failureMode === 'block') throw new AuthRateLimitError(cause)\n logger.error('rate_limit_binding_failed', {\n error: cause,\n details: { window: rule.window, max: rule.max, failureMode },\n })\n return { allowed: true, retryAfter: null }\n }\n },\n }\n}\n"],"mappings":";;;;;;;;;;AAwBA,MAAa,iCAA+D,CAC1E;CAAE,QAAQ;CAAI,KAAK;AAAE,GACrB;CAAE,QAAQ;CAAI,KAAK;AAAE,CACvB;;;;;;AAOA,SAAgB,mBACd,SAC8B;CAC9B,MAAM,YAAY,QAAQ;CAC1B,IAAI,WAAW,YAAY,OAAO,OAAO,CAAC;CAG1C,MAAM,QAA6B,CACjC;EAAE,QAAQ,WAAW,UAAU;EAAI,KAAK,WAAW,OAAO;CAAI,GAC9D,GAAG,8BACL;CACA,KAAK,MAAM,UAAU,QAAQ,WAAW,CAAC,GACvC,KAAK,MAAM,QAAQ,OAAO,aAAa,CAAC,GAAG,MAAM,KAAK;EAAE,QAAQ,KAAK;EAAQ,KAAK,KAAK;CAAI,CAAC;CAC9F,KAAK,MAAM,QAAQ,OAAO,OAAO,WAAW,eAAe,CAAC,CAAC,GAC3D,IAAI,SAAS,SAAS,EAAE,gBAAgB,WACtC,MAAM,KAAK;EAAE,QAAQ,KAAK;EAAQ,KAAK,KAAK;CAAI,CAAC;CAErD,OAAO,CAAC,GAAG,IADQ,IAAI,MAAM,KAAK,SAAS,CAAC,GAAG,KAAK,OAAO,GAAG,KAAK,OAAO,IAAI,CAAC,CAC/D,CAAC,CAAC,OAAO,CAAC;AAC5B;;AAGA,IAAa,qBAAb,cAAwC,SAAS;CAC/C,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAE1B,YAAY,OAAgB;EAC1B,MAAM,8CAA8C,EAAE,MAAM,CAAC;CAC/D;AACF;;AAGA,IAAa,oCAAb,cAAuD,SAAS;CAKnD;CACA;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAE1B,YACE,AAAS,QACT,AAAS,KACT;EACA,MAAM,mEAAmE;EAHhE;EACA;CAGX;AACF;;;;;AAWA,SAAgB,2BACd,UACA,EAAE,cAAc,YAAkC,CAAC,GACvB;CAC5B,MAAM,SAAS,aAAa,iBAAiB;CAC7C,OAAO,EACL,MAAM,QAAQ,KAAK,MAAM;EACvB,MAAM,aAAa,SAAS,MACzB,UAAU,MAAM,WAAW,KAAK,UAAU,MAAM,QAAQ,KAAK,GAChE;EACA,IAAI,eAAe,QACjB,MAAM,IAAI,kCAAkC,KAAK,QAAQ,KAAK,GAAG;EACnE,IAAI;GACF,MAAM,EAAE,YAAY,MAAM,WAAW,QAAQ,MAAM,EAAE,IAAI,CAAC;GAC1D,OAAO;IAAE,SAAS;IAAS,YAAY,UAAU,OAAO,KAAK;GAAO;EACtE,SAAS,OAAO;GACd,IAAI,gBAAgB,SAAS,MAAM,IAAI,mBAAmB,KAAK;GAC/D,OAAO,MAAM,6BAA6B;IACxC,OAAO;IACP,SAAS;KAAE,QAAQ,KAAK;KAAQ,KAAK,KAAK;KAAK;IAAY;GAC7D,CAAC;GACD,OAAO;IAAE,SAAS;IAAM,YAAY;GAAK;EAC3C;CACF,EACF;AACF"}
@@ -0,0 +1,36 @@
1
+ import { q as MessageId } from "../index-6XMPJu7u.js";
2
+ import { z } from "zod";
3
+ //#region src/infrastructure/cloudflare/scheduled/scheduled-handler.d.ts
4
+ /** The work one schedule runs. Nothing returns to the platform, so each run reports its own outcome. */
5
+ type ScheduledRun<Deps> = (deps: Deps, at: Date) => Promise<void>;
6
+ /** The cron expressions of a schedule map, as literal types. */
7
+ type ScheduleExpression<Schedules extends Readonly<Record<string, string>>> = Schedules[keyof Schedules];
8
+ /** The Cloudflare `scheduled` export, with the application's dependencies as its second argument. */
9
+ type ScheduledHandler<Deps> = (controller: ScheduledController, deps: Deps) => Promise<void>;
10
+ /** What `defineSchedules` returns: the map, its parser, and the handler factory. */
11
+ type AppSchedules<Schedules extends Readonly<Record<string, string>>> = {
12
+ readonly schedules: Schedules;
13
+ /** Parses `controller.cron`, a bare string from the platform, into a known expression. */
14
+ readonly schema: z.ZodType<ScheduleExpression<Schedules>>;
15
+ /**
16
+ * Route each schedule to its work. One run per expression, so a schedule added without
17
+ * work fails to compile. An expression Wrangler fires that the map does not know is logged
18
+ * once and ignored. A run that throws rejects, so Cloudflare marks the invocation failed.
19
+ */
20
+ handler<Deps>(runs: Readonly<Record<ScheduleExpression<Schedules>, ScheduledRun<Deps>>>): ScheduledHandler<Deps>;
21
+ };
22
+ /**
23
+ * Define the application's schedules once: `EVERY_MINUTE: '* * * * *'`. Keys name the time,
24
+ * not the work, because one schedule may drive several commands. Wrangler's `triggers.crons`
25
+ * must hold exactly these expressions. Cron does not retry: a run that sweeps many items
26
+ * catches each item's failure itself.
27
+ */
28
+ export declare function defineSchedules<const Schedules extends Readonly<Record<string, string>>>(schedules: Schedules): AppSchedules<Schedules>;
29
+ /**
30
+ * The id of a command a schedule issues: `cron:<Name>:<scheduledTime>`. A doubled trigger for
31
+ * the same time is then a duplicate by construction, and the unit of work skips it.
32
+ */
33
+ export declare function scheduledMessageId(name: string, at: Date): MessageId;
34
+ //#endregion
35
+ export type { AppSchedules, ScheduleExpression, ScheduledHandler, ScheduledRun };
36
+ //# sourceMappingURL=scheduled.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scheduled.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/scheduled/scheduled-handler.ts"],"mappings":";;;;KAOY,aAAa,SAAS,MAAM,MAAM,IAAI,SAAS;;KAG/C,mBAAmB,kBAAkB,SAAS,2BACxD,gBAAgB;;KAGN,iBAAiB,SAAS,YAAY,qBAAqB,MAAM,SAAS;;KAG1E,aAAa,kBAAkB,SAAS;WACzC,WAAW;;WAEX,QAAQ,EAAE,QAAQ,mBAAmB;;;;;;EAM9C,QAAQ,MACN,MAAM,SAAS,OAAO,mBAAmB,YAAY,aAAa,UACjE,iBAAiB;;;;;;;;wBASN,sBAAsB,kBAAkB,SAAS,yBAC/D,WAAW,YACV,aAAa;;;;;wBAoBA,mBAAmB,cAAc,IAAI,OAAO"}
@@ -0,0 +1,37 @@
1
+ import { a as createLogger } from "../core-DC_4pOrI.js";
2
+ import { z } from "zod";
3
+
4
+ //#region src/infrastructure/cloudflare/scheduled/scheduled-handler.ts
5
+ const logger = createLogger("scheduled");
6
+ /**
7
+ * Define the application's schedules once: `EVERY_MINUTE: '* * * * *'`. Keys name the time,
8
+ * not the work, because one schedule may drive several commands. Wrangler's `triggers.crons`
9
+ * must hold exactly these expressions. Cron does not retry: a run that sweeps many items
10
+ * catches each item's failure itself.
11
+ */
12
+ function defineSchedules(schedules) {
13
+ const schema = z.enum(schedules);
14
+ return {
15
+ schedules,
16
+ schema,
17
+ handler: (runs) => async (controller, deps) => {
18
+ const { cron } = controller;
19
+ if (!schema.validate(cron)) {
20
+ logger.error("scheduled_unknown_cron", { details: { cron } });
21
+ return;
22
+ }
23
+ await runs[cron](deps, new Date(controller.scheduledTime));
24
+ }
25
+ };
26
+ }
27
+ /**
28
+ * The id of a command a schedule issues: `cron:<Name>:<scheduledTime>`. A doubled trigger for
29
+ * the same time is then a duplicate by construction, and the unit of work skips it.
30
+ */
31
+ function scheduledMessageId(name, at) {
32
+ return `cron:${name}:${String(at.getTime())}`;
33
+ }
34
+
35
+ //#endregion
36
+ export { defineSchedules, scheduledMessageId };
37
+ //# sourceMappingURL=scheduled.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scheduled.js","names":[],"sources":["../../src/infrastructure/cloudflare/scheduled/scheduled-handler.ts"],"sourcesContent":["import { z } from 'zod'\n\nimport { createLogger, type MessageId } from '@/core'\n\nconst logger = createLogger('scheduled')\n\n/** The work one schedule runs. Nothing returns to the platform, so each run reports its own outcome. */\nexport type ScheduledRun<Deps> = (deps: Deps, at: Date) => Promise<void>\n\n/** The cron expressions of a schedule map, as literal types. */\nexport type ScheduleExpression<Schedules extends Readonly<Record<string, string>>> =\n Schedules[keyof Schedules]\n\n/** The Cloudflare `scheduled` export, with the application's dependencies as its second argument. */\nexport type ScheduledHandler<Deps> = (controller: ScheduledController, deps: Deps) => Promise<void>\n\n/** What `defineSchedules` returns: the map, its parser, and the handler factory. */\nexport type AppSchedules<Schedules extends Readonly<Record<string, string>>> = {\n readonly schedules: Schedules\n /** Parses `controller.cron`, a bare string from the platform, into a known expression. */\n readonly schema: z.ZodType<ScheduleExpression<Schedules>>\n /**\n * Route each schedule to its work. One run per expression, so a schedule added without\n * work fails to compile. An expression Wrangler fires that the map does not know is logged\n * once and ignored. A run that throws rejects, so Cloudflare marks the invocation failed.\n */\n handler<Deps>(\n runs: Readonly<Record<ScheduleExpression<Schedules>, ScheduledRun<Deps>>>,\n ): ScheduledHandler<Deps>\n}\n\n/**\n * Define the application's schedules once: `EVERY_MINUTE: '* * * * *'`. Keys name the time,\n * not the work, because one schedule may drive several commands. Wrangler's `triggers.crons`\n * must hold exactly these expressions. Cron does not retry: a run that sweeps many items\n * catches each item's failure itself.\n */\nexport function defineSchedules<const Schedules extends Readonly<Record<string, string>>>(\n schedules: Schedules,\n): AppSchedules<Schedules> {\n const schema = z.enum(schedules)\n return {\n schedules,\n schema,\n handler: (runs) => async (controller, deps) => {\n const { cron } = controller\n if (!schema.validate(cron)) {\n logger.error('scheduled_unknown_cron', { details: { cron } })\n return\n }\n await runs[cron](deps, new Date(controller.scheduledTime))\n },\n }\n}\n\n/**\n * The id of a command a schedule issues: `cron:<Name>:<scheduledTime>`. A doubled trigger for\n * the same time is then a duplicate by construction, and the unit of work skips it.\n */\nexport function scheduledMessageId(name: string, at: Date): MessageId {\n return `cron:${name}:${String(at.getTime())}`\n}\n"],"mappings":";;;;AAIA,MAAM,SAAS,aAAa,WAAW;;;;;;;AAiCvC,SAAgB,gBACd,WACyB;CACzB,MAAM,SAAS,EAAE,KAAK,SAAS;CAC/B,OAAO;EACL;EACA;EACA,UAAU,SAAS,OAAO,YAAY,SAAS;GAC7C,MAAM,EAAE,SAAS;GACjB,IAAI,CAAC,OAAO,SAAS,IAAI,GAAG;IAC1B,OAAO,MAAM,0BAA0B,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IAC5D;GACF;GACA,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,KAAK,WAAW,aAAa,CAAC;EAC3D;CACF;AACF;;;;;AAMA,SAAgB,mBAAmB,MAAc,IAAqB;CACpE,OAAO,QAAQ,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAC5C"}
@@ -1,7 +1,19 @@
1
1
  import { ot as ErrorTable } from "./index-6XMPJu7u.js";
2
- import { Register, RequestServerOptions } from "@tanstack/react-start";
3
- import { AnyRedirect } from "@tanstack/react-router";
2
+ import { Register } from "@tanstack/react-start";
4
3
  import { BetterAuthCookies } from "better-auth";
4
+ //#region src/infrastructure/tanstack/request-context.d.ts
5
+ /**
6
+ * The request context an application registers as `Register.server.requestContext`.
7
+ * TanStack's own request context type also reads `Register.config`, which lists the middleware
8
+ * whose options carry this type, so using it here would make every application's types circular.
9
+ * Without a registration TanStack passes no context, so the fallback is `undefined`.
10
+ */
11
+ type RequestContext = Register extends {
12
+ server: {
13
+ requestContext: infer Context;
14
+ };
15
+ } ? Context : undefined;
16
+ //#endregion
5
17
  //#region src/infrastructure/tanstack/auth.middleware.d.ts
6
18
  /** The fields middleware reads; generic callers retain every additional session and user field. */
7
19
  type AuthSession = {
@@ -24,7 +36,7 @@ type AuthContext<Session extends AuthSession> = RequiredAuthContext<Session> | {
24
36
  };
25
37
  /** Select the current request's Better Auth instance from TanStack's registered context. */
26
38
  type AuthMiddlewareOptions<Session extends AuthSession> = {
27
- readonly getAuth: (context: RequestServerOptions<Register, undefined>['context']) => {
39
+ readonly getAuth: (context: RequestContext) => {
28
40
  readonly api: {
29
41
  readonly getSession: (options: {
30
42
  headers: Headers;
@@ -43,7 +55,6 @@ export declare function createAuthMiddleware<Session extends AuthSession>(option
43
55
  };
44
56
  //#endregion
45
57
  //#region src/infrastructure/tanstack/error.middleware.d.ts
46
- type RequestContext = RequestServerOptions<Register, undefined>['context'];
47
58
  /** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */
48
59
  type AuthCookieSource = {
49
60
  readonly $context: Promise<{
@@ -53,8 +64,8 @@ type AuthCookieSource = {
53
64
  /** The application's error table, its stale-session redirect, and its Better Auth instance. */
54
65
  type ErrorMiddlewareOptions<Entries> = {
55
66
  readonly errors: ErrorTable<Entries>;
56
- /** Build the redirect a server function throws for a stale session. */
57
- readonly signIn: () => AnyRedirect;
67
+ /** The path a server function redirects to for a stale session, with any search string. */
68
+ readonly signIn: string;
58
69
  /** Select the request's Better Auth instance; its cookie definitions say what to clear. */
59
70
  readonly getAuth: (context: RequestContext) => AuthCookieSource;
60
71
  };
@@ -1 +1 @@
1
- {"version":3,"file":"tanstack.d.ts","names":[],"sources":["../src/infrastructure/tanstack/auth.middleware.ts","../src/infrastructure/tanstack/error.middleware.ts","../src/infrastructure/tanstack/last-login-method.ts"],"mappings":";;;;;;KASY;WACD;aAAiB;;WACjB;aAAoB;;;;KAInB,oBAAoB,gBAAgB;WACrC,MAAM;WACN,SAAS;;;KAIR,YAAY,gBAAgB,eACpC,oBAAoB;WAET;WACA;;;KAIH,sBAAsB,gBAAgB;WACvC,UAAU,SAAS,qBAAqB;aACtC;eACE,aAAa;QAAW,SAAS;YAAc,QAAQ;;;;;;;;wBAStD,qBAAqB,gBAAgB,aACnD,SAAS,sBAAsB;;;;;;;KChC5B,iBAAiB,qBAAqB;;KAG/B;WACD,UAAU;aAAmB,aAAa;;;;KAIzC,uBAAuB;WACxB,QAAQ,WAAW;;WAEnB,cAAc;;WAEd,UAAU,SAAS,mBAAmB;;;;;;wBAOjC,sBAAsB,SAAS,SAAS,uBAAuB;;;;;;;;;;;;;;;;;;;;wBCpB/D,uBAAuB"}
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","../src/infrastructure/tanstack/last-login-method.ts"],"mappings":";;;;;;;;;;KAQY,iBAAiB;EAAmB;IAAU,sBAAsB;;IAC5E;;;;KCEQ;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;;;;;;;;wBAStD,qBAAqB,gBAAgB,aACnD,SAAS,sBAAsB;;;;;;;;KChCrB;WACD,UAAU;aAAmB,aAAa;;;;KAIzC,uBAAuB;WACxB,QAAQ,WAAW;;WAEnB;;WAEA,UAAU,SAAS,mBAAmB;;;;;;wBAOjC,sBAAsB,SAAS,SAAS,uBAAuB;;;;;;;;;;;;;;;;;;;;wBCnB/D,uBAAuB"}
package/dist/tanstack.js CHANGED
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { createMiddleware } from "@tanstack/react-start";
4
4
  import { deleteCookie, getRequestHeaders } from "@tanstack/react-start/server";
5
5
  import { getSessionCookie, parseCookies } from "better-auth/cookies";
6
- import { isNotFound, isRedirect } from "@tanstack/react-router";
6
+ import { isNotFound, isRedirect, redirect } from "@tanstack/react-router";
7
7
  import { getReasonPhrase } from "http-status-codes";
8
8
 
9
9
  //#region src/infrastructure/tanstack/auth.middleware.ts
@@ -180,7 +180,7 @@ function createErrorMiddleware(options) {
180
180
  if (isNotFound(cause) || isRedirect(cause)) throw cause;
181
181
  if (cause instanceof StaleSessionError) {
182
182
  await clearSession(context);
183
- throw options.signIn();
183
+ throw redirect({ href: options.signIn });
184
184
  }
185
185
  throw toErrorPayload(cause);
186
186
  }
@@ -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, type Register, type RequestServerOptions } 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\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: RequestServerOptions<Register, undefined>['context']) => {\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: RequestServerOptions<Register, undefined>['context'],\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, type AnyRedirect } from '@tanstack/react-router'\nimport { createMiddleware, type Register, type RequestServerOptions } 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 { clearStaleSessionCookies } from './session-cookies.ts'\nimport { createErrorPayloadConverter } from './to-error-payload.ts'\n\ntype RequestContext = RequestServerOptions<Register, undefined>['context']\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 /** Build the redirect a server function throws for a stale session. */\n readonly signIn: () => AnyRedirect\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 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","../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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"}
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.8.0",
4
+ "version": "0.10.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": {
@@ -25,6 +25,7 @@
25
25
  "./cloudflare": "./dist/cloudflare.js",
26
26
  "./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
27
27
  "./cloudflare/queue": "./dist/cloudflare/queue.js",
28
+ "./cloudflare/scheduled": "./dist/cloudflare/scheduled.js",
28
29
  "./core": "./dist/core.js",
29
30
  "./postgres": "./dist/postgres.js",
30
31
  "./tanstack": "./dist/tanstack.js",
@@ -53,7 +54,6 @@
53
54
  "better-auth": "^1.7.4",
54
55
  "drizzle-kit": "^0.31.10",
55
56
  "drizzle-orm": "^0.45.2",
56
- "jose": "^6.2.12",
57
57
  "postgres": "^3.4.9",
58
58
  "stripe": "^22.6.2",
59
59
  "tsdown": "^0.23.0",
@@ -67,7 +67,6 @@
67
67
  "@tanstack/react-start": "^1.168.52",
68
68
  "better-auth": "^1.7.4",
69
69
  "drizzle-orm": "^0.45.2",
70
- "jose": "^6.2.12",
71
70
  "stripe": "^22.6.2"
72
71
  },
73
72
  "peerDependenciesMeta": {
@@ -77,9 +76,6 @@
77
76
  "stripe": {
78
77
  "optional": true
79
78
  },
80
- "jose": {
81
- "optional": true
82
- },
83
79
  "better-auth": {
84
80
  "optional": true
85
81
  },
@@ -1,12 +1,21 @@
1
- import { importPKCS8, SignJWT } from 'jose'
1
+ import { Buffer } from 'node:buffer'
2
+ import { createPrivateKey, sign } from 'node:crypto'
2
3
 
3
4
  import { AppError, createLogger } from '@/core'
4
5
 
5
6
  const logger = createLogger('auth')
6
7
  // Typist reports an invalid key once per isolate because session checks repeatedly construct auth options.
7
8
  let appleKeyFailureLogged = false
9
+ /**
10
+ * Apple caps `exp` at 15,777,000 seconds after `iat`; 180 days stays under it. Source:
11
+ * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
12
+ */
13
+ const SECRET_LIFETIME_SECONDS = 180 * 24 * 60 * 60
8
14
 
9
- /** Apple sends its OAuth callback from this origin. Include it in trustedOrigins when enabling Apple. */
15
+ /**
16
+ * Apple's issuer and OAuth callback origin, and the `aud` claim of the client secret. Include it in
17
+ * trustedOrigins when enabling Apple. Source: the same Apple page as `SECRET_LIFETIME_SECONDS`.
18
+ */
10
19
  export const APPLE_ORIGIN = 'https://appleid.apple.com'
11
20
 
12
21
  /** Apple credentials used to sign a client secret. Ported from Porte's apple-client-secret.ts. */
@@ -27,11 +36,23 @@ export class AppleClientSecretError extends AppError {
27
36
  }
28
37
  }
29
38
 
30
- /** Sign Apple's six-month client secret. Invalid private keys return an empty secret and log once per isolate, as in Typist. */
31
- export async function generateAppleClientSecret(config: AppleKeyConfig): Promise<string> {
32
- let key: Awaited<ReturnType<typeof importPKCS8>>
39
+ /** One JWS segment before encoding: the protected header or the claims. */
40
+ type JwsSegment = Readonly<Record<string, string | number>>
41
+
42
+ function base64UrlJson(segment: JwsSegment): string {
43
+ return Buffer.from(JSON.stringify(segment)).toString('base64url')
44
+ }
45
+
46
+ /**
47
+ * Sign Apple's six-month client secret synchronously, so Better Auth options can be built without awaiting it.
48
+ * Signing uses `node:crypto` as Typist does, so the Worker needs the `nodejs_compat` flag.
49
+ * Invalid private keys return an empty secret and log once per isolate, as in Typist.
50
+ */
51
+ export function generateAppleClientSecret(config: AppleKeyConfig): string {
52
+ const key = config.applePrivateKey.replace(/\\n/g, '\n')
53
+ // Validation only: workerd's sign() takes the PEM string, not the KeyObject.
33
54
  try {
34
- key = await importPKCS8(config.applePrivateKey.replace(/\\n/g, '\n'), 'ES256')
55
+ createPrivateKey(key)
35
56
  } catch (error) {
36
57
  if (!appleKeyFailureLogged) {
37
58
  appleKeyFailureLogged = true
@@ -39,15 +60,21 @@ export async function generateAppleClientSecret(config: AppleKeyConfig): Promise
39
60
  }
40
61
  return ''
41
62
  }
63
+ const issuedAt = Math.floor(Date.now() / 1000)
64
+ const header = base64UrlJson({ alg: 'ES256', kid: config.appleKeyId })
65
+ const claims = base64UrlJson({
66
+ iss: config.appleTeamId,
67
+ sub: config.appleClientId,
68
+ aud: APPLE_ORIGIN,
69
+ iat: issuedAt,
70
+ exp: issuedAt + SECRET_LIFETIME_SECONDS,
71
+ })
72
+ const input = `${header}.${claims}`
42
73
  try {
43
- return await new SignJWT({})
44
- .setProtectedHeader({ alg: 'ES256', kid: config.appleKeyId })
45
- .setIssuer(config.appleTeamId)
46
- .setSubject(config.appleClientId)
47
- .setAudience(APPLE_ORIGIN)
48
- .setIssuedAt()
49
- .setExpirationTime('180d')
50
- .sign(key)
74
+ // A JWS carries the raw r||s signature; OpenSSL emits DER unless told otherwise.
75
+ const signature = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' })
76
+ // TypeScript 7 types the returned buffer without an encoding parameter on toString.
77
+ return `${input}.${Buffer.from(signature).toString('base64url')}`
51
78
  } catch (cause) {
52
79
  throw new AppleClientSecretError(cause)
53
80
  }
@@ -1,7 +1,13 @@
1
- import type { BetterAuthRateLimitStorage } from 'better-auth'
1
+ import type { BetterAuthOptions, BetterAuthRateLimitStorage } from 'better-auth'
2
2
 
3
3
  import { AppError, createLogger } from '@/core'
4
4
 
5
+ /** A rule Better Auth passes to storage: a window in seconds and the requests it allows. */
6
+ export type AuthRateLimitRule = {
7
+ readonly window: number
8
+ readonly max: number
9
+ }
10
+
5
11
  /** Each rule must match its binding's deployed limit and period. Cloudflare accepts only 10 or 60 seconds. */
6
12
  export type AuthRateLimitBinding = {
7
13
  readonly binding: Pick<RateLimit, 'limit'>
@@ -9,6 +15,43 @@ export type AuthRateLimitBinding = {
9
15
  readonly max: number
10
16
  }
11
17
 
18
+ /**
19
+ * Better Auth's built-in rules, which it does not export. Sign-in, sign-up, change-password, and
20
+ * change-email paths get 3 requests per 10 seconds. Password reset, verification email, forget-password,
21
+ * and email OTP send paths get 3 per 60 seconds. A package test pins both against the installed version.
22
+ * Source: `getDefaultSpecialRules` in
23
+ * https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/api/rate-limiter/index.ts
24
+ */
25
+ export const BUILT_IN_AUTH_RATE_LIMIT_RULES: readonly AuthRateLimitRule[] = [
26
+ { window: 10, max: 3 },
27
+ { window: 60, max: 3 },
28
+ ]
29
+
30
+ /**
31
+ * Every rule the configured instance can pass to storage, deduplicated: the general rule, the built-in
32
+ * rules, each plugin's rules, and static custom rules. Function-valued custom rules decide per request
33
+ * and are not listed. Disabled rate limiting returns no rules.
34
+ */
35
+ export function authRateLimitRules(
36
+ options: Pick<BetterAuthOptions, 'rateLimit' | 'plugins'>,
37
+ ): readonly AuthRateLimitRule[] {
38
+ const rateLimit = options.rateLimit
39
+ if (rateLimit?.enabled === false) return []
40
+ // Better Auth's own defaults when the general rule is not set: window 10, max 100. Source:
41
+ // https://github.com/better-auth/better-auth/blob/v1.7.4/packages/better-auth/src/context/create-context.ts
42
+ const rules: AuthRateLimitRule[] = [
43
+ { window: rateLimit?.window ?? 10, max: rateLimit?.max ?? 100 },
44
+ ...BUILT_IN_AUTH_RATE_LIMIT_RULES,
45
+ ]
46
+ for (const plugin of options.plugins ?? [])
47
+ for (const rule of plugin.rateLimit ?? []) rules.push({ window: rule.window, max: rule.max })
48
+ for (const rule of Object.values(rateLimit?.customRules ?? {}))
49
+ if (rule !== false && !(rule instanceof Function))
50
+ rules.push({ window: rule.window, max: rule.max })
51
+ const unique = new Map(rules.map((rule) => [`${rule.window}:${rule.max}`, rule]))
52
+ return [...unique.values()]
53
+ }
54
+
12
55
  /** A binding failure with no known retry classification. The cause preserves the original failure. */
13
56
  export class AuthRateLimitError extends AppError {
14
57
  readonly _tag = 'AuthRateLimitError'
@@ -0,0 +1,8 @@
1
+ export {
2
+ defineSchedules,
3
+ scheduledMessageId,
4
+ type AppSchedules,
5
+ type ScheduledHandler,
6
+ type ScheduledRun,
7
+ type ScheduleExpression,
8
+ } from './scheduled-handler.ts'
@@ -0,0 +1,62 @@
1
+ import { z } from 'zod'
2
+
3
+ import { createLogger, type MessageId } from '@/core'
4
+
5
+ const logger = createLogger('scheduled')
6
+
7
+ /** The work one schedule runs. Nothing returns to the platform, so each run reports its own outcome. */
8
+ export type ScheduledRun<Deps> = (deps: Deps, at: Date) => Promise<void>
9
+
10
+ /** The cron expressions of a schedule map, as literal types. */
11
+ export type ScheduleExpression<Schedules extends Readonly<Record<string, string>>> =
12
+ Schedules[keyof Schedules]
13
+
14
+ /** The Cloudflare `scheduled` export, with the application's dependencies as its second argument. */
15
+ export type ScheduledHandler<Deps> = (controller: ScheduledController, deps: Deps) => Promise<void>
16
+
17
+ /** What `defineSchedules` returns: the map, its parser, and the handler factory. */
18
+ export type AppSchedules<Schedules extends Readonly<Record<string, string>>> = {
19
+ readonly schedules: Schedules
20
+ /** Parses `controller.cron`, a bare string from the platform, into a known expression. */
21
+ readonly schema: z.ZodType<ScheduleExpression<Schedules>>
22
+ /**
23
+ * Route each schedule to its work. One run per expression, so a schedule added without
24
+ * work fails to compile. An expression Wrangler fires that the map does not know is logged
25
+ * once and ignored. A run that throws rejects, so Cloudflare marks the invocation failed.
26
+ */
27
+ handler<Deps>(
28
+ runs: Readonly<Record<ScheduleExpression<Schedules>, ScheduledRun<Deps>>>,
29
+ ): ScheduledHandler<Deps>
30
+ }
31
+
32
+ /**
33
+ * Define the application's schedules once: `EVERY_MINUTE: '* * * * *'`. Keys name the time,
34
+ * not the work, because one schedule may drive several commands. Wrangler's `triggers.crons`
35
+ * must hold exactly these expressions. Cron does not retry: a run that sweeps many items
36
+ * catches each item's failure itself.
37
+ */
38
+ export function defineSchedules<const Schedules extends Readonly<Record<string, string>>>(
39
+ schedules: Schedules,
40
+ ): AppSchedules<Schedules> {
41
+ const schema = z.enum(schedules)
42
+ return {
43
+ schedules,
44
+ schema,
45
+ handler: (runs) => async (controller, deps) => {
46
+ const { cron } = controller
47
+ if (!schema.validate(cron)) {
48
+ logger.error('scheduled_unknown_cron', { details: { cron } })
49
+ return
50
+ }
51
+ await runs[cron](deps, new Date(controller.scheduledTime))
52
+ },
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The id of a command a schedule issues: `cron:<Name>:<scheduledTime>`. A doubled trigger for
58
+ * the same time is then a duplicate by construction, and the unit of work skips it.
59
+ */
60
+ export function scheduledMessageId(name: string, at: Date): MessageId {
61
+ return `cron:${name}:${String(at.getTime())}`
62
+ }
@@ -1,9 +1,11 @@
1
- import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'
1
+ import { createMiddleware } from '@tanstack/react-start'
2
2
  import { getRequestHeaders } from '@tanstack/react-start/server'
3
3
  import { getSessionCookie } from 'better-auth/cookies'
4
4
 
5
5
  import { AuthenticationError, StaleSessionError, createLogger } from '@/core'
6
6
 
7
+ import type { RequestContext } from './request-context.ts'
8
+
7
9
  const logger = createLogger('auth-middleware')
8
10
 
9
11
  /** The fields middleware reads; generic callers retain every additional session and user field. */
@@ -28,7 +30,7 @@ export type AuthContext<Session extends AuthSession> =
28
30
 
29
31
  /** Select the current request's Better Auth instance from TanStack's registered context. */
30
32
  export type AuthMiddlewareOptions<Session extends AuthSession> = {
31
- readonly getAuth: (context: RequestServerOptions<Register, undefined>['context']) => {
33
+ readonly getAuth: (context: RequestContext) => {
32
34
  readonly api: {
33
35
  readonly getSession: (options: { headers: Headers }) => Promise<Session | null>
34
36
  }
@@ -60,7 +62,7 @@ export function createAuthMiddleware<Session extends AuthSession>(
60
62
  )
61
63
 
62
64
  async function resolveRequiredSession(
63
- context: RequestServerOptions<Register, undefined>['context'],
65
+ context: RequestContext,
64
66
  headers: Headers,
65
67
  ): Promise<Session> {
66
68
  const resolved = await options.getAuth(context).api.getSession({ headers })
@@ -1,15 +1,14 @@
1
- import { isNotFound, isRedirect, type AnyRedirect } from '@tanstack/react-router'
2
- import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'
1
+ import { isNotFound, isRedirect, redirect } from '@tanstack/react-router'
2
+ import { createMiddleware } from '@tanstack/react-start'
3
3
  import type { BetterAuthCookies } from 'better-auth'
4
4
 
5
5
  import { StaleSessionError, type ErrorTable } from '@/core'
6
6
 
7
7
  import { createHttpErrorResponseConverter } from './http-error-response.ts'
8
+ import type { RequestContext } from './request-context.ts'
8
9
  import { clearStaleSessionCookies } from './session-cookies.ts'
9
10
  import { createErrorPayloadConverter } from './to-error-payload.ts'
10
11
 
11
- type RequestContext = RequestServerOptions<Register, undefined>['context']
12
-
13
12
  /** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */
14
13
  export type AuthCookieSource = {
15
14
  readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>
@@ -18,8 +17,8 @@ export type AuthCookieSource = {
18
17
  /** The application's error table, its stale-session redirect, and its Better Auth instance. */
19
18
  export type ErrorMiddlewareOptions<Entries> = {
20
19
  readonly errors: ErrorTable<Entries>
21
- /** Build the redirect a server function throws for a stale session. */
22
- readonly signIn: () => AnyRedirect
20
+ /** The path a server function redirects to for a stale session, with any search string. */
21
+ readonly signIn: string
23
22
  /** Select the request's Better Auth instance; its cookie definitions say what to clear. */
24
23
  readonly getAuth: (context: RequestContext) => AuthCookieSource
25
24
  }
@@ -43,7 +42,7 @@ export function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<E
43
42
  if (cause instanceof StaleSessionError) {
44
43
  await clearSession(context)
45
44
  // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.
46
- throw options.signIn()
45
+ throw redirect({ href: options.signIn })
47
46
  }
48
47
  // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.
49
48
  throw toErrorPayload(cause)
@@ -0,0 +1,11 @@
1
+ import type { Register } from '@tanstack/react-start'
2
+
3
+ /**
4
+ * The request context an application registers as `Register.server.requestContext`.
5
+ * TanStack's own request context type also reads `Register.config`, which lists the middleware
6
+ * whose options carry this type, so using it here would make every application's types circular.
7
+ * Without a registration TanStack passes no context, so the fallback is `undefined`.
8
+ */
9
+ export type RequestContext = Register extends { server: { requestContext: infer Context } }
10
+ ? Context
11
+ : undefined