feat(identity): add resend otp delivery adapter
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import type {
|
||||
EmailOtpMessage,
|
||||
EmailOtpSender,
|
||||
} from "../contracts.ts";
|
||||
|
||||
export class FakeEmailOtpSender implements EmailOtpSender {
|
||||
readonly messages: EmailOtpMessage[] = [];
|
||||
|
||||
async send(message: EmailOtpMessage): Promise<void> {
|
||||
this.messages.push({ ...message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
EmailOtpMessage,
|
||||
EmailOtpSender,
|
||||
EmailOtpType,
|
||||
} from "../contracts.ts";
|
||||
|
||||
const resendEndpoint = "https://api.resend.com/emails";
|
||||
const safeDeliveryError = "OTP email delivery failed";
|
||||
|
||||
const subjectByType: Record<EmailOtpType, string> = {
|
||||
"sign-in": "Your Jyotisha sign-in code",
|
||||
"email-verification": "Verify your Jyotisha email",
|
||||
"forget-password": "Reset your Jyotisha password",
|
||||
};
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => {
|
||||
const entities: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return entities[character];
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResendEmailOtpSenderOptions {
|
||||
apiKey: string;
|
||||
from: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class ResendEmailOtpSender implements EmailOtpSender {
|
||||
private readonly apiKey: string;
|
||||
private readonly from: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(options: ResendEmailOtpSenderOptions) {
|
||||
this.apiKey = options.apiKey;
|
||||
this.from = options.from;
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
async send(message: EmailOtpMessage): Promise<void> {
|
||||
const escapedOtp = escapeHtml(message.otp);
|
||||
const subject = subjectByType[message.type];
|
||||
|
||||
try {
|
||||
const response = await this.fetchImpl(resendEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": message.idempotencyKey,
|
||||
"User-Agent": "jyotisha-identity/1.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: this.from,
|
||||
to: [message.email],
|
||||
subject,
|
||||
text: `${subject}: ${message.otp}. This code expires in five minutes.`,
|
||||
html: `<p>${escapeHtml(subject)}</p><p><strong>${escapedOtp}</strong></p><p>This code expires in five minutes.</p>`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(safeDeliveryError);
|
||||
} catch {
|
||||
throw new Error(safeDeliveryError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
|
||||
import { ResendEmailOtpSender } from "../src/modules/identity/email/resend-email-otp-sender.ts";
|
||||
import type { EmailOtpMessage } from "../src/modules/identity/contracts.ts";
|
||||
|
||||
const message: EmailOtpMessage = {
|
||||
email: "person@example.com",
|
||||
otp: "123456",
|
||||
type: "sign-in",
|
||||
idempotencyKey: "otp-request-018f4e6d",
|
||||
};
|
||||
|
||||
test("fake OTP sender records messages without network access", async () => {
|
||||
const sender = new FakeEmailOtpSender();
|
||||
|
||||
await sender.send(message);
|
||||
|
||||
assert.deepEqual(sender.messages, [message]);
|
||||
assert.notEqual(sender.messages[0], message);
|
||||
});
|
||||
|
||||
test("Resend OTP sender emits an idempotent authenticated request", async () => {
|
||||
const requests: Array<{ input: string | URL | Request; init?: RequestInit }> = [];
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
requests.push({ input, init });
|
||||
return Response.json({ id: "email_123" }, { status: 200 });
|
||||
};
|
||||
const sender = new ResendEmailOtpSender({
|
||||
apiKey: "re_test_secret_value",
|
||||
from: "Jyotisha <login@staging.jyotisha.chat>",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await sender.send(message);
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].input, "https://api.resend.com/emails");
|
||||
assert.equal(requests[0].init?.method, "POST");
|
||||
const headers = new Headers(requests[0].init?.headers);
|
||||
assert.equal(headers.get("authorization"), "Bearer re_test_secret_value");
|
||||
assert.equal(headers.get("content-type"), "application/json");
|
||||
assert.equal(headers.get("idempotency-key"), message.idempotencyKey);
|
||||
assert.equal(headers.get("user-agent"), "jyotisha-identity/1.0");
|
||||
|
||||
const body = JSON.parse(String(requests[0].init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(body.from, "Jyotisha <login@staging.jyotisha.chat>");
|
||||
assert.deepEqual(body.to, [message.email]);
|
||||
assert.equal(body.subject, "Your Jyotisha sign-in code");
|
||||
assert.match(String(body.text), /123456/);
|
||||
assert.match(String(body.html), /123456/);
|
||||
});
|
||||
|
||||
test("Resend OTP sender escapes template values", async () => {
|
||||
let body = "";
|
||||
const sender = new ResendEmailOtpSender({
|
||||
apiKey: "re_test_secret_value",
|
||||
from: "Jyotisha <login@staging.jyotisha.chat>",
|
||||
fetchImpl: async (_input, init) => {
|
||||
body = String(init?.body);
|
||||
return Response.json({ id: "email_123" });
|
||||
},
|
||||
});
|
||||
|
||||
await sender.send({ ...message, otp: "<script>alert(1)</script>" });
|
||||
|
||||
const parsed = JSON.parse(body) as { html: string };
|
||||
assert.doesNotMatch(parsed.html, /<script>/);
|
||||
assert.match(parsed.html, /<script>/);
|
||||
});
|
||||
|
||||
test("Resend errors exclude secrets, OTPs, recipients, and provider bodies", async () => {
|
||||
const apiKey = "re_secret_should_not_leak";
|
||||
const rawProviderError = "provider diagnostic should not leak";
|
||||
const sender = new ResendEmailOtpSender({
|
||||
apiKey,
|
||||
from: "Jyotisha <login@staging.jyotisha.chat>",
|
||||
fetchImpl: async () =>
|
||||
new Response(rawProviderError, {
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(sender.send(message), (error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal(error.message, "OTP email delivery failed");
|
||||
assert.doesNotMatch(error.message, new RegExp(apiKey));
|
||||
assert.doesNotMatch(error.message, new RegExp(message.otp));
|
||||
assert.doesNotMatch(error.message, new RegExp(message.email));
|
||||
assert.doesNotMatch(error.message, new RegExp(rawProviderError));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("network errors use the same safe delivery error", async () => {
|
||||
const sender = new ResendEmailOtpSender({
|
||||
apiKey: "re_test_secret_value",
|
||||
from: "Jyotisha <login@staging.jyotisha.chat>",
|
||||
fetchImpl: async () => {
|
||||
throw new Error("socket includes recipient person@example.com");
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
sender.send(message),
|
||||
new Error("OTP email delivery failed"),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user