better-ship 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/{application-DYfcAy-Q.js → application-CIxo_BpK.js} +32 -3
  2. package/dist/application-CIxo_BpK.js.map +1 -0
  3. package/dist/application.d.ts +3 -3
  4. package/dist/application.js +2 -2
  5. package/dist/better-auth/apple-client-secret.d.ts +11 -4
  6. package/dist/better-auth/apple-client-secret.d.ts.map +1 -1
  7. package/dist/better-auth/apple-client-secret.js +39 -11
  8. package/dist/better-auth/apple-client-secret.js.map +1 -1
  9. package/dist/better-auth/auth-client.js +1 -1
  10. package/dist/better-auth/rate-limit.d.ts +21 -2
  11. package/dist/better-auth/rate-limit.d.ts.map +1 -1
  12. package/dist/better-auth/rate-limit.js +38 -2
  13. package/dist/better-auth/rate-limit.js.map +1 -1
  14. package/dist/better-auth.js +1 -1
  15. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  16. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  17. package/dist/cloudflare/queue.d.ts +1 -1
  18. package/dist/cloudflare/queue.js +2 -2
  19. package/dist/cloudflare/scheduled.d.ts +36 -0
  20. package/dist/cloudflare/scheduled.d.ts.map +1 -0
  21. package/dist/cloudflare/scheduled.js +37 -0
  22. package/dist/cloudflare/scheduled.js.map +1 -0
  23. package/dist/cloudflare.d.ts +3 -3
  24. package/dist/cloudflare.js +3 -3
  25. package/dist/{core-DC_4pOrI.js → core-DG_cC8YT.js} +110 -2
  26. package/dist/{core-DC_4pOrI.js.map → core-DG_cC8YT.js.map} +1 -1
  27. package/dist/core.d.ts +2 -2
  28. package/dist/core.js +2 -2
  29. package/dist/http.d.ts +19 -0
  30. package/dist/http.d.ts.map +1 -0
  31. package/dist/http.js +35 -0
  32. package/dist/http.js.map +1 -0
  33. package/dist/{index-6XMPJu7u.d.ts → index-Bl0K1RQW.d.ts} +90 -2
  34. package/dist/index-Bl0K1RQW.d.ts.map +1 -0
  35. package/dist/{index-_Cihfq1J.d.ts → index-DgDyeR5s.d.ts} +135 -55
  36. package/dist/index-DgDyeR5s.d.ts.map +1 -0
  37. package/dist/postgres.d.ts +3 -3
  38. package/dist/postgres.js +3 -3
  39. package/dist/resend.d.ts +73 -0
  40. package/dist/resend.d.ts.map +1 -0
  41. package/dist/resend.js +284 -0
  42. package/dist/resend.js.map +1 -0
  43. package/dist/tanstack/server.d.ts +8 -0
  44. package/dist/tanstack/server.d.ts.map +1 -0
  45. package/dist/tanstack/server.js +16 -0
  46. package/dist/tanstack/server.js.map +1 -0
  47. package/dist/tanstack.d.ts +18 -14
  48. package/dist/tanstack.d.ts.map +1 -1
  49. package/dist/tanstack.js +17 -23
  50. package/dist/tanstack.js.map +1 -1
  51. package/dist/{unit-of-work-context-BFCmU92Z.js → unit-of-work-context-BPE1In2f.js} +2 -2
  52. package/dist/{unit-of-work-context-BFCmU92Z.js.map → unit-of-work-context-BPE1In2f.js.map} +1 -1
  53. package/dist/{unit-of-work-context-BWTJfYPW.d.ts → unit-of-work-context-BzmnjlDa.d.ts} +3 -3
  54. package/dist/{unit-of-work-context-BWTJfYPW.d.ts.map → unit-of-work-context-BzmnjlDa.d.ts.map} +1 -1
  55. package/dist/webhook-verification-BQ9qZnVK.js +18 -0
  56. package/dist/webhook-verification-BQ9qZnVK.js.map +1 -0
  57. package/dist/webhook-verification-sZtwTI5n.d.ts +29 -0
  58. package/dist/webhook-verification-sZtwTI5n.d.ts.map +1 -0
  59. package/package.json +11 -6
  60. package/src/application/email-service.ts +55 -0
  61. package/src/application/index.ts +16 -0
  62. package/src/application/record-email-event.ts +53 -0
  63. package/src/core/email-address.ts +7 -0
  64. package/src/core/email-deliverability.ts +31 -0
  65. package/src/core/email-idempotency-key.ts +34 -0
  66. package/src/core/email-service.error.ts +51 -0
  67. package/src/core/index.ts +18 -0
  68. package/src/core/record-email-event.ts +23 -0
  69. package/src/infrastructure/better-auth/apple-client-secret.ts +41 -14
  70. package/src/infrastructure/better-auth/rate-limit.ts +44 -1
  71. package/src/infrastructure/cloudflare/scheduled/index.ts +8 -0
  72. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +62 -0
  73. package/src/infrastructure/http/index.ts +6 -0
  74. package/src/infrastructure/http/webhook-handler.ts +35 -0
  75. package/src/infrastructure/http/webhook-verification.ts +27 -0
  76. package/src/infrastructure/resend/email-event.ts +142 -0
  77. package/src/infrastructure/resend/index.ts +3 -0
  78. package/src/infrastructure/resend/resend-email-service.ts +125 -0
  79. package/src/infrastructure/resend/resend-webhook.ts +70 -0
  80. package/src/infrastructure/tanstack/auth.middleware.ts +14 -6
  81. package/src/infrastructure/tanstack/error.middleware.ts +7 -8
  82. package/src/infrastructure/tanstack/index.ts +0 -1
  83. package/src/infrastructure/tanstack/request-context.ts +11 -0
  84. package/src/infrastructure/tanstack/server.ts +7 -0
  85. package/src/infrastructure/tanstack/session-cookies.ts +6 -4
  86. package/dist/application-DYfcAy-Q.js.map +0 -1
  87. package/dist/index-6XMPJu7u.d.ts.map +0 -1
  88. package/dist/index-_Cihfq1J.d.ts.map +0 -1
@@ -1,5 +1,5 @@
1
- import "./index-6XMPJu7u.js";
2
- import { o as IEventCollector } from "./index-_Cihfq1J.js";
1
+ import "./index-Bl0K1RQW.js";
2
+ import { w as IEventCollector } from "./index-DgDyeR5s.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-BWTJfYPW.d.ts.map
16
+ //# sourceMappingURL=unit-of-work-context-BzmnjlDa.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"unit-of-work-context-BWTJfYPW.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB"}
1
+ {"version":3,"file":"unit-of-work-context-BzmnjlDa.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.8.0",
4
+ "version": "0.11.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,9 +25,13 @@
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",
30
+ "./http": "./dist/http.js",
29
31
  "./postgres": "./dist/postgres.js",
32
+ "./resend": "./dist/resend.js",
30
33
  "./tanstack": "./dist/tanstack.js",
34
+ "./tanstack/server": "./dist/tanstack/server.js",
31
35
  "./ui": "./dist/ui.js",
32
36
  "./package.json": "./package.json"
33
37
  },
@@ -53,8 +57,9 @@
53
57
  "better-auth": "^1.7.4",
54
58
  "drizzle-kit": "^0.31.10",
55
59
  "drizzle-orm": "^0.45.2",
56
- "jose": "^6.2.12",
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,17 +72,17 @@
67
72
  "@tanstack/react-start": "^1.168.52",
68
73
  "better-auth": "^1.7.4",
69
74
  "drizzle-orm": "^0.45.2",
70
- "jose": "^6.2.12",
75
+ "resend": "^6.28.0",
71
76
  "stripe": "^22.6.2"
72
77
  },
73
78
  "peerDependenciesMeta": {
74
- "@better-auth/stripe": {
79
+ "resend": {
75
80
  "optional": true
76
81
  },
77
- "stripe": {
82
+ "@better-auth/stripe": {
78
83
  "optional": true
79
84
  },
80
- "jose": {
85
+ "stripe": {
81
86
  "optional": true
82
87
  },
83
88
  "better-auth": {
@@ -0,0 +1,55 @@
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
+ readonly to: string | readonly string[]
21
+ readonly subject: string
22
+ readonly html: string
23
+ /** The stable identity of this send. Resend deduplicates it for 24 hours. */
24
+ readonly idempotencyKey: EmailIdempotencyKey
25
+ /** Resend generates plain text from html when omitted. An empty string disables that generation. */
26
+ readonly text?: string
27
+ readonly replyTo?: string
28
+ /** Labels the provider stores with the email and returns on every delivery event. */
29
+ readonly tags?: Readonly<Record<string, string>>
30
+ readonly attachments?: readonly EmailAttachment[]
31
+ }
32
+
33
+ /** The provider accepted the email and assigned its id. */
34
+ export type EmailAccepted = { readonly providerId: string }
35
+
36
+ /**
37
+ * The port a handler sends through. The adapter owns the sender address and the provider error mapping.
38
+ * A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.
39
+ */
40
+ export interface IEmailService {
41
+ /** Resolve after provider acceptance; this does not confirm delivery to the recipient. */
42
+ sendEmail(email: OutboundEmail): Promise<EmailAccepted>
43
+ }
44
+
45
+ /** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */
46
+ export class RecordingEmailService implements IEmailService {
47
+ /** Sends in call order, including repeated idempotency keys. */
48
+ readonly sent: OutboundEmail[] = []
49
+
50
+ /** Record the send before returning its provider id. */
51
+ async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {
52
+ this.sent.push(email)
53
+ return { providerId: `recorded-${this.sent.length}` }
54
+ }
55
+ }
@@ -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
+ }
@@ -0,0 +1,51 @@
1
+ import { AppError } from './app-error.ts'
2
+ import type { FailureClassification } from './failure-classification.ts'
3
+
4
+ /** Why the provider rejected a send. The reason owns the classification, so the two never disagree. */
5
+ export type EmailSendFailureReason =
6
+ | 'rate_limited'
7
+ | 'idempotency_conflict'
8
+ | 'provider_error'
9
+ | 'quota_exceeded'
10
+ | 'invalid_request'
11
+ | 'configuration'
12
+
13
+ const CLASSIFICATION_BY_REASON = {
14
+ rate_limited: 'transient',
15
+ idempotency_conflict: 'transient',
16
+ provider_error: 'transient',
17
+ quota_exceeded: 'terminal',
18
+ invalid_request: 'terminal',
19
+ configuration: 'terminal',
20
+ } satisfies Record<EmailSendFailureReason, FailureClassification>
21
+
22
+ /**
23
+ * The provider rejected a send. The boundary that awaited the send logs once; the adapter never does.
24
+ * `retryAfter` is the server's delay in seconds when it sent one, so `retryAfterSecondsOf` reads it.
25
+ */
26
+ export class EmailServiceError extends AppError {
27
+ readonly _tag = 'EmailServiceError'
28
+ readonly classification: FailureClassification
29
+ readonly reason: EmailSendFailureReason
30
+ readonly provider: string
31
+ readonly providerCode: string
32
+ readonly statusCode: number | null
33
+ readonly retryAfter: number | null
34
+
35
+ constructor(args: {
36
+ readonly reason: EmailSendFailureReason
37
+ readonly provider: string
38
+ readonly providerCode: string
39
+ readonly statusCode: number | null
40
+ readonly retryAfter: number | null
41
+ readonly cause: unknown
42
+ }) {
43
+ super(`Email send failed: ${args.provider} ${args.providerCode}`, { cause: args.cause })
44
+ this.reason = args.reason
45
+ this.classification = CLASSIFICATION_BY_REASON[args.reason]
46
+ this.provider = args.provider
47
+ this.providerCode = args.providerCode
48
+ this.statusCode = args.statusCode
49
+ this.retryAfter = args.retryAfter
50
+ }
51
+ }
package/src/core/index.ts CHANGED
@@ -1,4 +1,22 @@
1
1
  export { AppError } from './app-error.ts'
2
+ export { EmailAddressSchema, type EmailAddress } from './email-address.ts'
3
+ export {
4
+ emailIdempotencyKey,
5
+ InvalidEmailIdempotencyKeyError,
6
+ type EmailIdempotencyKey,
7
+ } from './email-idempotency-key.ts'
8
+ export { EmailServiceError, type EmailSendFailureReason } from './email-service.error.ts'
9
+ export {
10
+ BounceTypeSchema,
11
+ parseBounceType,
12
+ shouldUpdateBounce,
13
+ isEmailDeliverable,
14
+ type BounceType,
15
+ } from './email-deliverability.ts'
16
+ export {
17
+ RecordEmailEventPayloadSchema,
18
+ type RecordEmailEventPayload,
19
+ } from './record-email-event.ts'
2
20
  export { AuthenticationError } from './authentication.error.ts'
3
21
  export { RateLimitError } from './rate-limit.error.ts'
4
22
  export { StaleSessionError } from './stale-session.error.ts'
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod'
2
+
3
+ import { EmailAddressSchema } from './email-address.ts'
4
+ import { BounceTypeSchema } from './email-deliverability.ts'
5
+
6
+ /**
7
+ * The payload of the command an application registers to record a bounce or a complaint.
8
+ * Register it under a name of the application's choice in `defineMessages`; `recordEmailEvent` handles it.
9
+ */
10
+ export const RecordEmailEventPayloadSchema = z.object({
11
+ recipients: z.array(EmailAddressSchema).min(1),
12
+ event: z.discriminatedUnion('type', [
13
+ z.object({
14
+ type: z.literal('bounced'),
15
+ bounceType: BounceTypeSchema,
16
+ bounceSubType: z.string().nullable(),
17
+ }),
18
+ z.object({ type: z.literal('complained') }),
19
+ ]),
20
+ })
21
+
22
+ /** A parsed payload. */
23
+ export type RecordEmailEventPayload = z.infer<typeof RecordEmailEventPayloadSchema>
@@ -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
+ }