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.
- package/dist/{application-DYfcAy-Q.js → application-CIxo_BpK.js} +32 -3
- package/dist/application-CIxo_BpK.js.map +1 -0
- package/dist/application.d.ts +3 -3
- package/dist/application.js +2 -2
- package/dist/better-auth/apple-client-secret.d.ts +11 -4
- package/dist/better-auth/apple-client-secret.d.ts.map +1 -1
- package/dist/better-auth/apple-client-secret.js +39 -11
- package/dist/better-auth/apple-client-secret.js.map +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +21 -2
- package/dist/better-auth/rate-limit.d.ts.map +1 -1
- package/dist/better-auth/rate-limit.js +38 -2
- package/dist/better-auth/rate-limit.js.map +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/queue.d.ts +1 -1
- package/dist/cloudflare/queue.js +2 -2
- package/dist/cloudflare/scheduled.d.ts +36 -0
- package/dist/cloudflare/scheduled.d.ts.map +1 -0
- package/dist/cloudflare/scheduled.js +37 -0
- package/dist/cloudflare/scheduled.js.map +1 -0
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{core-DC_4pOrI.js → core-DG_cC8YT.js} +110 -2
- package/dist/{core-DC_4pOrI.js.map → core-DG_cC8YT.js.map} +1 -1
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +19 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +35 -0
- package/dist/http.js.map +1 -0
- package/dist/{index-6XMPJu7u.d.ts → index-Bl0K1RQW.d.ts} +90 -2
- package/dist/index-Bl0K1RQW.d.ts.map +1 -0
- package/dist/{index-_Cihfq1J.d.ts → index-DgDyeR5s.d.ts} +135 -55
- package/dist/index-DgDyeR5s.d.ts.map +1 -0
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/resend.d.ts +73 -0
- package/dist/resend.d.ts.map +1 -0
- package/dist/resend.js +284 -0
- package/dist/resend.js.map +1 -0
- package/dist/tanstack/server.d.ts +8 -0
- package/dist/tanstack/server.d.ts.map +1 -0
- package/dist/tanstack/server.js +16 -0
- package/dist/tanstack/server.js.map +1 -0
- package/dist/tanstack.d.ts +18 -14
- package/dist/tanstack.d.ts.map +1 -1
- package/dist/tanstack.js +17 -23
- package/dist/tanstack.js.map +1 -1
- package/dist/{unit-of-work-context-BFCmU92Z.js → unit-of-work-context-BPE1In2f.js} +2 -2
- package/dist/{unit-of-work-context-BFCmU92Z.js.map → unit-of-work-context-BPE1In2f.js.map} +1 -1
- package/dist/{unit-of-work-context-BWTJfYPW.d.ts → unit-of-work-context-BzmnjlDa.d.ts} +3 -3
- package/dist/{unit-of-work-context-BWTJfYPW.d.ts.map → unit-of-work-context-BzmnjlDa.d.ts.map} +1 -1
- package/dist/webhook-verification-BQ9qZnVK.js +18 -0
- package/dist/webhook-verification-BQ9qZnVK.js.map +1 -0
- package/dist/webhook-verification-sZtwTI5n.d.ts +29 -0
- package/dist/webhook-verification-sZtwTI5n.d.ts.map +1 -0
- package/package.json +11 -6
- package/src/application/email-service.ts +55 -0
- package/src/application/index.ts +16 -0
- package/src/application/record-email-event.ts +53 -0
- package/src/core/email-address.ts +7 -0
- package/src/core/email-deliverability.ts +31 -0
- package/src/core/email-idempotency-key.ts +34 -0
- package/src/core/email-service.error.ts +51 -0
- package/src/core/index.ts +18 -0
- package/src/core/record-email-event.ts +23 -0
- package/src/infrastructure/better-auth/apple-client-secret.ts +41 -14
- package/src/infrastructure/better-auth/rate-limit.ts +44 -1
- package/src/infrastructure/cloudflare/scheduled/index.ts +8 -0
- package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +62 -0
- package/src/infrastructure/http/index.ts +6 -0
- package/src/infrastructure/http/webhook-handler.ts +35 -0
- package/src/infrastructure/http/webhook-verification.ts +27 -0
- package/src/infrastructure/resend/email-event.ts +142 -0
- package/src/infrastructure/resend/index.ts +3 -0
- package/src/infrastructure/resend/resend-email-service.ts +125 -0
- package/src/infrastructure/resend/resend-webhook.ts +70 -0
- package/src/infrastructure/tanstack/auth.middleware.ts +14 -6
- package/src/infrastructure/tanstack/error.middleware.ts +7 -8
- package/src/infrastructure/tanstack/index.ts +0 -1
- package/src/infrastructure/tanstack/request-context.ts +11 -0
- package/src/infrastructure/tanstack/server.ts +7 -0
- package/src/infrastructure/tanstack/session-cookies.ts +6 -4
- package/dist/application-DYfcAy-Q.js.map +0 -1
- package/dist/index-6XMPJu7u.d.ts.map +0 -1
- package/dist/index-_Cihfq1J.d.ts.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import "./index-
|
|
2
|
-
import {
|
|
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-
|
|
16
|
+
//# sourceMappingURL=unit-of-work-context-BzmnjlDa.d.ts.map
|
package/dist/{unit-of-work-context-BWTJfYPW.d.ts.map → unit-of-work-context-BzmnjlDa.d.ts.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unit-of-work-context-
|
|
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.
|
|
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
|
-
"
|
|
75
|
+
"resend": "^6.28.0",
|
|
71
76
|
"stripe": "^22.6.2"
|
|
72
77
|
},
|
|
73
78
|
"peerDependenciesMeta": {
|
|
74
|
-
"
|
|
79
|
+
"resend": {
|
|
75
80
|
"optional": true
|
|
76
81
|
},
|
|
77
|
-
"stripe": {
|
|
82
|
+
"@better-auth/stripe": {
|
|
78
83
|
"optional": true
|
|
79
84
|
},
|
|
80
|
-
"
|
|
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
|
+
}
|
package/src/application/index.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
|
|
2
|
+
export {
|
|
3
|
+
RecordingEmailService,
|
|
4
|
+
type IEmailService,
|
|
5
|
+
type OutboundEmail,
|
|
6
|
+
type EmailAttachment,
|
|
7
|
+
type MimeType,
|
|
8
|
+
type EmailAccepted,
|
|
9
|
+
} from './email-service.ts'
|
|
10
|
+
export {
|
|
11
|
+
recordEmailEvent,
|
|
12
|
+
type EmailBounceRecord,
|
|
13
|
+
type IEmailDeliverabilityStore,
|
|
14
|
+
type RecordEmailEventRepos,
|
|
15
|
+
type RecordEmailEventDeps,
|
|
16
|
+
type RecordEmailEventCommand,
|
|
17
|
+
} from './record-email-event.ts'
|
|
2
18
|
export type { IMessageStore } from './message-store.ts'
|
|
3
19
|
export type { DomainCommand, DomainEvent, DomainMessage, DomainQuery, MessageId } from '@/core'
|
|
4
20
|
export { MessageBus, type IMessageBus } from './message-bus.ts'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { BounceType, DomainCommand, EmailAddress, RecordEmailEventPayload } from '@/core'
|
|
2
|
+
|
|
3
|
+
import type { IUnitOfWork } from './unit-of-work.ts'
|
|
4
|
+
|
|
5
|
+
/** What a bounce records for one address. */
|
|
6
|
+
export type EmailBounceRecord = {
|
|
7
|
+
readonly type: BounceType
|
|
8
|
+
readonly subType: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The application's deliverability state per address. An implementation keeps the higher bounce
|
|
13
|
+
* severity with `shouldUpdateBounce`, and treats an unknown address as nothing to record.
|
|
14
|
+
*/
|
|
15
|
+
export interface IEmailDeliverabilityStore {
|
|
16
|
+
/** Keep the higher severity atomically. Equal severity permits replacement; the store owns timestamp ordering. */
|
|
17
|
+
recordBounce(address: EmailAddress, bounce: EmailBounceRecord): Promise<void>
|
|
18
|
+
/** Record a complaint without clearing existing suppression state. */
|
|
19
|
+
recordComplaint(address: EmailAddress): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The repositories the handler needs from the unit of work. */
|
|
23
|
+
export type RecordEmailEventRepos = { readonly emailDeliverability: IEmailDeliverabilityStore }
|
|
24
|
+
|
|
25
|
+
/** The container slice the handler reads. */
|
|
26
|
+
export type RecordEmailEventDeps = { readonly uow: IUnitOfWork<RecordEmailEventRepos> }
|
|
27
|
+
|
|
28
|
+
/** The command, under whatever name the application registered the payload. */
|
|
29
|
+
export type RecordEmailEventCommand = DomainCommand & RecordEmailEventPayload
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record a bounce or a complaint for every recipient inside the command's unit of work.
|
|
33
|
+
* The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.
|
|
34
|
+
*/
|
|
35
|
+
export async function recordEmailEvent(
|
|
36
|
+
command: RecordEmailEventCommand,
|
|
37
|
+
deps: RecordEmailEventDeps,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
await deps.uow.run(command.id, async ({ emailDeliverability }) => {
|
|
40
|
+
for (const address of command.recipients) {
|
|
41
|
+
if (command.event.type === 'bounced') {
|
|
42
|
+
// oxlint-disable-next-line no-await-in-loop -- Finish each transaction write before starting another; a failure must leave no pending writes.
|
|
43
|
+
await emailDeliverability.recordBounce(address, {
|
|
44
|
+
type: command.event.bounceType,
|
|
45
|
+
subType: command.event.bounceSubType,
|
|
46
|
+
})
|
|
47
|
+
} else {
|
|
48
|
+
// oxlint-disable-next-line no-await-in-loop -- A rejected write must stop the transaction before another write starts.
|
|
49
|
+
await emailDeliverability.recordComplaint(address)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** A parsed email address for message payloads and webhook recipients. Ported from Typist's `EmailSchema`. */
|
|
4
|
+
export const EmailAddressSchema = z.email().brand<'EmailAddress'>()
|
|
5
|
+
|
|
6
|
+
/** An address the schema accepted. */
|
|
7
|
+
export type EmailAddress = z.infer<typeof EmailAddressSchema>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** The bounce classes Resend reports, ported from Typist's comm-eligibility.ts. */
|
|
4
|
+
export const BounceTypeSchema = z.enum(['Permanent', 'Transient', 'Undetermined'])
|
|
5
|
+
|
|
6
|
+
/** One bounce class. */
|
|
7
|
+
export type BounceType = z.infer<typeof BounceTypeSchema>
|
|
8
|
+
|
|
9
|
+
/** Parse a provider's bounce type. An unknown value is `Undetermined`, which suppresses. */
|
|
10
|
+
export function parseBounceType(value: string | undefined): BounceType {
|
|
11
|
+
const parsed = BounceTypeSchema.safeParse(value)
|
|
12
|
+
return parsed.success ? parsed.data : 'Undetermined'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BOUNCE_SEVERITY = {
|
|
16
|
+
Transient: 1,
|
|
17
|
+
Undetermined: 2,
|
|
18
|
+
Permanent: 3,
|
|
19
|
+
} satisfies Record<BounceType, number>
|
|
20
|
+
|
|
21
|
+
/** Keep the higher severity. Equal severity permits replacement; callers own timestamp ordering. */
|
|
22
|
+
export function shouldUpdateBounce(incoming: BounceType, current: BounceType | null): boolean {
|
|
23
|
+
if (current === null) return true
|
|
24
|
+
return BOUNCE_SEVERITY[incoming] >= BOUNCE_SEVERITY[current]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Typist's send policy: no complaint, and either no bounce or a transient one. Delivery is not guaranteed. */
|
|
28
|
+
export function isEmailDeliverable(bounceType: BounceType | null, complained: boolean): boolean {
|
|
29
|
+
if (complained) return false
|
|
30
|
+
return bounceType === null || bounceType === 'Transient'
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { AppError } from './app-error.ts'
|
|
4
|
+
|
|
5
|
+
/** A key only `emailIdempotencyKey` mints, so every send carries one built by its rules. */
|
|
6
|
+
export type EmailIdempotencyKey = string & z.core.$brand<'EmailIdempotencyKey'>
|
|
7
|
+
|
|
8
|
+
// Resend accepts 1 to 256 characters. Source: https://resend.com/docs/dashboard/emails/idempotency-keys
|
|
9
|
+
const MAX_KEY_LENGTH = 256
|
|
10
|
+
|
|
11
|
+
/** A key outside 1 to 256 characters. Keys come from stable ids, so this is a defect, not a retry. */
|
|
12
|
+
export class InvalidEmailIdempotencyKeyError extends AppError {
|
|
13
|
+
readonly _tag = 'InvalidEmailIdempotencyKeyError'
|
|
14
|
+
readonly classification = 'terminal'
|
|
15
|
+
|
|
16
|
+
constructor(readonly length: number) {
|
|
17
|
+
super(`Email idempotency key must have 1 to ${MAX_KEY_LENGTH} characters`)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `<scope>/<id>` for one logical send. Resend deduplicates it for 24 hours.
|
|
23
|
+
* Derive `id` from a stable identity: a message id, a job id, a user id with a template name.
|
|
24
|
+
* The same key must map to an identical payload; a changed body is rejected as terminal.
|
|
25
|
+
* A random `id` means every request is a deliberate new send, as a sign-in code resend is.
|
|
26
|
+
* Source: https://resend.com/docs/dashboard/emails/idempotency-keys
|
|
27
|
+
*/
|
|
28
|
+
export function emailIdempotencyKey(scope: string, id: string): EmailIdempotencyKey {
|
|
29
|
+
const key = `${scope}/${id}`
|
|
30
|
+
if (scope.length === 0 || id.length === 0 || key.length > MAX_KEY_LENGTH)
|
|
31
|
+
throw new InvalidEmailIdempotencyKeyError(key.length)
|
|
32
|
+
// SAFETY: the brand is a compile-time mark; the checks above are the whole rule.
|
|
33
|
+
return key as EmailIdempotencyKey
|
|
34
|
+
}
|
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,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
|
+
}
|