ADR-0001. Huỷ đăng ký dựa trên token (không xác thực, không session)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-01 |
| Deciders | Phat Nguyen |
| Supersedes | - |
Bối cảnh
- Người nhận newsletter phải có thể huỷ đăng ký chỉ bằng một cú nhấp vào link trong email, mà không cần đăng nhập.
- Một subscriber là đối tượng công khai, chưa xác thực - không có JWT, không có session, không có merchant scope.
- Chúng ta cần một cách cấp quyền "vô hiệu hoá đúng đăng ký này" đủ an toàn để nhúng vào một URL email dạng văn bản thuần, và không thể bị lợi dụng để dò tìm hay vô hiệu hoá đăng ký của người khác.
Quyết định
Mỗi dòng Subscriber mang một unsubscribeToken, được sinh qua IdGenerator.nextId() (một Snowflake 64-bit) khi thêm mới. Link huỷ đăng ký là GET /subscribers/unsubscribe?token=<unsubscribeToken> và không yêu cầu xác thực.
SubscriberService.unsubscribe() tra cứu token; nếu khớp thì đặt status=DEACTIVATED và đóng dấu unsubscribedAt; nếu không khớp thì ném UNSUBSCRIBE_INVALID_TOKEN (HTTP 404). Token được khai báo trong hiddenProperties của model, nên không bao giờ được trả về bởi bất kỳ endpoint đọc nào - cách duy nhất để có nó là nhận được email.
Hệ quả
| Ưu | Nhược |
|---|---|
| Huỷ đăng ký một cú nhấp, không vướng bước xác thực nào | Token là một bí mật dạng bearer nằm trong URL email văn bản thuần |
| Token không bao giờ rò rỉ qua API (hidden property) | Không có hạn dùng / xoay vòng - một link bị rò sẽ hoạt động vô thời hạn |
| Miền giá trị Snowflake (64-bit) khiến việc đoán là bất khả thi | Đăng ký lại không phát token mới (token ổn định qua các lần vô hiệu hoá/kích hoạt lại) |
| Idempotent: nhấp lại link trên một dòng đã vô hiệu hoá là vô hại | 404 với token sai hơi lộ thông tin "token không tồn tại" |
Các phương án đã cân nhắc
| Phương án | Ưu | Nhược | Lý do loại bỏ |
|---|---|---|---|
| JWT ký trong link | Có sẵn hạn dùng, không cần cột DB | Cần quản lý key; URL dài và xấu; subscriber không có tài khoản để giới hạn scope | Quá mức cần thiết cho một danh sách công khai |
| Email + bước xác nhận | Xác minh quyền sở hữu | Thêm bước phiền; phá vỡ kỳ vọng một cú nhấp | Giảm tỷ lệ hoàn tất huỷ đăng ký (và đi ngược ý định pháp lý về thao tác một cú nhấp) |
| Token UUID ngẫu nhiên | Trải nghiệm tương đương | Cần bộ sinh riêng; Snowflake đã có sẵn qua IdGenerator | Snowflake tái dùng được hạ tầng sẵn có |
Tham chiếu
packages/core/src/models/schemas/outreach/subscriber/schema.ts(unsubscribeToken$defaultFn,hiddenProperties)packages/outreach/src/services/subscriber.service.ts(unsubscribe)packages/outreach/src/errors/subscriber.errors.ts(UNSUBSCRIBE_INVALID_TOKEN)packages/outreach/src/controllers/subscriber/definitions.ts(routeUNSUBSCRIBE)