@3lineas/grooveskills 1.4.0 → 1.6.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.
@@ -0,0 +1,198 @@
1
+ # Implementation Reference: Email & Payment Adapters
2
+
3
+ Reference implementations illustrating the Wrapper + Adapter pattern across email providers and payment gateways with strict typing and guard clauses.
4
+
5
+ ---
6
+
7
+ ## 1. Transactional Email System
8
+
9
+ ### 1.1 Pure Domain Port (`src/domains/email/email-sender.port.ts`)
10
+ ```ts
11
+ export interface EmailRecipient {
12
+ email: string;
13
+ name?: string;
14
+ }
15
+
16
+ export interface SendEmailInput {
17
+ to: EmailRecipient[];
18
+ subject: string;
19
+ html: string;
20
+ text?: string;
21
+ from?: EmailRecipient;
22
+ replyTo?: string;
23
+ }
24
+
25
+ export interface SendEmailResult {
26
+ messageId: string;
27
+ provider: "smtp" | "resend" | "aws-ses" | "cloudflare";
28
+ sentAt: Date;
29
+ }
30
+
31
+ export interface EmailSenderPort {
32
+ send(input: SendEmailInput): Promise<SendEmailResult>;
33
+ }
34
+ ```
35
+
36
+ ### 1.2 SMTP Adapter (`nodemailer`)
37
+ ```ts
38
+ // src/infrastructure/adapters/email/smtp-email.adapter.ts
39
+ import type { EmailSenderPort, SendEmailInput, SendEmailResult } from "@/domains/email/email-sender.port";
40
+ import type { SmtpWrapper } from "@/infrastructure/wrappers/smtp/smtp.wrapper";
41
+ import { AppError } from "@/errors/app-error";
42
+
43
+ export class SmtpEmailAdapter implements EmailSenderPort {
44
+ constructor(private wrapper: SmtpWrapper, private defaultSender: string) {}
45
+
46
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
47
+ try {
48
+ const from = input.from ? `${input.from.name ?? ""} <${input.from.email}>`.trim() : this.defaultSender;
49
+ const info = await this.wrapper.sendMail({
50
+ from,
51
+ to: input.to.map((r) => r.email).join(", "),
52
+ subject: input.subject,
53
+ html: input.html,
54
+ text: input.text,
55
+ });
56
+ return { messageId: info.messageId, provider: "smtp", sentAt: new Date() };
57
+ } catch (err) {
58
+ throw new AppError({ message: "Fallo al enviar correo SMTP.", statusCode: 502, cause: err });
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### 1.3 AWS SES Adapter (`@aws-sdk/client-ses`)
65
+ ```ts
66
+ // src/infrastructure/adapters/email/ses-email.adapter.ts
67
+ import type { EmailSenderPort, SendEmailInput, SendEmailResult } from "@/domains/email/email-sender.port";
68
+ import type { SesWrapper } from "@/infrastructure/wrappers/aws/ses.wrapper";
69
+ import { AppError } from "@/errors/app-error";
70
+
71
+ export class SesEmailAdapter implements EmailSenderPort {
72
+ constructor(private wrapper: SesWrapper, private defaultSource: string) {}
73
+
74
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
75
+ const res = await this.wrapper.send({
76
+ Source: input.from?.email ?? this.defaultSource,
77
+ Destination: { ToAddresses: input.to.map((t) => t.email) },
78
+ Message: {
79
+ Subject: { Data: input.subject, Charset: "UTF-8" },
80
+ Body: { Html: { Data: input.html, Charset: "UTF-8" } },
81
+ },
82
+ });
83
+
84
+ if (!res.MessageId) {
85
+ throw new AppError({ message: "Fallo al enviar correo con AWS SES.", statusCode: 502 });
86
+ }
87
+ return { messageId: res.MessageId, provider: "aws-ses", sentAt: new Date() };
88
+ }
89
+ }
90
+ ```
91
+
92
+ ### 1.4 Cloudflare Email Routing Adapter
93
+ ```ts
94
+ // src/infrastructure/adapters/email/cloudflare-email.adapter.ts
95
+ import type { EmailSenderPort, SendEmailInput, SendEmailResult } from "@/domains/email/email-sender.port";
96
+ import type { CloudflareEmailWrapper } from "@/infrastructure/wrappers/cloudflare/cloudflare-email.wrapper";
97
+ import { AppError } from "@/errors/app-error";
98
+
99
+ export class CloudflareEmailAdapter implements EmailSenderPort {
100
+ constructor(private wrapper: CloudflareEmailWrapper, private defaultSender: string) {}
101
+
102
+ async send(input: SendEmailInput): Promise<SendEmailResult> {
103
+ const res = await this.wrapper.postEmail({
104
+ from: input.from?.email ?? this.defaultSender,
105
+ to: input.to.map((t) => t.email),
106
+ subject: input.subject,
107
+ content: input.html,
108
+ });
109
+ if (!res.ok) {
110
+ throw new AppError({ message: "Error al enviar correo con Cloudflare Email.", statusCode: 502 });
111
+ }
112
+ const data = await res.json() as { result?: { id?: string } };
113
+ return { messageId: data.result?.id ?? `cf_${Date.now()}`, provider: "cloudflare", sentAt: new Date() };
114
+ }
115
+ }
116
+ ```
117
+
118
+ ---
119
+
120
+ ## 2. Payment Gateways (Stripe & MercadoPago)
121
+
122
+ ### 2.1 Domain Port (`src/domains/billing/payment-gateway.port.ts`)
123
+ ```ts
124
+ export interface ChargeInput {
125
+ amountInCents: number;
126
+ currency: "usd" | "clp" | "mxn" | "eur";
127
+ customerId: string;
128
+ sourceToken?: string;
129
+ idempotencyKey: string;
130
+ }
131
+
132
+ export interface ChargeResult {
133
+ transactionId: string;
134
+ status: "succeeded" | "pending" | "failed";
135
+ provider: "stripe" | "mercadopago";
136
+ }
137
+
138
+ export interface PaymentGatewayPort {
139
+ createCharge(input: ChargeInput): Promise<ChargeResult>;
140
+ }
141
+ ```
142
+
143
+ ### 2.2 Stripe & MercadoPago Adapters
144
+ ```ts
145
+ // src/infrastructure/adapters/payment/stripe-payment.adapter.ts
146
+ import type { PaymentGatewayPort, ChargeInput, ChargeResult } from "@/domains/billing/payment-gateway.port";
147
+ import type Stripe from "stripe";
148
+ import { AppError } from "@/errors/app-error";
149
+
150
+ export class StripePaymentAdapter implements PaymentGatewayPort {
151
+ constructor(private stripe: Stripe) {}
152
+
153
+ async createCharge(input: ChargeInput): Promise<ChargeResult> {
154
+ try {
155
+ const intent = await this.stripe.paymentIntents.create(
156
+ { amount: input.amountInCents, currency: input.currency, customer: input.customerId, confirm: true },
157
+ { idempotencyKey: input.idempotencyKey }
158
+ );
159
+ return {
160
+ transactionId: intent.id,
161
+ status: intent.status === "succeeded" ? "succeeded" : "pending",
162
+ provider: "stripe",
163
+ };
164
+ } catch (err) {
165
+ throw new AppError({ message: "Fallo en Stripe.", statusCode: 402, cause: err });
166
+ }
167
+ }
168
+ }
169
+ ```
170
+ ```ts
171
+ // src/infrastructure/adapters/payment/mercadopago-payment.adapter.ts
172
+ import type { PaymentGatewayPort, ChargeInput, ChargeResult } from "@/domains/billing/payment-gateway.port";
173
+ import { Payment, type MercadoPagoConfig } from "mercadopago";
174
+ import { AppError } from "@/errors/app-error";
175
+
176
+ export class MercadoPagoPaymentAdapter implements PaymentGatewayPort {
177
+ private client: Payment;
178
+ constructor(cfg: MercadoPagoConfig) {
179
+ this.client = new Payment(cfg);
180
+ }
181
+
182
+ async createCharge(input: ChargeInput): Promise<ChargeResult> {
183
+ try {
184
+ const res = await this.client.create({
185
+ body: { transaction_amount: input.amountInCents / 100, token: input.sourceToken, payer: { id: input.customerId } },
186
+ requestOptions: { idempotencyKey: input.idempotencyKey },
187
+ });
188
+ return {
189
+ transactionId: String(res.id),
190
+ status: res.status === "approved" ? "succeeded" : "pending",
191
+ provider: "mercadopago",
192
+ };
193
+ } catch (err) {
194
+ throw new AppError({ message: "Fallo en MercadoPago.", statusCode: 402, cause: err });
195
+ }
196
+ }
197
+ }
198
+ ```
@@ -39,6 +39,7 @@ Every request is governed by the 7-step ODD protocol (*Authorize, Explore, Resol
39
39
 
40
40
  - **`.grooveskills/memory/context.md`**: Stores project stack, architectural patterns, conventions, and key commands. Read this when starting work on a domain; update it when new dependencies or architectural standards are introduced.
41
41
  - **`.grooveskills/memory/decisions.md`**: An append-only Architecture Decision Record (ADR). Log significant technical decisions (`Date`, `Context`, `Decision`, `Consequences`).
42
+ - **`.grooveskills/memory/tasks/00-onboarding.md`**: Initial onboarding task. If `status: pending`, inspect project structure, commands, and verify `.grooveskills/memory/context.md`, then mark as `completed`.
42
43
  - **`.grooveskills/memory/tasks/`**: Individual feature records holding outcome hypotheses, checklists, and observed proof.
43
44
 
44
45
  ---
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: storage-file-adapters
3
+ description: Standardized wrapper & adapter architecture for object storage, uploads, presigned URLs, and streaming (AWS S3, Cloudflare R2, Supabase Storage, In-Memory). Discards repository abstractions in favor of direct ports and isolated vendor clients. TRIGGER when implementing file uploads, asset storage, cloud buckets, multipart uploads, or presigned download/upload URLs.
4
+ ---
5
+
6
+ # Object Storage & File Adapters (Wrappers + Ports)
7
+
8
+ Opinionated architectural pattern for object storage and file management (AWS S3, Cloudflare R2, Supabase Storage, In-Memory) utilizing the **Wrapper + Adapter** pattern instead of generic repositories.
9
+
10
+ ---
11
+
12
+ ## 1. Core Principles
13
+
14
+ 1. **Storage is an I/O Port, Not a Repository**:
15
+ - Files are blobs with metadata and URLs, not relational entities. We define `StoragePort`, not `FileRepository`.
16
+ 2. **Standardized Domain Payload**:
17
+ - Operations consume and return pure domain contracts (`PutFileInput`, `PutFileOutput`, `PresignedUrlInput`).
18
+ - Vendor response shapes (S3 `PutObjectCommandOutput`, Supabase `StorageError`) never escape the adapter boundary.
19
+ 3. **Vendor Wrappers Isolate Credentials & SDK Quirks**:
20
+ - SDK clients, endpoint URLs, region configs, and multipart batching logic remain strictly inside `src/infrastructure/wrappers/storage/`.
21
+ 4. **Zero-Change Provider Portability**:
22
+ - Switching between AWS S3, Cloudflare R2, Supabase Storage, or an In-Memory mock during local testing requires swapping only the adapter instance.
23
+
24
+ ---
25
+
26
+ ## 2. Directory Layout
27
+
28
+ ```text
29
+ src/
30
+ ├── actions/
31
+ │ └── media/
32
+ │ └── upload-avatar.action.ts # Consumes StoragePort (<80 lines)
33
+ ├── domains/
34
+ │ └── storage/
35
+ │ ├── storage.port.ts # Domain interface contract (<50 lines)
36
+ │ └── storage.types.ts # Pure domain payloads (<50 lines)
37
+ └── infrastructure/
38
+ ├── adapters/
39
+ │ └── storage/
40
+ │ ├── s3-storage.adapter.ts # AWS S3 implementation (<90 lines)
41
+ │ ├── r2-storage.adapter.ts # Cloudflare R2 implementation (<90 lines)
42
+ │ ├── supabase-storage.adapter.ts # Supabase Storage implementation (<90 lines)
43
+ │ └── memory-storage.adapter.ts # In-memory testing adapter (<70 lines)
44
+ └── wrappers/
45
+ └── storage/
46
+ ├── s3-client.wrapper.ts # AWS SDK client wrapper (<70 lines)
47
+ ├── r2-client.wrapper.ts # Cloudflare S3-compatible client (<70 lines)
48
+ └── supabase-storage.wrapper.ts # Supabase storage client (<60 lines)
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 3. Implementation Contract (`src/domains/storage/storage.port.ts`)
54
+
55
+ ```ts
56
+ export interface StorageFile {
57
+ buffer: Uint8Array | Buffer;
58
+ contentType: string;
59
+ sizeBytes: number;
60
+ }
61
+
62
+ export interface PutFileInput {
63
+ bucket: string;
64
+ key: string;
65
+ file: StorageFile;
66
+ isPublic?: boolean;
67
+ }
68
+
69
+ export interface PutFileOutput {
70
+ key: string;
71
+ bucket: string;
72
+ publicUrl?: string;
73
+ etag?: string;
74
+ }
75
+
76
+ export interface PresignedUrlInput {
77
+ bucket: string;
78
+ key: string;
79
+ operation: "getObject" | "putObject";
80
+ expiresInSeconds: number;
81
+ }
82
+
83
+ export interface StoragePort {
84
+ putFile(input: PutFileInput): Promise<PutFileOutput>;
85
+ getFile(bucket: string, key: string): Promise<StorageFile>;
86
+ deleteFile(bucket: string, key: string): Promise<void>;
87
+ createPresignedUrl(input: PresignedUrlInput): Promise<string>;
88
+ }
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 4. Usage in Business Actions
94
+
95
+ ```ts
96
+ import type { StoragePort } from "@/domains/storage/storage.port";
97
+ import { AppError } from "@/errors/app-error";
98
+
99
+ export async function uploadAvatarAction(
100
+ storage: StoragePort,
101
+ userId: string,
102
+ rawFile: Uint8Array,
103
+ mimeType: string
104
+ ): Promise<{ avatarUrl: string }> {
105
+ if (rawFile.byteLength > 5 * 1024 * 1024) {
106
+ throw new AppError({ message: "La imagen excede el límite de 5 MB.", statusCode: 400 });
107
+ }
108
+
109
+ const result = await storage.putFile({
110
+ bucket: "avatars",
111
+ key: `users/${userId}/avatar.webp`,
112
+ file: { buffer: rawFile, contentType: mimeType, sizeBytes: rawFile.byteLength },
113
+ isPublic: true,
114
+ });
115
+
116
+ return { avatarUrl: result.publicUrl ?? result.key };
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## 5. Detailed Provider Blueprints
123
+
124
+ For complete production adapters (AWS S3, Cloudflare R2, Supabase Storage, and In-Memory testing fake), consult:
125
+ - `references/storage-adapters-examples.md`
@@ -0,0 +1,164 @@
1
+ # Implementation Reference: Object Storage & File Adapters
2
+
3
+ Reference implementations for S3, Cloudflare R2, Supabase Storage, and In-Memory testing fake under the Wrapper + Adapter architecture.
4
+
5
+ ---
6
+
7
+ ## 1. AWS S3 Adapter (`@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`)
8
+
9
+ ```ts
10
+ // src/infrastructure/adapters/storage/s3-storage.adapter.ts
11
+ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
12
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
13
+ import type { StoragePort, PutFileInput, PutFileOutput, StorageFile, PresignedUrlInput } from "@/domains/storage/storage.port";
14
+ import { AppError } from "@/errors/app-error";
15
+
16
+ export class S3StorageAdapter implements StoragePort {
17
+ constructor(private client: S3Client, private publicBaseUrl?: string) {}
18
+
19
+ async putFile(input: PutFileInput): Promise<PutFileOutput> {
20
+ try {
21
+ const res = await this.client.send(
22
+ new PutObjectCommand({
23
+ Bucket: input.bucket,
24
+ Key: input.key,
25
+ Body: input.file.buffer,
26
+ ContentType: input.file.contentType,
27
+ })
28
+ );
29
+ const publicUrl = this.publicBaseUrl ? `${this.publicBaseUrl.replace(/\/$/, "")}/${input.key}` : undefined;
30
+ return { key: input.key, bucket: input.bucket, publicUrl, etag: res.ETag };
31
+ } catch (err) {
32
+ throw new AppError({ message: "Error al subir archivo a S3.", statusCode: 502, cause: err });
33
+ }
34
+ }
35
+
36
+ async getFile(bucket: string, key: string): Promise<StorageFile> {
37
+ const res = await this.client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
38
+ const bytes = await res.Body?.transformToByteArray();
39
+ if (!bytes) {
40
+ throw new AppError({ message: "Archivo no encontrado o vacío.", statusCode: 404 });
41
+ }
42
+ return { buffer: bytes, contentType: res.ContentType ?? "application/octet-stream", sizeBytes: res.ContentLength ?? bytes.byteLength };
43
+ }
44
+
45
+ async deleteFile(bucket: string, key: string): Promise<void> {
46
+ await this.client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
47
+ }
48
+
49
+ async createPresignedUrl(input: PresignedUrlInput): Promise<string> {
50
+ const Command = input.operation === "getObject" ? GetObjectCommand : PutObjectCommand;
51
+ return getSignedUrl(this.client, new Command({ Bucket: input.bucket, Key: input.key }), { expiresIn: input.expiresInSeconds });
52
+ }
53
+ }
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 2. Cloudflare R2 Adapter (S3-Compatible)
59
+
60
+ ```ts
61
+ // src/infrastructure/adapters/storage/r2-storage.adapter.ts
62
+ import { S3Client } from "@aws-sdk/client-s3";
63
+ import { S3StorageAdapter } from "./s3-storage.adapter";
64
+
65
+ export function createR2StorageAdapter(accountId: string, accessKeyId: string, secretAccessKey: string, publicUrl?: string) {
66
+ const client = new S3Client({
67
+ region: "auto",
68
+ endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
69
+ credentials: { accessKeyId, secretAccessKey },
70
+ });
71
+ return new S3StorageAdapter(client, publicUrl);
72
+ }
73
+ ```
74
+
75
+ ---
76
+
77
+ ## 3. Supabase Storage Adapter (`@supabase/storage-js`)
78
+
79
+ ```ts
80
+ // src/infrastructure/adapters/storage/supabase-storage.adapter.ts
81
+ import type { StorageClient } from "@supabase/storage-js";
82
+ import type { StoragePort, PutFileInput, PutFileOutput, StorageFile, PresignedUrlInput } from "@/domains/storage/storage.port";
83
+ import { AppError } from "@/errors/app-error";
84
+
85
+ export class SupabaseStorageAdapter implements StoragePort {
86
+ constructor(private client: StorageClient) {}
87
+
88
+ async putFile(input: PutFileInput): Promise<PutFileOutput> {
89
+ const { data, error } = await this.client.from(input.bucket).upload(input.key, input.file.buffer, {
90
+ contentType: input.file.contentType,
91
+ upsert: true,
92
+ });
93
+ if (error || !data) {
94
+ throw new AppError({ message: "Error al almacenar archivo en Supabase.", statusCode: 502, cause: error });
95
+ }
96
+ const publicUrl = input.isPublic ? this.client.from(input.bucket).getPublicUrl(input.key).data.publicUrl : undefined;
97
+ return { key: input.key, bucket: input.bucket, publicUrl };
98
+ }
99
+
100
+ async getFile(bucket: string, key: string): Promise<StorageFile> {
101
+ const { data, error } = await this.client.from(bucket).download(key);
102
+ if (error || !data) {
103
+ throw new AppError({ message: "Archivo no encontrado.", statusCode: 404, cause: error });
104
+ }
105
+ const buffer = new Uint8Array(await data.arrayBuffer());
106
+ return { buffer, contentType: data.type || "application/octet-stream", sizeBytes: buffer.byteLength };
107
+ }
108
+
109
+ async deleteFile(bucket: string, key: string): Promise<void> {
110
+ const { error } = await this.client.from(bucket).remove([key]);
111
+ if (error) {
112
+ throw new AppError({ message: "Error al eliminar archivo en Supabase.", statusCode: 502, cause: error });
113
+ }
114
+ }
115
+
116
+ async createPresignedUrl(input: PresignedUrlInput): Promise<string> {
117
+ if (input.operation !== "getObject") {
118
+ throw new AppError({ message: "Operación de presigned URL no soportada.", statusCode: 400 });
119
+ }
120
+ const { data, error } = await this.client.from(input.bucket).createSignedUrl(input.key, input.expiresInSeconds);
121
+ if (error || !data?.signedUrl) {
122
+ throw new AppError({ message: "No se pudo generar URL firmada.", statusCode: 502, cause: error });
123
+ }
124
+ return data.signedUrl;
125
+ }
126
+ }
127
+ ```
128
+
129
+ ---
130
+
131
+ ## 4. In-Memory Storage Adapter (Testing Fake)
132
+
133
+ ```ts
134
+ // src/infrastructure/adapters/storage/memory-storage.adapter.ts
135
+ import type { StoragePort, PutFileInput, PutFileOutput, StorageFile, PresignedUrlInput } from "@/domains/storage/storage.port";
136
+ import { AppError } from "@/errors/app-error";
137
+
138
+ export class MemoryStorageAdapter implements StoragePort {
139
+ private files = new Map<string, StorageFile>();
140
+
141
+ private keyOf(bucket: string, key: string): string {
142
+ return `${bucket}:${key}`;
143
+ }
144
+
145
+ async putFile(input: PutFileInput): Promise<PutFileOutput> {
146
+ this.files.set(this.keyOf(input.bucket, input.key), input.file);
147
+ return { key: input.key, bucket: input.bucket, publicUrl: `https://mock.storage/${input.key}` };
148
+ }
149
+
150
+ async getFile(bucket: string, key: string): Promise<StorageFile> {
151
+ const file = this.files.get(this.keyOf(bucket, key));
152
+ if (!file) throw new AppError({ message: "Archivo inexistente en memoria.", statusCode: 404 });
153
+ return file;
154
+ }
155
+
156
+ async deleteFile(bucket: string, key: string): Promise<void> {
157
+ this.files.delete(this.keyOf(bucket, key));
158
+ }
159
+
160
+ async createPresignedUrl(input: PresignedUrlInput): Promise<string> {
161
+ return `https://mock.storage/presigned/${input.bucket}/${input.key}?expires=${input.expiresInSeconds}`;
162
+ }
163
+ }
164
+ ```
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: webhook-idempotency
3
+ description: Standardized architectural pattern for incoming webhook verification and idempotency guards. Decouples vendor cryptographic signature checks (Stripe, MercadoPago, Svix, GitHub) into isolated adapters, and guarantees single execution via an IdempotencyGuard wrapper backed by persistent storage. TRIGGER when building webhook endpoints, handling payment callbacks, verifying HMAC signatures, or ensuring at-most-once processing.
4
+ ---
5
+
6
+ # Incoming Webhooks, Signature Verification & Idempotency Guards
7
+
8
+ Architectural pattern for incoming webhooks: decoupled cryptographic signature verification and atomic idempotency guards to protect against duplicate delivery and replay attacks.
9
+
10
+ ---
11
+
12
+ ## 1. The 3 Invariants of Secure Webhooks
13
+
14
+ 1. **Verify Before Anything Else**:
15
+ - Webhook endpoints must verify HMAC cryptographic signatures using raw request bodies before running any business logic or parsing JSON.
16
+ 2. **Idempotency Guard**:
17
+ - Webhooks are delivered with "at-least-once" guarantees. An `IdempotencyGuard` checks if an event ID was already processed; if so, returns early (HTTP 200) without re-running actions.
18
+ 3. **Slim Webhook Controller**:
19
+ - The webhook controller verifies signatures, claims idempotency, and delegates to an action or dispatches to a queue.
20
+
21
+ ---
22
+
23
+ ## 2. Directory Layout
24
+
25
+ ```text
26
+ src/
27
+ ├── controllers/
28
+ │ └── webhooks/
29
+ │ ├── stripe-webhook.controller.ts # Verifies + Idempotency + Action (<80 lines)
30
+ │ └── mercadopago-webhook.controller.ts # Verifies + Idempotency + Action (<80 lines)
31
+ ├── domains/
32
+ │ └── webhooks/
33
+ │ ├── webhook-verifier.port.ts # Signature verification interface (<40 lines)
34
+ │ └── idempotency.port.ts # Idempotency storage interface (<40 lines)
35
+ └── infrastructure/
36
+ ├── adapters/
37
+ │ ├── webhooks/
38
+ │ │ ├── stripe-verifier.adapter.ts # Stripe signature verification (<70 lines)
39
+ │ │ ├── svix-verifier.adapter.ts # Svix HMAC verification (<70 lines)
40
+ │ │ └── mercadopago-verifier.adapter.ts # MP HMAC verification (<70 lines)
41
+ │ └── idempotency/
42
+ │ ├── redis-idempotency.adapter.ts # Redis SETNX implementation (<60 lines)
43
+ │ └── db-idempotency.adapter.ts # SQL unique constraint implementation (<60 lines)
44
+ └── guards/
45
+ └── idempotency.guard.ts # Idempotency execution wrapper (<70 lines)
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 3. Interfaces Contract (`src/domains/webhooks/`)
51
+
52
+ ```ts
53
+ // webhook-verifier.port.ts
54
+ export interface WebhookVerifyInput {
55
+ rawBody: string | Uint8Array;
56
+ signatureHeader: string;
57
+ secret: string;
58
+ }
59
+
60
+ export interface WebhookVerifierPort<TEvent = unknown> {
61
+ verify(input: WebhookVerifyInput): Promise<TEvent>;
62
+ }
63
+
64
+ // idempotency.port.ts
65
+ export interface IdempotencyPort {
66
+ lockOrSkip(key: string, ttlSeconds: number): Promise<boolean>;
67
+ markCompleted(key: string): Promise<void>;
68
+ }
69
+ ```
70
+
71
+ ---
72
+
73
+ ## 4. Idempotency Guard Implementation
74
+
75
+ ```ts
76
+ // src/infrastructure/guards/idempotency.guard.ts
77
+ import type { IdempotencyPort } from "@/domains/webhooks/idempotency.port";
78
+
79
+ export class IdempotencyGuard {
80
+ constructor(private port: IdempotencyPort) {}
81
+
82
+ async runOnce<T>(key: string, ttlSeconds: number, fn: () => Promise<T>): Promise<{ executed: boolean; result?: T }> {
83
+ const acquired = await this.port.lockOrSkip(key, ttlSeconds);
84
+ if (!acquired) {
85
+ return { executed: false };
86
+ }
87
+
88
+ try {
89
+ const result = await fn();
90
+ await this.port.markCompleted(key);
91
+ return { executed: true, result };
92
+ } catch (err) {
93
+ throw err;
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ---
100
+
101
+ ## 5. Detailed Verifiers & Store References
102
+
103
+ For real HMAC verifiers (Stripe, Svix, MercadoPago) and Redis/SQL idempotency stores, see:
104
+ - `references/webhook-idempotency-examples.md`