Skip to content

ADR-0001. Huỷ đăng ký dựa trên token (không xác thực, không session)

FieldValue
StatusAccepted
Date2026-05-01
DecidersPhat 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>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ả

ƯuNhược
Huỷ đăng ký một cú nhấp, không vướng bước xác thực nàoToken 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ại404 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ƯuNhượcLý do loại bỏ
JWT ký trong linkCó sẵn hạn dùng, không cần cột DBCần quản lý key; URL dài và xấu; subscriber không có tài khoản để giới hạn scopeQuá mức cần thiết cho một danh sách công khai
Email + bước xác nhậnXác minh quyền sở hữuThêm bước phiền; phá vỡ kỳ vọng một cú nhấpGiả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ênTrải nghiệm tương đươngCần bộ sinh riêng; Snowflake đã có sẵn qua IdGeneratorSnowflake 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 (route UNSUBSCRIBE)

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.