From 4f6268f7608dbf785d017ced013eb7f43c5f598a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Sun, 16 Aug 2026 09:13:49 +0700
Subject: [PATCH 1/3] feat: redesign OTP verification email
---
...ses-email-verification-delivery.adapter.ts | 29 +--
.../iam/adapter/aws-ses-v2-sender.adapter.ts | 5 +-
.../email-verification-message-content.ts | 169 ++++++++++++++++++
...mtp-email-verification-delivery.adapter.ts | 47 ++---
...mail-verification-delivery.adapter.test.ts | 26 ++-
...mail-verification-delivery.adapter.test.ts | 2 +
...mail-verification-delivery.adapter.test.ts | 21 +++
7 files changed, 247 insertions(+), 52 deletions(-)
create mode 100644 services/api/src/features/iam/adapter/email-verification-message-content.ts
diff --git a/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts b/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts
index 8ccea418..e5216ca1 100644
--- a/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts
+++ b/services/api/src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.ts
@@ -1,4 +1,8 @@
import type { EmailVerificationDeliveryPortV1 } from '../application/email-verification-repository.port.js';
+import {
+ createEmailVerificationMessageContentV1,
+ type EmailVerificationMessageContentV1,
+} from './email-verification-message-content.js';
const EMAIL_ADDRESS_PATTERN_V1 = /^[^\s@]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/u;
@@ -7,6 +11,7 @@ export interface SesEmailMessageV1 {
readonly toAddress: string;
readonly subject: string;
readonly textBody: string;
+ readonly htmlBody: string;
}
/** Provider-neutral transactional email boundary implemented by the AWS SES runtime adapter. */
@@ -25,26 +30,7 @@ function validAddress(value: string): boolean {
);
}
-function content(
- locale: string,
- code: string,
-): Pick | undefined {
- if (locale === 'vi-VN') {
- return Object.freeze({
- subject: 'Mã xác minh DataBreeze',
- textBody: `Mã xác minh DataBreeze của bạn là ${code}. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.`,
- });
- }
- if (locale === 'en') {
- return Object.freeze({
- subject: 'Your DataBreeze verification code',
- textBody: `Your DataBreeze verification code is ${code}. It expires in 10 minutes. If you did not request this code, ignore this email.`,
- });
- }
- return undefined;
-}
-
-/** IAM-022: minimal localized OTP delivery with no account or correlation metadata. */
+/** IAM-022: localized OTP delivery with no account or correlation metadata. */
export class AwsSesEmailVerificationDeliveryAdapter implements EmailVerificationDeliveryPortV1 {
public constructor(
private readonly sender: SesEmailSenderPortV1,
@@ -61,7 +47,8 @@ export class AwsSesEmailVerificationDeliveryAdapter implements EmailVerification
readonly locale: string;
readonly correlationId?: string;
}): Promise {
- const messageContent = content(input.locale, input.code);
+ const messageContent: EmailVerificationMessageContentV1 | undefined =
+ createEmailVerificationMessageContentV1(input.locale, input.code);
if (!validAddress(input.email) || !/^\d{6}$/u.test(input.code) || !messageContent) {
throw new Error('IAM_EMAIL_DELIVERY_INPUT_INVALID');
}
diff --git a/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts b/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts
index ed1320cc..7815ed3a 100644
--- a/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts
+++ b/services/api/src/features/iam/adapter/aws-ses-v2-sender.adapter.ts
@@ -21,7 +21,10 @@ export class AwsSesV2SenderAdapter implements SesEmailSenderPortV1 {
Content: {
Simple: {
Subject: { Data: message.subject, Charset: 'UTF-8' },
- Body: { Text: { Data: message.textBody, Charset: 'UTF-8' } },
+ Body: {
+ Text: { Data: message.textBody, Charset: 'UTF-8' },
+ Html: { Data: message.htmlBody, Charset: 'UTF-8' },
+ },
},
},
}),
diff --git a/services/api/src/features/iam/adapter/email-verification-message-content.ts b/services/api/src/features/iam/adapter/email-verification-message-content.ts
new file mode 100644
index 00000000..6da45312
--- /dev/null
+++ b/services/api/src/features/iam/adapter/email-verification-message-content.ts
@@ -0,0 +1,169 @@
+export interface EmailVerificationMessageContentV1 {
+ readonly subject: string;
+ readonly textBody: string;
+ readonly htmlBody: string;
+}
+
+const HTML_ESCAPES: Readonly> = Object.freeze({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+});
+
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/gu, (character) => HTML_ESCAPES[character] ?? character);
+}
+
+function renderHtml(input: {
+ readonly language: 'en' | 'vi';
+ readonly preheader: string;
+ readonly eyebrow: string;
+ readonly title: string;
+ readonly introduction: string;
+ readonly codeLabel: string;
+ readonly expires: string;
+ readonly security: string;
+ readonly notRequested: string;
+ readonly footer: string;
+ readonly code: string;
+}): string {
+ const code = escapeHtml(input.code);
+ const language = input.language;
+ return `
+
+
+
+
+
+ ${escapeHtml(input.title)}
+
+
+
+ ${escapeHtml(input.preheader)}
+
+
+
+
+
+
+
+
+
+
+ DB
+
+
+ DataBreeze
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(input.eyebrow)}
+
+
+ ${escapeHtml(input.title)}
+
+
+ ${escapeHtml(input.introduction)}
+
+
+
+
+
+ ${escapeHtml(input.codeLabel)}
+
+
+ ${code}
+
+
+
+
+
+ ${escapeHtml(input.expires)}
+
+
+
+
+ ${escapeHtml(input.security)}
+
+
+
+
+ ${escapeHtml(input.notRequested)}
+
+
+
+
+
+
+
+
+ ${escapeHtml(input.footer)}
+ © DataBreeze
+
+
+
+
+
+
+
+`;
+}
+
+/** IAM-022: localized OTP content with a safe text fallback and branded HTML presentation. */
+export function createEmailVerificationMessageContentV1(
+ locale: string,
+ code: string,
+): EmailVerificationMessageContentV1 | undefined {
+ if (locale === 'vi-VN') {
+ return Object.freeze({
+ subject: 'Mã xác minh DataBreeze',
+ textBody: `Mã xác minh DataBreeze của bạn là ${code}. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.`,
+ htmlBody: renderHtml({
+ language: 'vi',
+ preheader: 'Mã xác minh DataBreeze của bạn có hiệu lực trong 10 phút.',
+ eyebrow: 'XÁC MINH EMAIL',
+ title: 'Xác minh email của bạn',
+ introduction: 'Sử dụng mã bên dưới để hoàn tất việc tạo tài khoản DataBreeze.',
+ codeLabel: 'Mã xác minh',
+ expires: 'Mã này hết hạn sau 10 phút.',
+ security: 'Để bảo vệ tài khoản, không chia sẻ mã này với bất kỳ ai.',
+ notRequested: 'Nếu bạn không yêu cầu mã này, bạn có thể bỏ qua email này.',
+ footer: 'Không gian làm việc an toàn cho doanh nghiệp',
+ code,
+ }),
+ });
+ }
+ if (locale === 'en') {
+ return Object.freeze({
+ subject: 'Your DataBreeze verification code',
+ textBody: `Your DataBreeze verification code is ${code}. It expires in 10 minutes. If you did not request this code, ignore this email.`,
+ htmlBody: renderHtml({
+ language: 'en',
+ preheader: 'Your DataBreeze verification code is valid for 10 minutes.',
+ eyebrow: 'EMAIL VERIFICATION',
+ title: 'Verify your email',
+ introduction: 'Use the code below to finish creating your DataBreeze account.',
+ codeLabel: 'Verification code',
+ expires: 'This code expires in 10 minutes.',
+ security: 'For your security, never share this code with anyone.',
+ notRequested: 'If you did not request this code, you can safely ignore this email.',
+ footer: 'Secure workspaces for better decisions',
+ code,
+ }),
+ });
+ }
+ return undefined;
+}
diff --git a/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts b/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts
index ca8ea7de..208390ea 100644
--- a/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts
+++ b/services/api/src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.ts
@@ -1,6 +1,10 @@
import { createConnection, type Socket } from 'node:net';
import type { EmailVerificationDeliveryPortV1 } from '../application/email-verification-repository.port.js';
+import {
+ createEmailVerificationMessageContentV1,
+ type EmailVerificationMessageContentV1,
+} from './email-verification-message-content.js';
const EMAIL_ADDRESS_PATTERN_V1 = /^[^\s@]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/u;
const MAX_SMTP_MESSAGE_BYTES_V1 = 16 * 1024;
@@ -10,6 +14,7 @@ export interface SmtpMessageV1 {
readonly toAddresses: readonly [string];
readonly subject: string;
readonly textBody: string;
+ readonly htmlBody: string;
}
export interface SmtpSenderPortV1 {
@@ -35,23 +40,10 @@ export function validSmtpAddressV1(value: string): boolean {
);
}
-function content(
- locale: string,
- code: string,
-): Pick | undefined {
- if (locale === 'vi-VN') {
- return Object.freeze({
- subject: 'Mã xác minh DataBreeze',
- textBody: `Mã xác minh DataBreeze của bạn là ${code}. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.`,
- });
- }
- if (locale === 'en') {
- return Object.freeze({
- subject: 'Your DataBreeze verification code',
- textBody: `Your DataBreeze verification code is ${code}. It expires in 10 minutes. If you did not request this code, ignore this email.`,
- });
- }
- return undefined;
+const SMTP_BODY_LIMIT_BYTES_V1 = 12 * 1024;
+
+function normalizeSmtpBody(value: string): string {
+ return value.replace(/\r?\n/gu, '\r\n').replace(/^\./gmu, '..');
}
export function renderSmtpMessageV1(message: SmtpMessageV1): string {
@@ -65,21 +57,33 @@ export function renderSmtpMessageV1(message: SmtpMessageV1): string {
message.subject.includes('\r') ||
message.subject.includes('\n') ||
message.textBody.length < 1 ||
- message.textBody.length > 4_096
+ message.textBody.length > 4_096 ||
+ message.htmlBody.length < 1 ||
+ message.htmlBody.length > SMTP_BODY_LIMIT_BYTES_V1
) {
throw new Error('IAM_LOCAL_SMTP_MESSAGE_INVALID');
}
const subject = Buffer.from(message.subject, 'utf8').toString('base64');
- const normalizedBody = message.textBody.replace(/\r?\n/gu, '\r\n').replace(/^\./gmu, '..');
+ const boundary = '=_DataBreeze_Email_Verification_v1';
const payload = [
`From: <${message.fromAddress}>`,
`To: <${message.toAddresses[0]}>`,
`Subject: =?UTF-8?B?${subject}?=`,
'MIME-Version: 1.0',
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
+ '',
+ `--${boundary}`,
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
'',
- normalizedBody,
+ normalizeSmtpBody(message.textBody),
+ `--${boundary}`,
+ 'Content-Type: text/html; charset=UTF-8',
+ 'Content-Transfer-Encoding: 8bit',
+ '',
+ normalizeSmtpBody(message.htmlBody),
+ `--${boundary}--`,
+ '',
].join('\r\n');
if (Buffer.byteLength(payload, 'utf8') > MAX_SMTP_MESSAGE_BYTES_V1) {
throw new Error('IAM_LOCAL_SMTP_MESSAGE_INVALID');
@@ -202,7 +206,8 @@ export class MailpitSmtpEmailVerificationDeliveryAdapter
readonly locale: string;
readonly correlationId?: string;
}): Promise {
- const messageContent = content(input.locale, input.code);
+ const messageContent: EmailVerificationMessageContentV1 | undefined =
+ createEmailVerificationMessageContentV1(input.locale, input.code);
if (!validSmtpAddressV1(input.email) || !/^\d{6}$/u.test(input.code) || !messageContent) {
throw new Error('IAM_LOCAL_EMAIL_INPUT_INVALID');
}
diff --git a/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts b/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts
index 77a94bf9..6332b150 100644
--- a/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts
+++ b/services/api/test/features/iam/aws-ses-email-verification-delivery.adapter.test.ts
@@ -23,15 +23,16 @@ void test('[IAM-022] SES delivery emits one bounded localized transactional mess
correlationId: 'internal-correlation-id',
});
- assert.deepEqual(messages, [
- {
- fromAddress: 'verify@databreeze.example',
- toAddress: 'customer@example.com',
- subject: 'Mã xác minh DataBreeze',
- textBody:
- 'Mã xác minh DataBreeze của bạn là 042917. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.',
- },
- ]);
+ assert.equal(messages.length, 1);
+ assert.equal(messages[0]?.fromAddress, 'verify@databreeze.example');
+ assert.equal(messages[0]?.toAddress, 'customer@example.com');
+ assert.equal(messages[0]?.subject, 'Mã xác minh DataBreeze');
+ assert.equal(
+ messages[0]?.textBody,
+ 'Mã xác minh DataBreeze của bạn là 042917. Mã này hết hạn sau 10 phút. Nếu bạn không yêu cầu mã này, hãy bỏ qua email.',
+ );
+ assert.match(messages[0]?.htmlBody ?? '', /042917/u);
+ assert.match(messages[0]?.htmlBody ?? '', /Xác minh email của bạn/u);
assert.equal(JSON.stringify(messages).includes('internal-correlation-id'), false);
});
@@ -49,6 +50,8 @@ void test('[IAM-022] SES delivery supports the complete English locale', async (
messages[0]?.textBody,
'Your DataBreeze verification code is 123456. It expires in 10 minutes. If you did not request this code, ignore this email.',
);
+ assert.match(messages[0]?.htmlBody ?? '', /123456/u);
+ assert.match(messages[0]?.htmlBody ?? '', /Verify your email/u);
});
void test('[IAM-022] SES delivery fails closed with stable content-safe errors', async () => {
@@ -99,6 +102,7 @@ void test('[IAM-022] AWS SES v2 sender maps only the bounded simple-message fiel
toAddress: 'customer@example.com',
subject: 'Your DataBreeze verification code',
textBody: 'Your DataBreeze verification code is 123456.',
+ htmlBody: 'Your DataBreeze verification code is 123456.
',
});
assert.equal(commands.length, 1);
@@ -114,6 +118,10 @@ void test('[IAM-022] AWS SES v2 sender maps only the bounded simple-message fiel
Data: 'Your DataBreeze verification code is 123456.',
Charset: 'UTF-8',
},
+ Html: {
+ Data: 'Your DataBreeze verification code is 123456.
',
+ Charset: 'UTF-8',
+ },
},
},
},
diff --git a/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts b/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts
index 4fe2ea21..ee1025a9 100644
--- a/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts
+++ b/services/api/test/features/iam/gmail-smtp-email-verification-delivery.adapter.test.ts
@@ -48,6 +48,8 @@ void test('[IAM-022] Gmail delivery uses the authenticated Gmail identity as the
assert.deepEqual(messages[0]?.toAddresses, ['owner@example.com']);
assert.equal(messages[0]?.subject, 'Your DataBreeze verification code');
assert.match(messages[0]?.textBody ?? '', /042917/u);
+ assert.match(messages[0]?.htmlBody ?? '', /042917/u);
+ assert.match(messages[0]?.htmlBody ?? '', /Verify your email/u);
});
void test('[IAM-022] Gmail delivery hides SMTP provider details', async () => {
diff --git a/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts b/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts
index c3774354..983ca4bc 100644
--- a/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts
+++ b/services/api/test/features/iam/mailpit-smtp-email-verification-delivery.adapter.test.ts
@@ -6,6 +6,7 @@ import test from 'node:test';
import {
MailpitSmtpEmailVerificationDeliveryAdapter,
NodeLoopbackSmtpSenderAdapter,
+ renderSmtpMessageV1,
type SmtpMessageV1,
} from '../../../src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.js';
@@ -29,9 +30,29 @@ void test('[IAM-022] Mailpit delivery emits one bounded localized SMTP message w
assert.equal(messages[0]?.subject, 'Mã xác minh DataBreeze');
assert.match(messages[0]?.textBody ?? '', /042917/u);
assert.match(messages[0]?.textBody ?? '', /10 phút/u);
+ assert.match(messages[0]?.htmlBody ?? '', /042917/u);
+ assert.match(messages[0]?.htmlBody ?? '', /XÁC MINH EMAIL/u);
assert.equal(JSON.stringify(messages).includes('must-not-enter-email'), false);
});
+void test('[IAM-022] SMTP rendering keeps the text fallback and branded HTML alternative', async () => {
+ const message: SmtpMessageV1 = {
+ fromAddress: 'verify@databreeze.local',
+ toAddresses: ['owner@example.com'],
+ subject: 'Your DataBreeze verification code',
+ textBody: 'Your DataBreeze verification code is 042917.',
+ htmlBody: '\n042917
',
+ };
+
+ const payload = renderSmtpMessageV1(message);
+
+ assert.match(payload, /multipart\/alternative/u);
+ assert.match(payload, /Content-Type: text\/plain; charset=UTF-8/u);
+ assert.match(payload, /Content-Type: text\/html; charset=UTF-8/u);
+ assert.match(payload, /Your DataBreeze verification code is 042917\./u);
+ assert.match(payload, //u);
+});
+
void test('[IAM-022] Mailpit delivery rejects header injection and hides provider details', async () => {
assert.throws(
() =>
From c7653e0150c24306f0bcd0a02c7faec8ebbbc260 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Sun, 16 Aug 2026 09:48:15 +0700
Subject: [PATCH 2/3] feat: add password recovery flow
---
apps/web/src/app/router.tsx | 6 +
apps/web/src/features/auth/auth-api.ts | 45 +++++
.../src/features/auth/auth-route-pages.tsx | 39 +++-
.../features/auth/forgot-password-page.tsx | 131 +++++++++++++
.../src/features/auth/reset-password-page.tsx | 179 ++++++++++++++++++
apps/web/src/features/auth/sign-in-page.tsx | 21 +-
apps/web/src/styles.css | 80 ++++++++
apps/web/test/auth-api.test.ts | 71 ++++++-
apps/web/test/auth-pages.test.tsx | 69 +++++++
apps/web/test/auth-routing.test.tsx | 12 ++
docs/operations/iam-recovery-2026-08-03.md | 8 +-
infrastructure/aws/environments/alpha/main.tf | 1 +
.../alpha/tests/alpha-plan.tofutest.hcl | 10 +-
.../aws/environments/production/main.tf | 1 +
.../tests/production-plan.tofutest.hcl | 16 +-
.../aws/environments/staging/main.tf | 1 +
.../staging/tests/staging-plan.tofutest.hcl | 16 +-
infrastructure/aws/modules/compute/main.tf | 7 +-
.../aws/modules/compute/variables.tf | 14 ++
infrastructure/aws/modules/security/main.tf | 8 +
.../aws/modules/security/outputs.tf | 4 +
.../tests/platform-key-policy.tofutest.hcl | 19 ++
infrastructure/lightsail/.env.example | 1 +
infrastructure/lightsail/README.md | 19 +-
infrastructure/lightsail/compose.pilot.yml | 2 +
infrastructure/local/.env.example | 1 +
infrastructure/local/README.md | 6 +-
infrastructure/local/compose.yml | 1 +
services/api/openapi/v1.json | 5 +-
...-ses-password-recovery-delivery.adapter.ts | 52 +++++
.../password-recovery-delivery.utils.ts | 79 ++++++++
.../password-recovery-message-content.ts | 125 ++++++++++++
...smtp-password-recovery-delivery.adapter.ts | 57 ++++++
.../features/iam/api/recovery.controller.ts | 2 +-
.../api/src/features/iam/api/recovery.dto.ts | 9 +-
.../application/recovery-repository.port.ts | 3 +
.../iam/application/recovery.service.ts | 15 +-
.../platform/local-database.composition.ts | 10 +
.../production-database.composition.ts | 26 ++-
...password-recovery-delivery.adapter.test.ts | 177 +++++++++++++++++
.../test/features/iam/recovery-http.test.ts | 9 +-
.../local-database-composition.test.ts | 4 +
.../production-database-composition.test.ts | 8 +-
43 files changed, 1330 insertions(+), 39 deletions(-)
create mode 100644 apps/web/src/features/auth/forgot-password-page.tsx
create mode 100644 apps/web/src/features/auth/reset-password-page.tsx
create mode 100644 services/api/src/features/iam/adapter/aws-ses-password-recovery-delivery.adapter.ts
create mode 100644 services/api/src/features/iam/adapter/password-recovery-delivery.utils.ts
create mode 100644 services/api/src/features/iam/adapter/password-recovery-message-content.ts
create mode 100644 services/api/src/features/iam/adapter/smtp-password-recovery-delivery.adapter.ts
create mode 100644 services/api/test/features/iam/password-recovery-delivery.adapter.test.ts
diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx
index 5eb86b94..ffedff76 100644
--- a/apps/web/src/app/router.tsx
+++ b/apps/web/src/app/router.tsx
@@ -21,8 +21,10 @@ import { DashboardPage } from '../features/dashboards/dashboard-page.tsx';
import { AnalysisRoutePage } from '../features/analysis/analysis-route-page.tsx';
import { DataRoutePage } from '../features/data/data-route-page.tsx';
import {
+ ForgotPasswordRoutePage,
SignInRoutePage,
RegisterRoutePage,
+ ResetPasswordRoutePage,
VerifyEmailRoutePage,
} from '../features/auth/auth-route-pages.tsx';
import { LandingRoutePage } from '../features/landing/landing-page.tsx';
@@ -74,6 +76,8 @@ const logicalRoots = new Set([
'sign-in',
'register',
'verify-email',
+ 'forgot-password',
+ 'reset-password',
]);
function canonicalPathname(pathname: string): string | undefined {
@@ -133,6 +137,8 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] {
{ path: 'sign-in', element: },
{ path: 'register', element: },
{ path: 'verify-email', element: },
+ { path: 'forgot-password', element: },
+ { path: 'reset-password', element: },
],
},
{
diff --git a/apps/web/src/features/auth/auth-api.ts b/apps/web/src/features/auth/auth-api.ts
index 565664c5..02bb5f58 100644
--- a/apps/web/src/features/auth/auth-api.ts
+++ b/apps/web/src/features/auth/auth-api.ts
@@ -29,6 +29,14 @@ export interface AuthApiOptionsV1 {
export type AuthFailureV1 = { readonly accepted: false; readonly code: 'AUTH_FAILED' };
+export type PasswordResetRequestResultV1 = { readonly accepted: true } | AuthFailureV1;
+export type PasswordResetCompleteResultV1 =
+ | {
+ readonly accepted: true;
+ readonly value: { readonly userId: string; readonly mfaReenrollmentRequired: true };
+ }
+ | AuthFailureV1;
+
export interface AuthApiV1 {
readonly register: (
input: Omit,
@@ -42,6 +50,14 @@ export interface AuthApiV1 {
readonly email: string;
readonly password: string;
}) => Promise<{ readonly accepted: true; readonly value: IamAuthSession } | AuthFailureV1>;
+ readonly requestPasswordReset: (input: {
+ readonly email: string;
+ readonly locale: 'en' | 'vi-VN';
+ }) => Promise;
+ readonly completePasswordReset: (input: {
+ readonly token: string;
+ readonly newPassword: string;
+ }) => Promise;
readonly recoverWebSession: () => Promise<{ readonly accepted: true } | AuthFailureV1>;
readonly loadBootstrap: () => Promise<
{ readonly accepted: true; readonly value: IamBootstrapValue } | AuthFailureV1
@@ -53,6 +69,25 @@ function failure(): AuthFailureV1 {
return Object.freeze({ accepted: false, code: 'AUTH_FAILED' });
}
+function passwordResetRequested(raw: unknown): raw is { readonly requested: true } {
+ return typeof raw === 'object' && raw !== null && 'requested' in raw && raw.requested === true;
+}
+
+function passwordResetCompleted(
+ raw: unknown,
+): raw is { readonly userId: string; readonly mfaReenrollmentRequired: true } {
+ return (
+ typeof raw === 'object' &&
+ raw !== null &&
+ 'userId' in raw &&
+ typeof raw.userId === 'string' &&
+ raw.userId.length > 0 &&
+ raw.userId.length <= 128 &&
+ 'mfaReenrollmentRequired' in raw &&
+ raw.mfaReenrollmentRequired === true
+ );
+}
+
async function request(fetcher: typeof fetch, url: string, body: unknown): Promise {
try {
const response = await fetcher(url, {
@@ -114,6 +149,16 @@ export function createAuthApiV1(options: AuthApiOptionsV1 = {}): AuthApiV1 {
? Object.freeze({ accepted: true as const, value: parsed.value as IamAuthSession })
: failure();
},
+ async requestPasswordReset(input: { readonly email: string; readonly locale: 'en' | 'vi-VN' }) {
+ const raw = await request(fetcher, `${baseUrl}/v1/auth/recovery`, input);
+ return passwordResetRequested(raw) ? Object.freeze({ accepted: true as const }) : failure();
+ },
+ async completePasswordReset(input: { readonly token: string; readonly newPassword: string }) {
+ const raw = await request(fetcher, `${baseUrl}/v1/auth/recovery/complete`, input);
+ return passwordResetCompleted(raw)
+ ? Object.freeze({ accepted: true as const, value: raw })
+ : failure();
+ },
async recoverWebSession() {
clearAuthSessionV1();
if (currentCsrfTokenV1() === undefined) return failure();
diff --git a/apps/web/src/features/auth/auth-route-pages.tsx b/apps/web/src/features/auth/auth-route-pages.tsx
index 3c02410e..581ea83e 100644
--- a/apps/web/src/features/auth/auth-route-pages.tsx
+++ b/apps/web/src/features/auth/auth-route-pages.tsx
@@ -9,11 +9,18 @@ import {
rememberAuthSessionV1,
} from './auth-session.ts';
import { RegisterPage } from './register-page.tsx';
+import { ForgotPasswordPage } from './forgot-password-page.tsx';
+import { ResetPasswordPage } from './reset-password-page.tsx';
import { SignInPage } from './sign-in-page.tsx';
import { VerifyEmailPage } from './verify-email-page.tsx';
function authApi() {
- return createAuthApiV1({ baseUrl: import.meta.env['VITE_DATABREEZE_API_BASE_URL'] ?? '' });
+ const configuredBaseUrl = (import.meta.env as Record)[
+ 'VITE_DATABREEZE_API_BASE_URL'
+ ];
+ return createAuthApiV1({
+ baseUrl: typeof configuredBaseUrl === 'string' ? configuredBaseUrl : '',
+ });
}
async function establishProductSession(
@@ -41,7 +48,7 @@ export function SignInRoutePage() {
if (result.accepted) {
const established = await establishProductSession(api, result.value);
if (!established.accepted) return established;
- navigate(`/${locale}/data`, { replace: true });
+ void navigate(`/${locale}/data`, { replace: true });
}
return result;
}}
@@ -60,7 +67,7 @@ export function RegisterRoutePage() {
onRegistered={async (input) => {
const result = await api.register(input);
if (result.accepted)
- navigate(`/${locale}/verify-email`, {
+ void navigate(`/${locale}/verify-email`, {
state: { challengeId: result.value.challengeId, email: input.email },
});
return result;
@@ -105,10 +112,34 @@ export function VerifyEmailRoutePage() {
if (result.accepted) {
const established = await establishProductSession(api, result.value);
if (!established.accepted) return established;
- navigate(`/${locale}/data`, { replace: true });
+ void navigate(`/${locale}/data`, { replace: true });
}
return result;
}}
/>
);
}
+
+export function ForgotPasswordRoutePage() {
+ const { locale: routeLocale } = useParams();
+ const locale = normalizeRouteLocale(routeLocale);
+ const api = useMemo(authApi, []);
+ return (
+ api.requestPasswordReset(input)} />
+ );
+}
+
+export function ResetPasswordRoutePage() {
+ const { locale: routeLocale } = useParams();
+ const locale = normalizeRouteLocale(routeLocale);
+ const location = useLocation();
+ const api = useMemo(authApi, []);
+ const token = new URLSearchParams(location.search).get('token') ?? '';
+ return (
+ api.completePasswordReset(input)}
+ />
+ );
+}
diff --git a/apps/web/src/features/auth/forgot-password-page.tsx b/apps/web/src/features/auth/forgot-password-page.tsx
new file mode 100644
index 00000000..bed11f34
--- /dev/null
+++ b/apps/web/src/features/auth/forgot-password-page.tsx
@@ -0,0 +1,131 @@
+import { useState, type FormEvent } from 'react';
+
+import { AuthPageShell } from './auth-page-shell.tsx';
+
+export interface PasswordResetRequestInputV1 {
+ readonly email: string;
+ readonly locale: 'en' | 'vi-VN';
+}
+
+type PasswordResetRequestResultV1 = { readonly accepted: boolean };
+
+function rejected(result: PasswordResetRequestResultV1 | undefined): boolean {
+ return result?.accepted === false;
+}
+
+export function ForgotPasswordPage({
+ locale,
+ onRequested,
+}: {
+ readonly locale: 'en' | 'vi-VN';
+ readonly onRequested: (
+ input: PasswordResetRequestInputV1,
+ ) => Promise | PasswordResetRequestResultV1;
+}) {
+ const [email, setEmail] = useState('');
+ const [pending, setPending] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState(false);
+ const isVi = locale === 'vi-VN';
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (pending || email.trim().length < 3) {
+ setError(true);
+ return;
+ }
+ setPending(true);
+ setError(false);
+ try {
+ const result = await onRequested({ email: email.trim(), locale });
+ if (rejected(result)) setError(true);
+ else setSubmitted(true);
+ } catch {
+ setError(true);
+ } finally {
+ setPending(false);
+ }
+ };
+
+ return (
+
+ {isVi ? 'Nhớ lại mật khẩu?' : 'Remember your password?'}{' '}
+
+ {isVi ? 'Đăng nhập' : 'Sign in'}
+
+
+ }
+ >
+ {submitted ? (
+
+
+ ✓
+
+
+
{isVi ? 'Hãy kiểm tra hộp thư' : 'Check your inbox'}
+
+ {isVi
+ ? 'Nếu email này thuộc DataBreeze, chúng tôi đã gửi liên kết đặt lại mật khẩu. Hãy kiểm tra cả thư mục spam.'
+ : 'If this email belongs to DataBreeze, we sent a password reset link. Check your spam folder too.'}
+
+
+
+ ) : (
+ <>
+
+ {error ? (
+
+
+ !
+
+
+ {isVi
+ ? 'Không thể gửi yêu cầu. Hãy thử lại.'
+ : 'Could not send the request. Try again.'}
+
+
+ ) : null}
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/auth/reset-password-page.tsx b/apps/web/src/features/auth/reset-password-page.tsx
new file mode 100644
index 00000000..1ef6a707
--- /dev/null
+++ b/apps/web/src/features/auth/reset-password-page.tsx
@@ -0,0 +1,179 @@
+import { useState, type FormEvent } from 'react';
+
+import { AuthPageShell } from './auth-page-shell.tsx';
+
+export interface PasswordResetCompletionInputV1 {
+ readonly token: string;
+ readonly newPassword: string;
+}
+
+type PasswordResetCompletionResultV1 = { readonly accepted: boolean };
+
+function rejected(result: PasswordResetCompletionResultV1 | undefined): boolean {
+ return result?.accepted === false;
+}
+
+export function ResetPasswordPage({
+ locale,
+ token,
+ onReset,
+}: {
+ readonly locale: 'en' | 'vi-VN';
+ readonly token: string;
+ readonly onReset: (
+ input: PasswordResetCompletionInputV1,
+ ) => Promise | PasswordResetCompletionResultV1;
+}) {
+ const [password, setPassword] = useState('');
+ const [confirmation, setConfirmation] = useState('');
+ const [pending, setPending] = useState(false);
+ const [success, setSuccess] = useState(false);
+ const [error, setError] = useState(false);
+ const isVi = locale === 'vi-VN';
+ const invalidToken = token.trim().length === 0;
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (
+ pending ||
+ invalidToken ||
+ password.length < 12 ||
+ password.length > 128 ||
+ password !== confirmation
+ ) {
+ setError(true);
+ return;
+ }
+ setPending(true);
+ setError(false);
+ try {
+ const result = await onReset({ token, newPassword: password });
+ if (rejected(result)) setError(true);
+ else setSuccess(true);
+ } catch {
+ setError(true);
+ } finally {
+ setPending(false);
+ }
+ };
+
+ return (
+
+ {isVi ? 'Cần bắt đầu lại?' : 'Need to start over?'}{' '}
+
+ {isVi ? 'Yêu cầu liên kết mới' : 'Request a new link'}
+
+
+ }
+ >
+ {invalidToken ? (
+
+
+ !
+
+
+ {isVi
+ ? 'Liên kết đặt lại không hợp lệ hoặc đã hết hạn.'
+ : 'This reset link is invalid or has expired.'}
+
+
+ ) : success ? (
+
+
+ ✓
+
+
+
{isVi ? 'Mật khẩu đã được cập nhật' : 'Password updated'}
+
+ {isVi
+ ? 'Hãy đăng nhập lại bằng mật khẩu mới. Bạn có thể cần đăng ký lại MFA trước khi dùng các thao tác nhạy cảm.'
+ : 'Sign in again with your new password. You may need to re-enroll MFA before using sensitive actions.'}
+
+
+ {isVi ? 'Đi tới đăng nhập' : 'Go to sign in'} →
+
+
+
+ ) : (
+ <>
+
+ {error ? (
+
+
+ !
+
+
+ {password !== confirmation
+ ? isVi
+ ? 'Hai mật khẩu chưa giống nhau.'
+ : 'The passwords do not match.'
+ : isVi
+ ? 'Không thể cập nhật mật khẩu. Liên kết có thể đã hết hạn.'
+ : 'Could not update the password. The link may have expired.'}
+
+
+ ) : null}
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/auth/sign-in-page.tsx b/apps/web/src/features/auth/sign-in-page.tsx
index bce18d42..a99b091d 100644
--- a/apps/web/src/features/auth/sign-in-page.tsx
+++ b/apps/web/src/features/auth/sign-in-page.tsx
@@ -1,6 +1,8 @@
import { useState, type FormEvent } from 'react';
import { AuthPageShell } from './auth-page-shell.tsx';
+type SignInActionResultV1 = { readonly accepted: boolean };
+
export function SignInPage({
locale,
onSignedIn,
@@ -9,7 +11,7 @@ export function SignInPage({
readonly onSignedIn: (input: {
readonly email: string;
readonly password: string;
- }) => Promise | unknown;
+ }) => Promise | SignInActionResultV1 | undefined;
}) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -23,14 +25,7 @@ export function SignInPage({
setError(false);
try {
const result = await onSignedIn({ email, password });
- if (
- typeof result === 'object' &&
- result !== null &&
- 'accepted' in result &&
- result.accepted === false
- ) {
- setError(true);
- }
+ if (result?.accepted === false) setError(true);
} catch {
setError(true);
} finally {
@@ -69,9 +64,15 @@ export function SignInPage({
onChange={(event) => setEmail(event.currentTarget.value)}
/>
-
+
+
{
vi.unstubAllGlobals();
});
+function jsonBody(init?: RequestInit): unknown {
+ return typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
+}
+
describe('generated-contract auth transport [IAM-022, IAM-023, WEB-004]', () => {
const session = {
schemaVersion: 4 as const,
@@ -28,7 +32,7 @@ describe('generated-contract auth transport [IAM-022, IAM-023, WEB-004]', () =>
it('registers with email/password and returns only the opaque OTP challenge', async () => {
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.credentials).toBe('include');
- expect(JSON.parse(String(init?.body))).toEqual({
+ expect(jsonBody(init)).toEqual({
schemaVersion: 4,
email: 'owner@example.com',
password: 'correct horse battery staple',
@@ -119,12 +123,73 @@ describe('generated-contract auth transport [IAM-022, IAM-023, WEB-004]', () =>
});
});
+ it('requests and completes password recovery only for the closed recovery response shapes', async () => {
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
+ const body = jsonBody(init);
+ if (url.endsWith('/v1/auth/recovery')) {
+ expect(body).toEqual({ email: 'owner@example.com', locale: 'en' });
+ return new Response(JSON.stringify({ requested: true }), {
+ status: 202,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ expect(url).toMatch(/\/v1\/auth\/recovery\/complete$/u);
+ expect(body).toEqual({
+ token: 'r'.repeat(43),
+ newPassword: 'new correct horse battery staple',
+ });
+ return new Response(
+ JSON.stringify({
+ userId: '00000000-0000-4000-8000-000000000402',
+ mfaReenrollmentRequired: true,
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ );
+ });
+ const api = createAuthApiV1({
+ baseUrl: 'https://api.example.test',
+ fetcher: fetchMock as never,
+ });
+
+ await expect(
+ api.requestPasswordReset({ email: 'owner@example.com', locale: 'en' }),
+ ).resolves.toEqual({ accepted: true });
+ await expect(
+ api.completePasswordReset({
+ token: 'r'.repeat(43),
+ newPassword: 'new correct horse battery staple',
+ }),
+ ).resolves.toEqual({
+ accepted: true,
+ value: {
+ userId: '00000000-0000-4000-8000-000000000402',
+ mfaReenrollmentRequired: true,
+ },
+ });
+ });
+
+ it('fails closed when a recovery endpoint returns a malformed completion payload', async () => {
+ const malformed = createAuthApiV1({
+ baseUrl: 'https://api.example.test',
+ fetcher: vi.fn(
+ async () =>
+ new Response(JSON.stringify({ userId: 'user', mfaReenrollmentRequired: false }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ ) as never,
+ });
+ await expect(
+ malformed.completePasswordReset({ token: 'r'.repeat(43), newPassword: 'password' }),
+ ).resolves.toEqual({ accepted: false, code: 'AUTH_FAILED' });
+ });
+
it('recovers a reload through the HttpOnly-cookie refresh endpoint and remembers only the v4 access session', async () => {
globalThis.document.cookie = `databreeze_csrf=${'c'.repeat(43)}; Path=/`;
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.credentials).toBe('include');
expect(new Headers(init?.headers).get('x-csrf-token')).toBe('c'.repeat(43));
- expect(JSON.parse(String(init?.body))).toEqual({ clientPlatform: 'web' });
+ expect(jsonBody(init)).toEqual({ clientPlatform: 'web' });
return new Response(
JSON.stringify({
schemaVersion: 4,
@@ -322,7 +387,7 @@ describe('generated-contract auth transport [IAM-022, IAM-023, WEB-004]', () =>
expect(headers.get('authorization')).toBe(`Bearer ${session.accessToken}`);
expect(headers.get('x-csrf-token')).toBe('c'.repeat(43));
expect(headers.get('idempotency-key')).toBe(session.sessionId);
- expect(JSON.parse(String(init?.body))).toEqual({
+ expect(jsonBody(init)).toEqual({
clientPlatform: 'web',
sessionId: session.sessionId,
});
diff --git a/apps/web/test/auth-pages.test.tsx b/apps/web/test/auth-pages.test.tsx
index d8ff4eda..0351400b 100644
--- a/apps/web/test/auth-pages.test.tsx
+++ b/apps/web/test/auth-pages.test.tsx
@@ -5,6 +5,8 @@ import { authSquareWaveLevel } from '../src/features/auth/auth-matrix-field.tsx'
import { SignInPage } from '../src/features/auth/sign-in-page.tsx';
import { RegisterPage } from '../src/features/auth/register-page.tsx';
import { VerifyEmailPage } from '../src/features/auth/verify-email-page.tsx';
+import { ForgotPasswordPage } from '../src/features/auth/forgot-password-page.tsx';
+import { ResetPasswordPage } from '../src/features/auth/reset-password-page.tsx';
const WAVE = { periodMs: 8000, span: 30, band: 4, tail: 6 };
@@ -19,6 +21,7 @@ describe('auth product surfaces', () => {
expect(screen.getByRole('heading', { name: 'Đăng nhập' })).toBeTruthy();
expect(screen.getByLabelText('Mật khẩu')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Đăng nhập' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Quên mật khẩu?' })).toBeTruthy();
});
it('fills the left half with brand story proofs beside a square form panel', () => {
@@ -136,6 +139,72 @@ describe('auth product surfaces', () => {
expect(screen.queryByLabelText(/display name/i)).toBeNull();
});
+ it('requests a password reset with generic confirmation copy', async () => {
+ const user = userEvent.setup();
+ const requested: unknown[] = [];
+ render(
+ {
+ requested.push(input);
+ return { accepted: true as const };
+ }}
+ />,
+ );
+
+ await user.type(screen.getByLabelText('Email'), 'owner@example.com');
+ await user.click(screen.getByRole('button', { name: 'Send reset link' }));
+
+ expect(requested).toEqual([{ email: 'owner@example.com', locale: 'en' }]);
+ expect(screen.getByRole('status').textContent).toContain('Check your inbox');
+ expect(screen.getByRole('status').textContent).toContain('If this email belongs to DataBreeze');
+ });
+
+ it('validates and completes a password reset without exposing the bearer token', async () => {
+ const user = userEvent.setup();
+ const completed: unknown[] = [];
+ const token = 'r'.repeat(43);
+ render(
+ {
+ completed.push(input);
+ return { accepted: true as const };
+ }}
+ />,
+ );
+
+ await user.type(screen.getByLabelText('Mật khẩu mới'), 'new correct horse battery staple');
+ await user.type(
+ screen.getByLabelText('Xác nhận mật khẩu mới'),
+ 'new correct horse battery staple',
+ );
+ await user.click(screen.getByRole('button', { name: 'Cập nhật mật khẩu' }));
+
+ expect(completed).toEqual([{ token, newPassword: 'new correct horse battery staple' }]);
+ expect(screen.getByRole('status').textContent).toContain('Mật khẩu đã được cập nhật');
+ expect(screen.getByRole('status').textContent).not.toContain(token);
+ });
+
+ it('shows a safe invalid-link state and blocks reset submission when the token is missing', async () => {
+ const submitted: unknown[] = [];
+ render(
+ {
+ submitted.push(input);
+ return { accepted: true as const };
+ }}
+ />,
+ );
+
+ expect(screen.getByRole('alert').textContent).toContain('invalid or has expired');
+ expect(screen.queryByRole('button', { name: 'Update password' })).toBeNull();
+ expect(submitted).toEqual([]);
+ });
+
it('does not submit registration until password confirmation matches', async () => {
const user = userEvent.setup();
const submitted: unknown[] = [];
diff --git a/apps/web/test/auth-routing.test.tsx b/apps/web/test/auth-routing.test.tsx
index 5fc0f3e9..f33318f6 100644
--- a/apps/web/test/auth-routing.test.tsx
+++ b/apps/web/test/auth-routing.test.tsx
@@ -55,6 +55,18 @@ describe('live authentication routing [IAM-023, WEB-002, WEB-004]', () => {
expect(screen.queryByRole('navigation', { name: 'Điều hướng chính' })).toBeNull();
});
+ it('renders localized password recovery routes without the protected workspace shell', async () => {
+ const router = createAppRouter({
+ authenticationState: 'signed-out',
+ initialEntries: ['/en/forgot-password'],
+ });
+
+ render( );
+
+ expect(await screen.findByRole('heading', { name: 'Forgot your password?' })).toBeTruthy();
+ expect(screen.queryByRole('navigation', { name: 'Primary navigation' })).toBeNull();
+ });
+
it('keeps signed-in users out of public authentication routes', async () => {
const router = createAppRouter({
authenticationState: 'signed-in',
diff --git a/docs/operations/iam-recovery-2026-08-03.md b/docs/operations/iam-recovery-2026-08-03.md
index b0564bbc..bf50175c 100644
--- a/docs/operations/iam-recovery-2026-08-03.md
+++ b/docs/operations/iam-recovery-2026-08-03.md
@@ -15,6 +15,9 @@ It does not claim that IAM-015 or the IAM plan is complete.
- Completion attempts use a separate admission port and, when Redis is configured, a distinct `databreeze:iam:recovery:completion:v1:` namespace so email-request and token-brute-force limits cannot collide.
- Keep the public completion response free of bearer material; no session is automatically created.
- Select the Prisma recovery adapter only when persistence is configured, and fail closed when the delivery, digest, or password boundary is missing.
+- Render a localized, origin-bound reset-link message for the local SMTP/Gmail and production AWS SES providers. The reset link expires after 60 minutes and the raw bearer is never persisted or logged.
+- Expose signed-out Web routes for `/vi-VN/forgot-password`, `/en/forgot-password`, and their `/reset-password` counterparts. The request response remains generic, and successful completion requires a new sign-in and may require MFA re-enrollment.
+- Keep the recovery digest key separate from email-verification keys as `DATABREEZE_IAM_RECOVERY_DIGEST_KEY`; the key is required in local, pilot, and production compositions.
## Evidence
@@ -25,9 +28,12 @@ It does not claim that IAM-015 or the IAM plan is complete.
- MFA re-enrollment transaction tests: `services/api/test/features/iam/mfa.service.test.ts` and `services/api/test/features/iam/prisma-mfa-repository.test.ts`.
- Live principal/context propagation tests: `services/api/test/features/iam/prisma-credential-lookup.test.ts`, `services/api/test/features/iam/prisma-session-lifecycle.test.ts`, and `services/api/test/platform/http/session-tenant-context.test.ts`.
- Composition/controller/HTTP tests: `services/api/test/features/iam/recovery-composition.test.ts`, `recovery-controller.test.ts`, and `recovery-http.test.ts`.
+- Recovery delivery tests: `services/api/test/features/iam/password-recovery-delivery.adapter.test.ts`.
+- Recovery composition tests: `services/api/test/platform/local-database-composition.test.ts` and `production-database-composition.test.ts`.
+- Web recovery tests: `apps/web/test/auth-api.test.ts`, `auth-pages.test.tsx`, and `auth-routing.test.tsx`.
- Public routes: `services/api/openapi/v1.json` (`POST /v1/auth/recovery` and `POST /v1/auth/recovery/complete`).
- Bilingual problem copy: `packages/i18n/src/catalogs-v1.ts` and `packages/i18n/test/catalogs-v1.test.mjs`.
## Verification
-The scoped API TypeScript build, recovery tests, i18n tests, OpenAPI generation/check, and Prisma validation passed on 2026-08-03. The requirement remains `partial` and `not-verified` until authenticated MFA re-enrollment enforcement, audit events, rate limits, abuse monitoring, restoration drills, and the complete IAM release gates are delivered.
+The focused API/Web TypeScript builds, recovery delivery and HTTP tests, Web route tests, i18n tests, OpenAPI generation/check, and Prisma validation passed for this slice. The requirement remains `partial` and `not-verified` until authenticated MFA re-enrollment enforcement, audit events, rate limits, abuse monitoring, restoration drills, and the complete IAM release gates are delivered.
diff --git a/infrastructure/aws/environments/alpha/main.tf b/infrastructure/aws/environments/alpha/main.tf
index 7c451e90..2ca81f81 100644
--- a/infrastructure/aws/environments/alpha/main.tf
+++ b/infrastructure/aws/environments/alpha/main.tf
@@ -76,6 +76,7 @@ module "compute" {
csrf_allowed_origins_secret_arn = module.security.csrf_allowed_origins_secret_arn
service_account_secret_envelope_key_secret_arn = module.security.service_account_secret_envelope_key_secret_arn
email_verification_digest_key_secret_arn = module.security.email_verification_digest_key_secret_arn
+ recovery_digest_key_secret_arn = module.security.recovery_digest_key_secret_arn
email_verification_envelope_key_secret_arn = module.security.email_verification_envelope_key_secret_arn
registration_admission_key_secret_arn = module.security.registration_admission_key_secret_arn
iae_worker_capability_signing_key_secret_arn = module.security.iae_worker_capability_signing_key_secret_arn
diff --git a/infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl b/infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl
index e1c81d53..469defa2 100644
--- a/infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl
+++ b/infrastructure/aws/environments/alpha/tests/alpha-plan.tofutest.hcl
@@ -56,6 +56,13 @@ override_resource {
}
}
+override_resource {
+ target = module.security.aws_secretsmanager_secret.recovery_digest_key
+ values = {
+ arn = "arn:aws:secretsmanager:ap-southeast-1:123456789012:secret:databreeze/alpha/iam/recovery-digest-key-AbCdEf"
+ }
+}
+
override_resource {
target = module.security.aws_secretsmanager_secret.email_verification_envelope_key
values = {
@@ -163,7 +170,7 @@ run "openai_feature_contract" {
assert {
condition = (
- jsondecode(module.compute.api_task_definition_container_definitions)[0].secrets[7] == {
+ jsondecode(module.compute.api_task_definition_container_definitions)[0].secrets[8] == {
name = "OPENAI_API_KEY"
valueFrom = module.security.openai_api_key_secret_arn
} &&
@@ -172,6 +179,7 @@ run "openai_feature_contract" {
module.security.csrf_allowed_origins_secret_arn,
module.security.service_account_secret_envelope_key_secret_arn,
module.security.email_verification_digest_key_secret_arn,
+ module.security.recovery_digest_key_secret_arn,
module.security.email_verification_envelope_key_secret_arn,
module.security.registration_admission_key_secret_arn,
module.security.iae_worker_capability_signing_key_secret_arn,
diff --git a/infrastructure/aws/environments/production/main.tf b/infrastructure/aws/environments/production/main.tf
index 7c451e90..2ca81f81 100644
--- a/infrastructure/aws/environments/production/main.tf
+++ b/infrastructure/aws/environments/production/main.tf
@@ -76,6 +76,7 @@ module "compute" {
csrf_allowed_origins_secret_arn = module.security.csrf_allowed_origins_secret_arn
service_account_secret_envelope_key_secret_arn = module.security.service_account_secret_envelope_key_secret_arn
email_verification_digest_key_secret_arn = module.security.email_verification_digest_key_secret_arn
+ recovery_digest_key_secret_arn = module.security.recovery_digest_key_secret_arn
email_verification_envelope_key_secret_arn = module.security.email_verification_envelope_key_secret_arn
registration_admission_key_secret_arn = module.security.registration_admission_key_secret_arn
iae_worker_capability_signing_key_secret_arn = module.security.iae_worker_capability_signing_key_secret_arn
diff --git a/infrastructure/aws/environments/production/tests/production-plan.tofutest.hcl b/infrastructure/aws/environments/production/tests/production-plan.tofutest.hcl
index bb0ff0d7..3e81ab89 100644
--- a/infrastructure/aws/environments/production/tests/production-plan.tofutest.hcl
+++ b/infrastructure/aws/environments/production/tests/production-plan.tofutest.hcl
@@ -61,6 +61,13 @@ override_resource {
}
}
+override_resource {
+ target = module.security.aws_secretsmanager_secret.recovery_digest_key
+ values = {
+ arn = "arn:aws:secretsmanager:ap-southeast-1:123456789012:secret:databreeze/production/iam/recovery-digest-key-AbCdEf"
+ }
+}
+
override_resource {
target = module.security.aws_secretsmanager_secret.email_verification_envelope_key
values = {
@@ -160,6 +167,10 @@ run "api_runtime_secret_contract" {
name = "DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY"
valueFrom = module.security.email_verification_digest_key_secret_arn
},
+ {
+ name = "DATABREEZE_IAM_RECOVERY_DIGEST_KEY"
+ valueFrom = module.security.recovery_digest_key_secret_arn
+ },
{
name = "DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY"
valueFrom = module.security.email_verification_envelope_key_secret_arn
@@ -184,6 +195,7 @@ run "api_runtime_secret_contract" {
module.security.csrf_allowed_origins_secret_arn,
module.security.service_account_secret_envelope_key_secret_arn,
module.security.email_verification_digest_key_secret_arn,
+ module.security.recovery_digest_key_secret_arn,
module.security.email_verification_envelope_key_secret_arn,
module.security.registration_admission_key_secret_arn,
module.security.iae_worker_capability_signing_key_secret_arn,
@@ -201,6 +213,7 @@ run "api_runtime_secret_contract" {
module.security.csrf_allowed_origins_secret_arn,
module.security.service_account_secret_envelope_key_secret_arn,
module.security.email_verification_digest_key_secret_arn,
+ module.security.recovery_digest_key_secret_arn,
module.security.email_verification_envelope_key_secret_arn,
module.security.registration_admission_key_secret_arn,
module.security.iae_worker_capability_signing_key_secret_arn,
@@ -251,7 +264,7 @@ run "openai_feature_contract" {
assert {
condition = (
- jsondecode(module.compute.api_task_definition_container_definitions)[0].secrets[7] == {
+ jsondecode(module.compute.api_task_definition_container_definitions)[0].secrets[8] == {
name = "OPENAI_API_KEY"
valueFrom = module.security.openai_api_key_secret_arn
} &&
@@ -260,6 +273,7 @@ run "openai_feature_contract" {
module.security.csrf_allowed_origins_secret_arn,
module.security.service_account_secret_envelope_key_secret_arn,
module.security.email_verification_digest_key_secret_arn,
+ module.security.recovery_digest_key_secret_arn,
module.security.email_verification_envelope_key_secret_arn,
module.security.registration_admission_key_secret_arn,
module.security.iae_worker_capability_signing_key_secret_arn,
diff --git a/infrastructure/aws/environments/staging/main.tf b/infrastructure/aws/environments/staging/main.tf
index 7c451e90..2ca81f81 100644
--- a/infrastructure/aws/environments/staging/main.tf
+++ b/infrastructure/aws/environments/staging/main.tf
@@ -76,6 +76,7 @@ module "compute" {
csrf_allowed_origins_secret_arn = module.security.csrf_allowed_origins_secret_arn
service_account_secret_envelope_key_secret_arn = module.security.service_account_secret_envelope_key_secret_arn
email_verification_digest_key_secret_arn = module.security.email_verification_digest_key_secret_arn
+ recovery_digest_key_secret_arn = module.security.recovery_digest_key_secret_arn
email_verification_envelope_key_secret_arn = module.security.email_verification_envelope_key_secret_arn
registration_admission_key_secret_arn = module.security.registration_admission_key_secret_arn
iae_worker_capability_signing_key_secret_arn = module.security.iae_worker_capability_signing_key_secret_arn
diff --git a/infrastructure/aws/environments/staging/tests/staging-plan.tofutest.hcl b/infrastructure/aws/environments/staging/tests/staging-plan.tofutest.hcl
index 71dea77a..d38f1c3d 100644
--- a/infrastructure/aws/environments/staging/tests/staging-plan.tofutest.hcl
+++ b/infrastructure/aws/environments/staging/tests/staging-plan.tofutest.hcl
@@ -56,6 +56,13 @@ override_resource {
}
}
+override_resource {
+ target = module.security.aws_secretsmanager_secret.recovery_digest_key
+ values = {
+ arn = "arn:aws:secretsmanager:ap-southeast-1:123456789012:secret:databreeze/staging/iam/recovery-digest-key-AbCdEf"
+ }
+}
+
override_resource {
target = module.security.aws_secretsmanager_secret.email_verification_envelope_key
values = {
@@ -145,6 +152,10 @@ run "api_runtime_secret_contract" {
name = "DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY"
valueFrom = module.security.email_verification_digest_key_secret_arn
},
+ {
+ name = "DATABREEZE_IAM_RECOVERY_DIGEST_KEY"
+ valueFrom = module.security.recovery_digest_key_secret_arn
+ },
{
name = "DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY"
valueFrom = module.security.email_verification_envelope_key_secret_arn
@@ -169,6 +180,7 @@ run "api_runtime_secret_contract" {
module.security.csrf_allowed_origins_secret_arn,
module.security.service_account_secret_envelope_key_secret_arn,
module.security.email_verification_digest_key_secret_arn,
+ module.security.recovery_digest_key_secret_arn,
module.security.email_verification_envelope_key_secret_arn,
module.security.registration_admission_key_secret_arn,
module.security.iae_worker_capability_signing_key_secret_arn,
@@ -186,6 +198,7 @@ run "api_runtime_secret_contract" {
module.security.csrf_allowed_origins_secret_arn,
module.security.service_account_secret_envelope_key_secret_arn,
module.security.email_verification_digest_key_secret_arn,
+ module.security.recovery_digest_key_secret_arn,
module.security.email_verification_envelope_key_secret_arn,
module.security.registration_admission_key_secret_arn,
module.security.iae_worker_capability_signing_key_secret_arn,
@@ -234,7 +247,7 @@ run "openai_feature_contract" {
assert {
condition = (
- jsondecode(module.compute.api_task_definition_container_definitions)[0].secrets[7] == {
+ jsondecode(module.compute.api_task_definition_container_definitions)[0].secrets[8] == {
name = "OPENAI_API_KEY"
valueFrom = module.security.openai_api_key_secret_arn
} &&
@@ -243,6 +256,7 @@ run "openai_feature_contract" {
module.security.csrf_allowed_origins_secret_arn,
module.security.service_account_secret_envelope_key_secret_arn,
module.security.email_verification_digest_key_secret_arn,
+ module.security.recovery_digest_key_secret_arn,
module.security.email_verification_envelope_key_secret_arn,
module.security.registration_admission_key_secret_arn,
module.security.iae_worker_capability_signing_key_secret_arn,
diff --git a/infrastructure/aws/modules/compute/main.tf b/infrastructure/aws/modules/compute/main.tf
index 4ff58d60..40a00ba5 100644
--- a/infrastructure/aws/modules/compute/main.tf
+++ b/infrastructure/aws/modules/compute/main.tf
@@ -1,14 +1,15 @@
locals {
common_tags = merge(var.tags, { Component = "compute" })
- api_base_secrets = [
+ api_base_secrets = concat([
{ name = "DATABASE_URL", valueFrom = var.database_url_secret_arn },
{ name = "DATABREEZE_CSRF_ALLOWED_ORIGINS", valueFrom = var.csrf_allowed_origins_secret_arn },
{ name = "DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY", valueFrom = var.service_account_secret_envelope_key_secret_arn },
{ name = "DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY", valueFrom = var.email_verification_digest_key_secret_arn },
+ ], trimspace(var.recovery_digest_key_secret_arn) == "" ? [] : [{ name = "DATABREEZE_IAM_RECOVERY_DIGEST_KEY", valueFrom = var.recovery_digest_key_secret_arn }], [
{ name = "DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY", valueFrom = var.email_verification_envelope_key_secret_arn },
{ name = "DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY", valueFrom = var.registration_admission_key_secret_arn },
{ name = "DATABREEZE_IAE_WORKER_CAPABILITY_SIGNING_KEY", valueFrom = var.iae_worker_capability_signing_key_secret_arn }
- ]
+ ])
api_openai_secret = [{ name = "OPENAI_API_KEY", valueFrom = var.openai_api_key_secret_arn }]
api_runtime_secrets = var.openai_agent_enabled || var.openai_receipt_enabled || var.openai_dashboard_enabled ? concat(local.api_base_secrets, local.api_openai_secret) : local.api_base_secrets
api_runtime_secret_arns = [for secret in local.api_runtime_secrets : secret.valueFrom]
@@ -16,6 +17,7 @@ locals {
current_csrf_secret_arn = "arn:${data.aws_partition.current.partition}:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:databreeze/${var.name}/csrf-allowed-origins-"
current_service_account_secret_arn = "arn:${data.aws_partition.current.partition}:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:databreeze/${var.name}/iam/service-account-envelope-key-"
current_email_verification_digest_secret_arn = "arn:${data.aws_partition.current.partition}:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:databreeze/${var.name}/iam/email-verification-digest-key-"
+ current_recovery_digest_secret_arn = "arn:${data.aws_partition.current.partition}:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:databreeze/${var.name}/iam/recovery-digest-key-"
current_email_verification_envelope_secret_arn = "arn:${data.aws_partition.current.partition}:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:databreeze/${var.name}/iam/email-verification-envelope-key-"
current_registration_admission_secret_arn = "arn:${data.aws_partition.current.partition}:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:databreeze/${var.name}/iam/registration-admission-key-"
current_iae_worker_signing_secret_arn = "arn:${data.aws_partition.current.partition}:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:databreeze/${var.name}/iae/worker-capability-signing-key-"
@@ -292,6 +294,7 @@ resource "aws_ecs_task_definition" "api" {
can(regex("^${local.current_csrf_secret_arn}[A-Za-z0-9]{6}$", trimspace(var.csrf_allowed_origins_secret_arn))) &&
can(regex("^${local.current_service_account_secret_arn}[A-Za-z0-9]{6}$", trimspace(var.service_account_secret_envelope_key_secret_arn))) &&
can(regex("^${local.current_email_verification_digest_secret_arn}[A-Za-z0-9]{6}$", trimspace(var.email_verification_digest_key_secret_arn))) &&
+ (trimspace(var.recovery_digest_key_secret_arn) == "" || can(regex("^${local.current_recovery_digest_secret_arn}[A-Za-z0-9]{6}$", trimspace(var.recovery_digest_key_secret_arn)))) &&
can(regex("^${local.current_email_verification_envelope_secret_arn}[A-Za-z0-9]{6}$", trimspace(var.email_verification_envelope_key_secret_arn))) &&
can(regex("^${local.current_registration_admission_secret_arn}[A-Za-z0-9]{6}$", trimspace(var.registration_admission_key_secret_arn))) &&
can(regex("^${local.current_iae_worker_signing_secret_arn}[A-Za-z0-9]{6}$", trimspace(var.iae_worker_capability_signing_key_secret_arn)))
diff --git a/infrastructure/aws/modules/compute/variables.tf b/infrastructure/aws/modules/compute/variables.tf
index d7885697..57c5ecdd 100644
--- a/infrastructure/aws/modules/compute/variables.tf
+++ b/infrastructure/aws/modules/compute/variables.tf
@@ -113,6 +113,20 @@ variable "email_verification_digest_key_secret_arn" {
}
}
+variable "recovery_digest_key_secret_arn" {
+ type = string
+ description = "Dedicated whole Secrets Manager ARN containing the base64url-encoded 32-byte account-recovery HMAC key."
+ default = ""
+
+ validation {
+ condition = trimspace(var.recovery_digest_key_secret_arn) == "" || can(regex(
+ "^arn:[^:]+:secretsmanager:${var.region}:[0-9]{12}:secret:databreeze/${var.name}/iam/recovery-digest-key-[A-Za-z0-9]{6}$",
+ trimspace(var.recovery_digest_key_secret_arn),
+ ))
+ error_message = "recovery_digest_key_secret_arn must be empty for legacy module consumers or a whole DataBreeze recovery digest-key secret ARN in var.region."
+ }
+}
+
variable "email_verification_envelope_key_secret_arn" {
type = string
description = "Dedicated whole Secrets Manager ARN containing the base64url-encoded 32-byte email-verification envelope key."
diff --git a/infrastructure/aws/modules/security/main.tf b/infrastructure/aws/modules/security/main.tf
index 66a49285..af17a5b7 100644
--- a/infrastructure/aws/modules/security/main.tf
+++ b/infrastructure/aws/modules/security/main.tf
@@ -138,6 +138,14 @@ resource "aws_secretsmanager_secret" "email_verification_digest_key" {
tags = merge(local.common_tags, { Name = "${var.name}-email-verification-digest-key" })
}
+resource "aws_secretsmanager_secret" "recovery_digest_key" {
+ name = "databreeze/${var.name}/iam/recovery-digest-key"
+ description = "Owner-populated base64url-encoded 32-byte account-recovery HMAC key; value is injected out of band and never stored in Terraform."
+ kms_key_id = aws_kms_key.platform.arn
+ recovery_window_in_days = 30
+ tags = merge(local.common_tags, { Name = "${var.name}-recovery-digest-key" })
+}
+
resource "aws_secretsmanager_secret" "email_verification_envelope_key" {
name = "databreeze/${var.name}/iam/email-verification-envelope-key"
description = "Owner-populated base64url-encoded 32-byte email-verification envelope key; value is injected out of band and never stored in Terraform."
diff --git a/infrastructure/aws/modules/security/outputs.tf b/infrastructure/aws/modules/security/outputs.tf
index ec6a6517..1a38a985 100644
--- a/infrastructure/aws/modules/security/outputs.tf
+++ b/infrastructure/aws/modules/security/outputs.tf
@@ -18,6 +18,10 @@ output "email_verification_digest_key_secret_arn" {
value = aws_secretsmanager_secret.email_verification_digest_key.arn
}
+output "recovery_digest_key_secret_arn" {
+ value = aws_secretsmanager_secret.recovery_digest_key.arn
+}
+
output "email_verification_envelope_key_secret_arn" {
value = aws_secretsmanager_secret.email_verification_envelope_key.arn
}
diff --git a/infrastructure/aws/modules/security/tests/platform-key-policy.tofutest.hcl b/infrastructure/aws/modules/security/tests/platform-key-policy.tofutest.hcl
index 7ab4ed24..6005a53a 100644
--- a/infrastructure/aws/modules/security/tests/platform-key-policy.tofutest.hcl
+++ b/infrastructure/aws/modules/security/tests/platform-key-policy.tofutest.hcl
@@ -118,6 +118,25 @@ run "creates_dedicated_whole_service_account_envelope_key_secret" {
}
}
+run "creates_dedicated_whole_recovery_digest_key_secret" {
+ command = plan
+
+ variables {
+ name = "production"
+ region = "ap-southeast-1"
+ }
+
+ assert {
+ condition = (
+ aws_secretsmanager_secret.recovery_digest_key.name == "databreeze/production/iam/recovery-digest-key" &&
+ strcontains(aws_secretsmanager_secret.recovery_digest_key.description, "base64url-encoded 32-byte") &&
+ aws_secretsmanager_secret.recovery_digest_key.recovery_window_in_days == 30 &&
+ output.recovery_digest_key_secret_arn == aws_secretsmanager_secret.recovery_digest_key.arn
+ )
+ error_message = "The security module must create and publish a dedicated recoverable whole secret for the account-recovery HMAC key without storing its value in Terraform."
+ }
+}
+
run "creates_separate_api_signing_and_worker_bearer_secrets" {
command = plan
diff --git a/infrastructure/lightsail/.env.example b/infrastructure/lightsail/.env.example
index 35d65c45..41ae8537 100644
--- a/infrastructure/lightsail/.env.example
+++ b/infrastructure/lightsail/.env.example
@@ -31,4 +31,5 @@ WEB_IMAGE=ghcr.io/OWNER/databreeze-web:CHANGE_ME_COMMIT_SHA
DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY=CHANGE_ME_43_CHAR_BASE64URL_KEY
DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY=CHANGE_ME_43_CHAR_BASE64URL_KEY
DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY=CHANGE_ME_43_CHAR_BASE64URL_KEY
+DATABREEZE_IAM_RECOVERY_DIGEST_KEY=CHANGE_ME_43_CHAR_BASE64URL_KEY
DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY=CHANGE_ME_43_CHAR_BASE64URL_KEY
diff --git a/infrastructure/lightsail/README.md b/infrastructure/lightsail/README.md
index d88a1628..289431cf 100644
--- a/infrastructure/lightsail/README.md
+++ b/infrastructure/lightsail/README.md
@@ -55,8 +55,23 @@ Redis, and MinIO API ports are never published publicly.
```
Gmail requires 2-Step Verification and an App Password; never use a normal
- account password. The sender address must match the SMTP username. Use SES
- separately for a wider production rollout.
+ account password. The sender address must match the SMTP username. The same
+ transport sends OTP verification and password-recovery messages. Password
+ recovery also requires a separate `DATABREEZE_IAM_RECOVERY_DIGEST_KEY` in
+ `/opt/databreeze/.env`; generate it on the server with the command below and
+ never commit or paste the value:
+
+ ```bash
+ value="$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
+ if sudo grep -q '^DATABREEZE_IAM_RECOVERY_DIGEST_KEY=' /opt/databreeze/.env; then
+ sudo sed -i "s|^DATABREEZE_IAM_RECOVERY_DIGEST_KEY=.*|DATABREEZE_IAM_RECOVERY_DIGEST_KEY=${value}|" /opt/databreeze/.env
+ else
+ echo "DATABREEZE_IAM_RECOVERY_DIGEST_KEY=${value}" | sudo tee -a /opt/databreeze/.env >/dev/null
+ fi
+ unset value
+ ```
+
+ Use SES separately for a wider production rollout.
- OpenAI is disabled by default. Never copy an API key into this file through
source control or a CI log; place it only in the server’s protected secret
mechanism after rotating the exposed key.
diff --git a/infrastructure/lightsail/compose.pilot.yml b/infrastructure/lightsail/compose.pilot.yml
index c1068609..d2bed606 100644
--- a/infrastructure/lightsail/compose.pilot.yml
+++ b/infrastructure/lightsail/compose.pilot.yml
@@ -157,6 +157,8 @@ services:
DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY:
${DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY:?DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY must
be set}
+ DATABREEZE_IAM_RECOVERY_DIGEST_KEY:
+ ${DATABREEZE_IAM_RECOVERY_DIGEST_KEY:?DATABREEZE_IAM_RECOVERY_DIGEST_KEY must be set}
DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY:
${DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY:?DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY
must be set}
diff --git a/infrastructure/local/.env.example b/infrastructure/local/.env.example
index f554c73d..59ffa873 100644
--- a/infrastructure/local/.env.example
+++ b/infrastructure/local/.env.example
@@ -54,4 +54,5 @@ DATABREEZE_IAM_EMAIL_FROM_ADDRESS=verify@databreeze.local
DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY=ERERERERERERERERERERERERERERERERERERERERERE
DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY=EhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhI
DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY=ExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExM
+DATABREEZE_IAM_RECOVERY_DIGEST_KEY=FBUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRU
DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY=FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ
diff --git a/infrastructure/local/README.md b/infrastructure/local/README.md
index 993898ac..43a3a9cd 100644
--- a/infrastructure/local/README.md
+++ b/infrastructure/local/README.md
@@ -65,7 +65,7 @@ The Web URL is ; it uses Vite HMR and
proxies API paths to the watched host API at . The
`dev:api` watcher uses the database-backed local composition, runs Prisma
generation/migrations, and talks to the Docker PostgreSQL, Redis, and Mailpit
-services. Registration, OTP, sign-in, refresh, logout, and durable data
+services. Registration, OTP, password reset, sign-in, refresh, logout, and durable data
changes therefore exercise the real local backend while Web source changes
update without a rebuild. The pilot/production Caddy URL is for built-image
validation, not HMR.
@@ -138,8 +138,8 @@ composition while retaining `NODE_ENV=production`. PostgreSQL and Redis remain
durable authorities, Mailpit is the local email provider, and all application
ports stay on the isolated Compose network except the loopback HTTPS gateway.
-Mailpit is the default OTP provider and captures messages at
-. To deliver OTPs to a real Gmail inbox during local
+Mailpit is the default OTP and password-recovery provider and captures messages at
+. To deliver OTP and password-recovery messages to a real Gmail inbox during local
testing, set `DATABREEZE_LOCAL_EMAIL_PROVIDER=gmail` in the ignored
`infrastructure/local/.env`, set the SMTP host to `smtp.gmail.com`, port `465`,
the Gmail account as both SMTP username and sender, and provide a Google App
diff --git a/infrastructure/local/compose.yml b/infrastructure/local/compose.yml
index 397075f2..49a1b451 100644
--- a/infrastructure/local/compose.yml
+++ b/infrastructure/local/compose.yml
@@ -205,6 +205,7 @@ services:
DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY: ${DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY:-ERERERERERERERERERERERERERERERERERERERERERE}
DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY: ${DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY:-EhISEhISEhISEhISEhISEhISEhISEhISEhISEhISEhI}
DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY: ${DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY:-ExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExM}
+ DATABREEZE_IAM_RECOVERY_DIGEST_KEY: ${DATABREEZE_IAM_RECOVERY_DIGEST_KEY:-FBUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRU}
DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY: ${DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY:-FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ}
DATABREEZE_LOCAL_MINIO_ENDPOINT: http://minio:9000
DATABREEZE_LOCAL_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-databreeze}
diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json
index 648cfad6..800e3e95 100644
--- a/services/api/openapi/v1.json
+++ b/services/api/openapi/v1.json
@@ -15338,7 +15338,10 @@
},
"RecoveryRequestDto": {
"type": "object",
- "properties": { "email": { "type": "string", "format": "email", "maxLength": 254 } },
+ "properties": {
+ "email": { "type": "string", "format": "email", "maxLength": 254 },
+ "locale": { "type": "string", "enum": ["vi-VN", "en"], "default": "vi-VN" }
+ },
"required": ["email"]
},
"RecoveryRequestResponseDto": {
diff --git a/services/api/src/features/iam/adapter/aws-ses-password-recovery-delivery.adapter.ts b/services/api/src/features/iam/adapter/aws-ses-password-recovery-delivery.adapter.ts
new file mode 100644
index 00000000..741af277
--- /dev/null
+++ b/services/api/src/features/iam/adapter/aws-ses-password-recovery-delivery.adapter.ts
@@ -0,0 +1,52 @@
+import type { RecoveryDeliveryPortV1 } from '../application/recovery-repository.port.js';
+import {
+ createPasswordRecoveryUrlV1,
+ validPasswordRecoveryEmailV1,
+ validRecoveryExpiryV1,
+} from './password-recovery-delivery.utils.js';
+import {
+ createPasswordRecoveryMessageContentV1,
+ type PasswordRecoveryMessageContentV1,
+} from './password-recovery-message-content.js';
+import type {
+ SesEmailMessageV1,
+ SesEmailSenderPortV1,
+} from './aws-ses-email-verification-delivery.adapter.js';
+
+/** IAM-015: production password recovery through the provider-neutral SES sender boundary. */
+export class AwsSesPasswordRecoveryDeliveryAdapter implements RecoveryDeliveryPortV1 {
+ public constructor(
+ private readonly sender: SesEmailSenderPortV1,
+ private readonly fromAddress: string,
+ private readonly webOrigin: string,
+ ) {
+ if (!validPasswordRecoveryEmailV1(fromAddress)) {
+ throw new Error('IAM_EMAIL_DELIVERY_CONFIGURATION_INVALID');
+ }
+ }
+
+ public async deliver(input: Parameters[0]): Promise {
+ const resetUrl = createPasswordRecoveryUrlV1(this.webOrigin, input.locale, input.rawToken);
+ const messageContent: PasswordRecoveryMessageContentV1 | undefined = resetUrl
+ ? createPasswordRecoveryMessageContentV1(input.locale, resetUrl)
+ : undefined;
+ if (
+ !validPasswordRecoveryEmailV1(input.recipientEmail) ||
+ !validRecoveryExpiryV1(input.expiresAt) ||
+ !resetUrl ||
+ !messageContent
+ ) {
+ throw new Error('IAM_EMAIL_DELIVERY_INPUT_INVALID');
+ }
+ const message: SesEmailMessageV1 = Object.freeze({
+ fromAddress: this.fromAddress,
+ toAddress: input.recipientEmail,
+ ...messageContent,
+ });
+ try {
+ await this.sender.sendEmail(message);
+ } catch {
+ throw new Error('IAM_EMAIL_DELIVERY_UNAVAILABLE');
+ }
+ }
+}
diff --git a/services/api/src/features/iam/adapter/password-recovery-delivery.utils.ts b/services/api/src/features/iam/adapter/password-recovery-delivery.utils.ts
new file mode 100644
index 00000000..4ee72f95
--- /dev/null
+++ b/services/api/src/features/iam/adapter/password-recovery-delivery.utils.ts
@@ -0,0 +1,79 @@
+import type { RecoveryLocaleV1 } from '../application/recovery-repository.port.js';
+
+const EMAIL_ADDRESS_PATTERN_V1 = /^[^\s@]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/u;
+const RECOVERY_TOKEN_PATTERN_V1 = /^[A-Za-z0-9_-]{32,512}$/u;
+const RESET_PASSWORD_PATH_PATTERN_V1 = /^\/(?:vi-VN|en)\/reset-password$/u;
+
+export function validPasswordRecoveryEmailV1(value: string): boolean {
+ return (
+ value.length <= 320 &&
+ !value.includes('\r') &&
+ !value.includes('\n') &&
+ EMAIL_ADDRESS_PATTERN_V1.test(value)
+ );
+}
+
+export function validRecoveryLocaleV1(value: string): value is RecoveryLocaleV1 {
+ return value === 'vi-VN' || value === 'en';
+}
+
+export function validRecoveryTokenV1(value: string): boolean {
+ return RECOVERY_TOKEN_PATTERN_V1.test(value) && !/\p{Cc}/u.test(value);
+}
+
+export function validRecoveryExpiryV1(value: string): boolean {
+ try {
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
+ } catch {
+ return false;
+ }
+}
+
+function loopbackHostname(value: string): boolean {
+ return value === '127.0.0.1' || value === 'localhost' || value === '::1' || value === '[::1]';
+}
+
+function validRecoveryOriginV1(value: string, allowLoopbackHttp: boolean): boolean {
+ try {
+ const parsed = new URL(value);
+ const validProtocol =
+ parsed.protocol === 'https:' ||
+ (allowLoopbackHttp && parsed.protocol === 'http:' && loopbackHostname(parsed.hostname));
+ return (
+ validProtocol &&
+ parsed.hostname.length > 0 &&
+ parsed.username === '' &&
+ parsed.password === '' &&
+ parsed.pathname === '/' &&
+ parsed.search === '' &&
+ parsed.hash === '' &&
+ value === parsed.origin
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function createPasswordRecoveryUrlV1(
+ webOrigin: string,
+ locale: RecoveryLocaleV1,
+ rawToken: string,
+ allowLoopbackHttp = false,
+): string | undefined {
+ if (
+ !validRecoveryOriginV1(webOrigin, allowLoopbackHttp) ||
+ !validRecoveryLocaleV1(locale) ||
+ !validRecoveryTokenV1(rawToken)
+ ) {
+ return undefined;
+ }
+ try {
+ const url = new URL(`/${locale}/reset-password`, webOrigin);
+ url.searchParams.set('token', rawToken);
+ if (!RESET_PASSWORD_PATH_PATTERN_V1.test(url.pathname)) return undefined;
+ return url.toString();
+ } catch {
+ return undefined;
+ }
+}
diff --git a/services/api/src/features/iam/adapter/password-recovery-message-content.ts b/services/api/src/features/iam/adapter/password-recovery-message-content.ts
new file mode 100644
index 00000000..004244f0
--- /dev/null
+++ b/services/api/src/features/iam/adapter/password-recovery-message-content.ts
@@ -0,0 +1,125 @@
+import type { RecoveryLocaleV1 } from '../application/recovery-repository.port.js';
+import { validRecoveryLocaleV1 } from './password-recovery-delivery.utils.js';
+
+const RECOVERY_LINK_LIFETIME_MINUTES = 60;
+
+export interface PasswordRecoveryMessageContentV1 {
+ readonly subject: string;
+ readonly textBody: string;
+ readonly htmlBody: string;
+}
+
+const COPY = {
+ 'vi-VN': {
+ subject: 'Đặt lại mật khẩu DataBreeze',
+ eyebrow: 'BẢO MẬT TÀI KHOẢN',
+ title: 'Đặt lại mật khẩu',
+ intro: 'Bạn vừa yêu cầu tạo một mật khẩu mới cho tài khoản DataBreeze của mình.',
+ button: 'Tạo mật khẩu mới',
+ fallback: 'Nếu nút không hoạt động, hãy mở liên kết này:',
+ expiry: `Liên kết này có hiệu lực trong ${RECOVERY_LINK_LIFETIME_MINUTES} phút và chỉ dùng được một lần.`,
+ safety:
+ 'Nếu bạn không yêu cầu thay đổi này, hãy bỏ qua email. Mật khẩu hiện tại của bạn vẫn được giữ nguyên.',
+ footer: 'Email tự động từ DataBreeze. Vui lòng không trả lời email này.',
+ },
+ en: {
+ subject: 'Reset your DataBreeze password',
+ eyebrow: 'ACCOUNT SECURITY',
+ title: 'Reset your password',
+ intro: 'You requested a new password for your DataBreeze account.',
+ button: 'Create a new password',
+ fallback: 'If the button does not work, open this link:',
+ expiry: `This link expires in ${RECOVERY_LINK_LIFETIME_MINUTES} minutes and can only be used once.`,
+ safety:
+ 'If you did not request this change, you can ignore this email. Your current password will remain unchanged.',
+ footer: 'Automated email from DataBreeze. Please do not reply.',
+ },
+} as const satisfies Record>;
+
+function escapeHtml(value: string): string {
+ return value.replace(
+ /[&<>"']/gu,
+ (character) =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ??
+ character,
+ );
+}
+
+function validResetUrl(value: string): boolean {
+ try {
+ const parsed = new URL(value);
+ const token = parsed.searchParams.get('token');
+ const localHttp =
+ parsed.protocol === 'http:' &&
+ (parsed.hostname === '127.0.0.1' ||
+ parsed.hostname === 'localhost' ||
+ parsed.hostname === '::1' ||
+ parsed.hostname === '[::1]');
+ return (
+ (parsed.protocol === 'https:' || localHttp) &&
+ parsed.username === '' &&
+ parsed.password === '' &&
+ parsed.pathname === `/${parsed.pathname.split('/')[1]}/reset-password` &&
+ (parsed.pathname.startsWith('/en/') || parsed.pathname.startsWith('/vi-VN/')) &&
+ token !== null &&
+ /^[A-Za-z0-9_-]{32,512}$/u.test(token) &&
+ [...parsed.searchParams.keys()].every((key) => key === 'token')
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function createPasswordRecoveryMessageContentV1(
+ localeInput: string,
+ resetUrl: string,
+): PasswordRecoveryMessageContentV1 | undefined {
+ if (!validRecoveryLocaleV1(localeInput) || !validResetUrl(resetUrl)) return undefined;
+ const locale = localeInput;
+ const copy = COPY[locale];
+ const escapedUrl = escapeHtml(resetUrl);
+ const textBody = [
+ copy.title,
+ '',
+ copy.intro,
+ '',
+ `${copy.fallback} ${resetUrl}`,
+ '',
+ copy.expiry,
+ copy.safety,
+ '',
+ copy.footer,
+ ].join('\n');
+ const htmlBody = `
+
+
+
+
+
+
+
+
+
${copy.eyebrow}
+
${copy.title}
+
${copy.intro}
+
+
${copy.fallback}
+
${escapedUrl}
+
${copy.expiry}
+
${copy.safety}
+
+
+
${copy.footer}
+
+
+
+`;
+ return Object.freeze({ subject: copy.subject, textBody, htmlBody });
+}
diff --git a/services/api/src/features/iam/adapter/smtp-password-recovery-delivery.adapter.ts b/services/api/src/features/iam/adapter/smtp-password-recovery-delivery.adapter.ts
new file mode 100644
index 00000000..d749db97
--- /dev/null
+++ b/services/api/src/features/iam/adapter/smtp-password-recovery-delivery.adapter.ts
@@ -0,0 +1,57 @@
+import type { RecoveryDeliveryPortV1 } from '../application/recovery-repository.port.js';
+import {
+ createPasswordRecoveryUrlV1,
+ validPasswordRecoveryEmailV1,
+ validRecoveryExpiryV1,
+} from './password-recovery-delivery.utils.js';
+import {
+ createPasswordRecoveryMessageContentV1,
+ type PasswordRecoveryMessageContentV1,
+} from './password-recovery-message-content.js';
+import {
+ validSmtpAddressV1,
+ type SmtpMessageV1,
+ type SmtpSenderPortV1,
+} from './mailpit-smtp-email-verification-delivery.adapter.js';
+
+/** IAM-015/IAM-022: sends a single-use reset link through the configured local SMTP provider. */
+export class SmtpPasswordRecoveryDeliveryAdapter implements RecoveryDeliveryPortV1 {
+ public constructor(
+ private readonly sender: SmtpSenderPortV1,
+ private readonly fromAddress: string,
+ private readonly webOrigin: string,
+ private readonly allowLoopbackHttp = false,
+ ) {
+ if (!validSmtpAddressV1(fromAddress)) throw new Error('IAM_LOCAL_EMAIL_CONFIGURATION_INVALID');
+ }
+
+ public async deliver(input: Parameters[0]): Promise {
+ const resetUrl = createPasswordRecoveryUrlV1(
+ this.webOrigin,
+ input.locale,
+ input.rawToken,
+ this.allowLoopbackHttp,
+ );
+ const messageContent: PasswordRecoveryMessageContentV1 | undefined = resetUrl
+ ? createPasswordRecoveryMessageContentV1(input.locale, resetUrl)
+ : undefined;
+ if (
+ !validPasswordRecoveryEmailV1(input.recipientEmail) ||
+ !validRecoveryExpiryV1(input.expiresAt) ||
+ !resetUrl ||
+ !messageContent
+ ) {
+ throw new Error('IAM_LOCAL_EMAIL_INPUT_INVALID');
+ }
+ const message: SmtpMessageV1 = Object.freeze({
+ fromAddress: this.fromAddress,
+ toAddresses: Object.freeze([input.recipientEmail] as const),
+ ...messageContent,
+ });
+ try {
+ await this.sender.send(message);
+ } catch {
+ throw new Error('IAM_LOCAL_EMAIL_DELIVERY_UNAVAILABLE');
+ }
+ }
+}
diff --git a/services/api/src/features/iam/api/recovery.controller.ts b/services/api/src/features/iam/api/recovery.controller.ts
index eb217732..e7870f17 100644
--- a/services/api/src/features/iam/api/recovery.controller.ts
+++ b/services/api/src/features/iam/api/recovery.controller.ts
@@ -40,7 +40,7 @@ export class RecoveryController {
@ApiServiceUnavailableResponse({ description: 'Recovery delivery is unavailable.' })
async request(@Body() input: RecoveryRequestDto): Promise {
if (this.recovery === undefined) throw new RecoveryProblemError('RECOVERY_UNAVAILABLE');
- const result = await this.recovery.request(input.email);
+ const result = await this.recovery.request(input.email, input.locale);
if (!result.accepted) {
throw new RecoveryProblemError(
result.code === 'RECOVERY_UNAVAILABLE'
diff --git a/services/api/src/features/iam/api/recovery.dto.ts b/services/api/src/features/iam/api/recovery.dto.ts
index ef91af94..ab49aa5f 100644
--- a/services/api/src/features/iam/api/recovery.dto.ts
+++ b/services/api/src/features/iam/api/recovery.dto.ts
@@ -1,5 +1,5 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class RecoveryRequestDto {
@ApiProperty({ format: 'email', maxLength: 254 })
@@ -8,6 +8,11 @@ export class RecoveryRequestDto {
@MinLength(3)
@MaxLength(254)
email!: string;
+
+ @ApiPropertyOptional({ enum: ['vi-VN', 'en'], default: 'vi-VN' })
+ @IsOptional()
+ @IsIn(['vi-VN', 'en'])
+ locale?: 'vi-VN' | 'en';
}
export class RecoveryCompleteDto {
diff --git a/services/api/src/features/iam/application/recovery-repository.port.ts b/services/api/src/features/iam/application/recovery-repository.port.ts
index f84c4a17..e1e17701 100644
--- a/services/api/src/features/iam/application/recovery-repository.port.ts
+++ b/services/api/src/features/iam/application/recovery-repository.port.ts
@@ -36,6 +36,8 @@ export interface RecoveryDigestPortV1 {
digestEmail(normalizedEmail: string): string;
}
+export type RecoveryLocaleV1 = 'en' | 'vi-VN';
+
/** Optional abuse-control boundary; callers must not use it to reveal account existence. */
export interface RecoveryAdmissionPortV1 {
allow(keyDigest: string, issuedAt: string): Promise;
@@ -47,6 +49,7 @@ export interface RecoveryDeliveryPortV1 {
readonly recipientEmail: string;
readonly rawToken: string;
readonly expiresAt: string;
+ readonly locale: RecoveryLocaleV1;
}): Promise;
}
diff --git a/services/api/src/features/iam/application/recovery.service.ts b/services/api/src/features/iam/application/recovery.service.ts
index f7f67e44..d3f91b2b 100644
--- a/services/api/src/features/iam/application/recovery.service.ts
+++ b/services/api/src/features/iam/application/recovery.service.ts
@@ -18,6 +18,7 @@ import type {
RecoveryDeliveryPortV1,
RecoveryDigestPortV1,
RecoveryFailureCodeV1,
+ RecoveryLocaleV1,
RecoveryRepositoryPortV1,
RecoveryRequestResultV1,
} from './recovery-repository.port.js';
@@ -55,10 +56,16 @@ function stable(input: unknown): StableIdentifierV1 | undefined {
function rawToken(input: unknown): string | undefined {
if (typeof input !== 'string' || input.length < 32 || input.length > 512) return undefined;
+ if (!/^[A-Za-z0-9_-]+$/u.test(input)) return undefined;
if (/\p{Cc}/u.test(input)) return undefined;
return input;
}
+function recoveryLocale(input: unknown): RecoveryLocaleV1 | undefined {
+ if (input === undefined) return 'vi-VN';
+ return input === 'en' || input === 'vi-VN' ? input : undefined;
+}
+
function timestamp(clock: RecoveryClockV1 | undefined): string | undefined {
try {
const value = clock?.now() ?? new Date();
@@ -87,9 +94,14 @@ export class RecoveryService {
public constructor(private readonly ports: RecoveryServicePortsV1) {}
- public async request(emailInput: unknown): Promise {
+ public async request(
+ emailInput: unknown,
+ localeInput: unknown = 'vi-VN',
+ ): Promise {
const normalized = normalizeEmailAddressV1(emailInput);
if (!normalized.accepted) return inputRejected('INVALID_INPUT');
+ const locale = recoveryLocale(localeInput);
+ if (!locale) return inputRejected('INVALID_INPUT');
const issuedAt = timestamp(this.ports.clock);
if (!issuedAt) return unavailable();
let challengeId: string;
@@ -157,6 +169,7 @@ export class RecoveryService {
recipientEmail: normalized.value,
rawToken: raw,
expiresAt: issued.expiresAt,
+ locale,
});
} catch {
try {
diff --git a/services/api/src/platform/local-database.composition.ts b/services/api/src/platform/local-database.composition.ts
index 8f4db721..e2235919 100644
--- a/services/api/src/platform/local-database.composition.ts
+++ b/services/api/src/platform/local-database.composition.ts
@@ -12,6 +12,7 @@ import { Argon2PasswordHasherAdapter } from '../features/iam/adapter/argon2-pass
import { Aes256GcmEmailVerificationEnvelopeAdapter } from '../features/iam/adapter/email-verification-envelope.adapter.js';
import { HmacSha256EmailVerificationDigestAdapter } from '../features/iam/adapter/in-memory-email-verification-repository.adapter.js';
import { HmacSha256IamRegistrationAdmissionDigestAdapter } from '../features/iam/adapter/iam-registration-crypto.adapter.js';
+import { HmacSha256IamRecoveryDigestAdapter } from '../features/iam/adapter/iam-recovery-crypto.adapter.js';
import {
GmailSmtpEmailVerificationDeliveryAdapter,
GmailSmtpSenderAdapter,
@@ -23,6 +24,7 @@ import {
type NodeLoopbackSmtpOptionsV1,
type SmtpSenderPortV1,
} from '../features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.js';
+import { SmtpPasswordRecoveryDeliveryAdapter } from '../features/iam/adapter/smtp-password-recovery-delivery.adapter.js';
import {
NodeRedisEvalClientAdapter,
type NodeRedisEvalPortV1,
@@ -430,6 +432,7 @@ async function createComposeDatabaseComposition(
environment,
'DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY',
);
+ const recoveryDigestKey = localManagedKey(environment, 'DATABREEZE_IAM_RECOVERY_DIGEST_KEY');
localManagedKey(environment, 'DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY');
const serviceAccountKey = environment['DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY']?.trim();
if (!serviceAccountKey) throw new Error(LOCAL_IAM_KEY_ERROR);
@@ -503,10 +506,17 @@ async function createComposeDatabaseComposition(
),
emailVerificationDigest: new HmacSha256EmailVerificationDigestAdapter(emailDigestKey),
emailVerificationEnvelope: new Aes256GcmEmailVerificationEnvelopeAdapter(emailEnvelopeKey),
+ recoveryDigest: new HmacSha256IamRecoveryDigestAdapter(recoveryDigestKey),
emailVerificationDelivery:
emailProvider === 'gmail'
? new GmailSmtpEmailVerificationDeliveryAdapter(smtpSender, fromAddress)
: new MailpitSmtpEmailVerificationDeliveryAdapter(smtpSender, fromAddress),
+ recoveryDelivery: new SmtpPasswordRecoveryDeliveryAdapter(
+ smtpSender,
+ fromAddress,
+ httpsOrigin,
+ profile === LOCAL_RUNTIME_PROFILE && environment['DATABREEZE_LOCAL_HMR_HTTP'] === 'true',
+ ),
identityBootstrapPolicyProvisionerFactory: (transaction: unknown) =>
new PrismaInitialWorkspacePolicyProvisionerAdapter(
transaction as InitialWorkspacePolicyDatabaseClientV1,
diff --git a/services/api/src/platform/production-database.composition.ts b/services/api/src/platform/production-database.composition.ts
index 36dd4809..dd14f309 100644
--- a/services/api/src/platform/production-database.composition.ts
+++ b/services/api/src/platform/production-database.composition.ts
@@ -15,6 +15,7 @@ import { UnavailableMfaFactorProofVerifier } from '../features/iam/application/m
import { HmacSha256EmailVerificationDigestAdapter } from '../features/iam/adapter/in-memory-email-verification-repository.adapter.js';
import { Aes256GcmEmailVerificationEnvelopeAdapter } from '../features/iam/adapter/email-verification-envelope.adapter.js';
import { HmacSha256IamRegistrationAdmissionDigestAdapter } from '../features/iam/adapter/iam-registration-crypto.adapter.js';
+import { HmacSha256IamRecoveryDigestAdapter } from '../features/iam/adapter/iam-recovery-crypto.adapter.js';
import {
RedisEvalRecoveryAdmissionCounterAdapter,
RedisRecoveryAdmissionAdapter,
@@ -24,6 +25,7 @@ import {
type NodeRedisEvalPortV1,
} from '../features/iam/adapter/node-redis-admission-counter.adapter.js';
import { AwsSesEmailVerificationDeliveryAdapter } from '../features/iam/adapter/aws-ses-email-verification-delivery.adapter.js';
+import { AwsSesPasswordRecoveryDeliveryAdapter } from '../features/iam/adapter/aws-ses-password-recovery-delivery.adapter.js';
import {
AwsSesV2SenderAdapter,
type AwsSesV2SendClientPortV1,
@@ -50,6 +52,7 @@ export const PRODUCTION_IAM_EMAIL_VERIFICATION_SECRET_ERROR =
'PRODUCTION_IAM_EMAIL_VERIFICATION_SECRET_INVALID';
export const PRODUCTION_IAM_REGISTRATION_ADMISSION_SECRET_ERROR =
'PRODUCTION_IAM_REGISTRATION_ADMISSION_SECRET_INVALID';
+export const PRODUCTION_IAM_RECOVERY_SECRET_ERROR = 'PRODUCTION_IAM_RECOVERY_SECRET_INVALID';
export const PRODUCTION_IAM_REDIS_URL_ERROR = 'PRODUCTION_IAM_REDIS_URL_INVALID';
export const PRODUCTION_IAM_EMAIL_DELIVERY_CONFIGURATION_ERROR =
'PRODUCTION_IAM_EMAIL_DELIVERY_CONFIGURATION_INVALID';
@@ -78,6 +81,7 @@ const PRODUCTION_SERVICE_ACCOUNT_SECRET_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
const PRODUCTION_IAM_EMAIL_DIGEST_KEY_ENV = 'DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY';
const PRODUCTION_IAM_EMAIL_ENVELOPE_KEY_ENV = 'DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY';
const PRODUCTION_IAM_REGISTRATION_ADMISSION_KEY_ENV = 'DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY';
+const PRODUCTION_IAM_RECOVERY_DIGEST_KEY_ENV = 'DATABREEZE_IAM_RECOVERY_DIGEST_KEY';
const PRODUCTION_IAM_REDIS_URL_ENV = 'DATABREEZE_REDIS_URL';
const PRODUCTION_IAM_EMAIL_FROM_ADDRESS_ENV = 'DATABREEZE_IAM_EMAIL_FROM_ADDRESS';
const PRODUCTION_IAM_EMAIL_SES_REGION_ENV = 'DATABREEZE_IAM_EMAIL_SES_REGION';
@@ -153,6 +157,7 @@ export type ProductionDatabaseOptions = {
readonly registrationAdmissionDigest: NonNullable<
ApiApplicationOptions['registrationAdmissionDigest']
>;
+ readonly recoveryDigest: NonNullable;
readonly emailVerificationDigest: NonNullable;
readonly emailVerificationEnvelope: NonNullable<
ApiApplicationOptions['emailVerificationEnvelope']
@@ -160,6 +165,7 @@ export type ProductionDatabaseOptions = {
readonly emailVerificationDelivery: NonNullable<
ApiApplicationOptions['emailVerificationDelivery']
>;
+ readonly recoveryDelivery: NonNullable;
readonly mfaFactorProofVerifier: NonNullable;
readonly deviceEnrollmentProofVerifier: NonNullable<
ApiApplicationOptions['deviceEnrollmentProofVerifier']
@@ -621,6 +627,7 @@ function optionsFor(
readonly registrationAdmissionDigest: NonNullable<
ApiApplicationOptions['registrationAdmissionDigest']
>;
+ readonly recoveryDigest: NonNullable;
readonly emailVerificationDigest: NonNullable;
readonly emailVerificationEnvelope: NonNullable<
ApiApplicationOptions['emailVerificationEnvelope']
@@ -628,6 +635,7 @@ function optionsFor(
readonly emailVerificationDelivery: NonNullable<
ApiApplicationOptions['emailVerificationDelivery']
>;
+ readonly recoveryDelivery: NonNullable;
},
): ProductionDatabaseOptions {
return {
@@ -726,6 +734,11 @@ export async function createProductionDatabaseComposition(
PRODUCTION_IAM_REGISTRATION_ADMISSION_KEY_ENV,
PRODUCTION_IAM_REGISTRATION_ADMISSION_SECRET_ERROR,
);
+ const recoveryDigestKey = productionManaged32ByteKey(
+ environment,
+ PRODUCTION_IAM_RECOVERY_DIGEST_KEY_ENV,
+ PRODUCTION_IAM_RECOVERY_SECRET_ERROR,
+ );
const redisUrl = productionIamRedisUrl(environment);
const emailDeliveryConfiguration = productionIamEmailDeliveryConfiguration(environment);
const artifactStorageConfiguration = productionIaeArtifactStorageConfiguration(environment);
@@ -753,6 +766,7 @@ export async function createProductionDatabaseComposition(
};
let emailVerificationDelivery: AwsSesEmailVerificationDeliveryAdapter;
+ let recoveryDelivery: AwsSesPasswordRecoveryDeliveryAdapter;
try {
redisClient = dependencies.createRedisClient
? dependencies.createRedisClient(redisUrl)
@@ -760,9 +774,17 @@ export async function createProductionDatabaseComposition(
const sesClient = dependencies.createSesClient
? dependencies.createSesClient(emailDeliveryConfiguration.region)
: new SESv2Client({ region: emailDeliveryConfiguration.region });
+ const sesSender = new AwsSesV2SenderAdapter(sesClient);
+ const browserOrigin = requestContext.csrf?.allowedOrigins?.[0];
+ if (!browserOrigin) throw new Error(PRODUCTION_IAM_EMAIL_DELIVERY_CONFIGURATION_ERROR);
emailVerificationDelivery = new AwsSesEmailVerificationDeliveryAdapter(
- new AwsSesV2SenderAdapter(sesClient),
+ sesSender,
+ emailDeliveryConfiguration.fromAddress,
+ );
+ recoveryDelivery = new AwsSesPasswordRecoveryDeliveryAdapter(
+ sesSender,
emailDeliveryConfiguration.fromAddress,
+ browserOrigin,
);
await redisClient.connect();
} catch {
@@ -791,6 +813,7 @@ export async function createProductionDatabaseComposition(
registrationAdmissionDigest: new HmacSha256IamRegistrationAdmissionDigestAdapter(
registrationAdmissionKey,
),
+ recoveryDigest: new HmacSha256IamRecoveryDigestAdapter(recoveryDigestKey),
emailVerificationDigest: new HmacSha256EmailVerificationDigestAdapter(
emailVerificationDigestKey,
),
@@ -798,6 +821,7 @@ export async function createProductionDatabaseComposition(
emailVerificationEnvelopeKey,
),
emailVerificationDelivery,
+ recoveryDelivery,
});
try {
diff --git a/services/api/test/features/iam/password-recovery-delivery.adapter.test.ts b/services/api/test/features/iam/password-recovery-delivery.adapter.test.ts
new file mode 100644
index 00000000..bf1b2341
--- /dev/null
+++ b/services/api/test/features/iam/password-recovery-delivery.adapter.test.ts
@@ -0,0 +1,177 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { AwsSesPasswordRecoveryDeliveryAdapter } from '../../../src/features/iam/adapter/aws-ses-password-recovery-delivery.adapter.js';
+import { SmtpPasswordRecoveryDeliveryAdapter } from '../../../src/features/iam/adapter/smtp-password-recovery-delivery.adapter.js';
+import type { SesEmailMessageV1 } from '../../../src/features/iam/adapter/aws-ses-email-verification-delivery.adapter.js';
+import type { SmtpMessageV1 } from '../../../src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.js';
+import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1';
+
+const challengeId = '00000000-0000-4000-8000-000000000001' as StableIdentifierV1;
+const rawToken = 'r'.repeat(43);
+const expiresAt = '2026-08-03T01:00:00.000Z';
+
+void test('[IAM-015] SMTP recovery delivery sends a localized, origin-bound reset link without correlation metadata', async () => {
+ const messages: SmtpMessageV1[] = [];
+ const delivery = new SmtpPasswordRecoveryDeliveryAdapter(
+ { send: async (message) => void messages.push(message) },
+ 'support@databreeze.local',
+ 'https://databreeze.tech',
+ );
+
+ await delivery.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken,
+ expiresAt,
+ locale: 'en',
+ });
+
+ assert.equal(messages.length, 1);
+ assert.equal(messages[0]?.subject, 'Reset your DataBreeze password');
+ assert.match(
+ messages[0]?.textBody ?? '',
+ /https:\/\/databreeze\.tech\/en\/reset-password\?token=/u,
+ );
+ assert.match(messages[0]?.htmlBody ?? '', /Create a new password/u);
+ assert.match(
+ messages[0]?.htmlBody ?? '',
+ /https:\/\/databreeze\.tech\/en\/reset-password\?token=/u,
+ );
+ assert.match(messages[0]?.textBody ?? '', /60 minutes/u);
+ assert.equal(JSON.stringify(messages).includes(challengeId), false);
+});
+
+void test('[IAM-015] local HMR recovery delivery permits only an explicit loopback HTTP origin', async () => {
+ const messages: SmtpMessageV1[] = [];
+ const delivery = new SmtpPasswordRecoveryDeliveryAdapter(
+ { send: async (message) => void messages.push(message) },
+ 'support@databreeze.local',
+ 'http://127.0.0.1:5173',
+ true,
+ );
+
+ await delivery.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken,
+ expiresAt,
+ locale: 'vi-VN',
+ });
+
+ assert.equal(messages[0]?.subject, 'Đặt lại mật khẩu DataBreeze');
+ assert.match(
+ messages[0]?.textBody ?? '',
+ /http:\/\/127\.0\.0\.1:5173\/vi-VN\/reset-password\?token=/u,
+ );
+});
+
+void test('[IAM-015] recovery delivery rejects invalid origin, token, expiry, and locale before sending', async () => {
+ const send = async () => {
+ throw new Error('must not send');
+ };
+ const invalidOrigin = new SmtpPasswordRecoveryDeliveryAdapter(
+ { send },
+ 'support@databreeze.local',
+ 'http://public.example.com',
+ true,
+ );
+ const validOrigin = new SmtpPasswordRecoveryDeliveryAdapter(
+ { send },
+ 'support@databreeze.local',
+ 'https://databreeze.tech',
+ );
+
+ await assert.rejects(
+ invalidOrigin.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken,
+ expiresAt,
+ locale: 'en',
+ }),
+ /IAM_LOCAL_EMAIL_INPUT_INVALID/u,
+ );
+ await assert.rejects(
+ validOrigin.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken: `${rawToken}!`,
+ expiresAt,
+ locale: 'en',
+ }),
+ /IAM_LOCAL_EMAIL_INPUT_INVALID/u,
+ );
+ await assert.rejects(
+ validOrigin.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken,
+ expiresAt: 'not-a-timestamp',
+ locale: 'en',
+ }),
+ /IAM_LOCAL_EMAIL_INPUT_INVALID/u,
+ );
+ await assert.rejects(
+ validOrigin.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken,
+ expiresAt,
+ locale: 'fr' as never,
+ }),
+ /IAM_LOCAL_EMAIL_INPUT_INVALID/u,
+ );
+});
+
+void test('[IAM-015] SES recovery delivery maps the same localized message boundary for production', async () => {
+ const messages: SesEmailMessageV1[] = [];
+ const delivery = new AwsSesPasswordRecoveryDeliveryAdapter(
+ { sendEmail: async (message) => void messages.push(message) },
+ 'support@databreeze.tech',
+ 'https://databreeze.tech',
+ );
+
+ await delivery.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken,
+ expiresAt,
+ locale: 'vi-VN',
+ });
+
+ assert.equal(messages[0]?.toAddress, 'owner@example.com');
+ assert.equal(messages[0]?.subject, 'Đặt lại mật khẩu DataBreeze');
+ assert.match(
+ messages[0]?.htmlBody ?? '',
+ /https:\/\/databreeze\.tech\/vi-VN\/reset-password\?token=/u,
+ );
+ assert.equal(JSON.stringify(messages).includes(challengeId), false);
+});
+
+void test('[IAM-015] SES recovery delivery normalizes provider failures without leaking message data', async () => {
+ const delivery = new AwsSesPasswordRecoveryDeliveryAdapter(
+ {
+ sendEmail: async () => {
+ throw new Error('provider body contained owner@example.com and secret-token');
+ },
+ },
+ 'support@databreeze.tech',
+ 'https://databreeze.tech',
+ );
+
+ await assert.rejects(
+ delivery.deliver({
+ challengeId,
+ recipientEmail: 'owner@example.com',
+ rawToken,
+ expiresAt,
+ locale: 'en',
+ }),
+ (error: unknown) =>
+ error instanceof Error &&
+ error.message === 'IAM_EMAIL_DELIVERY_UNAVAILABLE' &&
+ !error.message.includes('owner@example.com') &&
+ !error.message.includes('secret-token'),
+ );
+});
diff --git a/services/api/test/features/iam/recovery-http.test.ts b/services/api/test/features/iam/recovery-http.test.ts
index 3d8374a8..f02b4656 100644
--- a/services/api/test/features/iam/recovery-http.test.ts
+++ b/services/api/test/features/iam/recovery-http.test.ts
@@ -30,7 +30,7 @@ function credentials() {
void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and consumes a link once', async () => {
const repository = new InMemoryRecoveryRepositoryAdapter();
repository.seed({ email: 'user@example.com', userId, activeSessionFamilies: ['family-1'] });
- const delivered: Array<{ readonly rawToken: string }> = [];
+ const delivered: Array<{ readonly rawToken: string; readonly locale: string }> = [];
const { app } = await createApiApplication({
recoveryRepository: repository,
passwordCredentials: credentials(),
@@ -38,9 +38,9 @@ void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and
'test-recovery-key-v1-012345678901234567',
),
recoveryDelivery: {
- deliver: async ({ rawToken: deliveredToken }) => {
+ deliver: async ({ rawToken: deliveredToken, locale }) => {
await Promise.resolve();
- delivered.push({ rawToken: deliveredToken });
+ delivered.push({ rawToken: deliveredToken, locale });
},
},
recoveryIdGenerator: { next: () => challengeId },
@@ -51,7 +51,7 @@ void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and
const known = await app.inject({
method: 'POST',
url: '/v1/auth/recovery',
- payload: { email: 'User@example.com' },
+ payload: { email: 'User@example.com', locale: 'en' },
});
const unknown = await app.inject({
method: 'POST',
@@ -63,6 +63,7 @@ void test('[IAM-015] recovery HTTP keeps known and unknown requests generic and
assert.equal(unknown.statusCode, 202);
assert.deepEqual(unknown.json(), { requested: true });
assert.equal(delivered.length, 1);
+ assert.deepEqual(delivered[0], { rawToken, locale: 'en' });
const completed = await app.inject({
method: 'POST',
diff --git a/services/api/test/platform/local-database-composition.test.ts b/services/api/test/platform/local-database-composition.test.ts
index 22df0fde..355b305f 100644
--- a/services/api/test/platform/local-database-composition.test.ts
+++ b/services/api/test/platform/local-database-composition.test.ts
@@ -7,6 +7,7 @@ import { PasswordCredentialService } from '../../src/features/iam/application/pa
import { RedisRecoveryAdmissionAdapter } from '../../src/features/iam/adapter/redis-recovery-admission.adapter.js';
import { MailpitSmtpEmailVerificationDeliveryAdapter } from '../../src/features/iam/adapter/mailpit-smtp-email-verification-delivery.adapter.js';
import { GmailSmtpEmailVerificationDeliveryAdapter } from '../../src/features/iam/adapter/gmail-smtp-email-verification-delivery.adapter.js';
+import { SmtpPasswordRecoveryDeliveryAdapter } from '../../src/features/iam/adapter/smtp-password-recovery-delivery.adapter.js';
import {
createLocalDatabaseComposition,
LOCAL_DATABASE_URL_ERROR,
@@ -36,6 +37,7 @@ const environment = {
DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY: key(1),
DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY: key(2),
DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY: key(3),
+ DATABREEZE_IAM_RECOVERY_DIGEST_KEY: key(5),
DATABREEZE_SERVICE_ACCOUNT_SECRET_ENVELOPE_KEY: key(4),
} as const;
@@ -108,6 +110,7 @@ void test('[FND-003, IAM-005, IAM-022, IAM-023] local profile composes durable P
composition.options.emailVerificationDelivery instanceof
MailpitSmtpEmailVerificationDeliveryAdapter,
);
+ assert.ok(composition.options.recoveryDelivery instanceof SmtpPasswordRecoveryDeliveryAdapter);
for (const option of [
'credentialDatabase',
'sessionDatabase',
@@ -179,6 +182,7 @@ void test('[IAM-022] explicit local Gmail provider composes TLS SMTP delivery wi
composition.options.emailVerificationDelivery instanceof
GmailSmtpEmailVerificationDeliveryAdapter,
);
+ assert.ok(composition.options.recoveryDelivery instanceof SmtpPasswordRecoveryDeliveryAdapter);
} finally {
await composition.disconnect();
}
diff --git a/services/api/test/platform/production-database-composition.test.ts b/services/api/test/platform/production-database-composition.test.ts
index c1188109..2446fba6 100644
--- a/services/api/test/platform/production-database-composition.test.ts
+++ b/services/api/test/platform/production-database-composition.test.ts
@@ -68,6 +68,8 @@ import type { SourceCatalogDatabaseClientV1 } from '../../src/features/dda/sourc
import type { ServiceAccountDatabaseClientV1 } from '../../src/features/iam/adapter/prisma-service-account-repository.adapter.js';
import { IAM_EMAIL_VERIFICATION_SERVICE } from '../../src/features/iam/application/email-verification.service.js';
import { RedisRecoveryAdmissionAdapter } from '../../src/features/iam/adapter/redis-recovery-admission.adapter.js';
+import { AwsSesPasswordRecoveryDeliveryAdapter } from '../../src/features/iam/adapter/aws-ses-password-recovery-delivery.adapter.js';
+import { RecoveryService } from '../../src/features/iam/application/recovery.service.js';
const databaseOptionKeys = [
'credentialDatabase',
@@ -126,6 +128,7 @@ const environment = {
DATABREEZE_IAM_EMAIL_VERIFICATION_DIGEST_KEY: Buffer.alloc(32, 8).toString('base64url'),
DATABREEZE_IAM_EMAIL_VERIFICATION_ENVELOPE_KEY: Buffer.alloc(32, 9).toString('base64url'),
DATABREEZE_IAM_REGISTRATION_ADMISSION_KEY: Buffer.alloc(32, 10).toString('base64url'),
+ DATABREEZE_IAM_RECOVERY_DIGEST_KEY: Buffer.alloc(32, 11).toString('base64url'),
DATABREEZE_REDIS_URL: 'rediss://redis.internal:6379',
DATABREEZE_IAM_EMAIL_FROM_ADDRESS: 'verify@databreeze.example',
DATABREEZE_IAM_EMAIL_SES_REGION: 'ap-southeast-1',
@@ -287,7 +290,7 @@ void test('[DDA-036, IAM-022, IAM-023, IAE-003, DSM-001, DDA-003, DDA-004, DDA-0
assert.ok(providerValue(iam, IAM_PRINCIPAL_EMAIL_LOOKUP_PORT));
assert.ok(providerValue(iam, IAM_RECOVERY_REPOSITORY_PORT));
assert.equal(providerValue(iam, IAM_INVITATION_SERVICE), undefined);
- assert.equal(providerValue(iam, IAM_RECOVERY_SERVICE), undefined);
+ assert.ok(providerValue(iam, IAM_RECOVERY_SERVICE) instanceof RecoveryService);
const application = AppModule.register(composition.options);
const dda = application.imports?.find(
@@ -378,6 +381,9 @@ void test('[IAM-022] production composes durable OTP delivery and two shared Red
assert.ok(
composition.options.registrationEmailAdmission instanceof RedisRecoveryAdmissionAdapter,
);
+ assert.ok(
+ composition.options.recoveryDelivery instanceof AwsSesPasswordRecoveryDeliveryAdapter,
+ );
const iam = iamDynamicModuleFor(composition.options);
assert.ok(providerValue(iam, IAM_EMAIL_VERIFICATION_SERVICE));
} finally {
From 06bcf1da2183165092269b92a25e0d09a3931be0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Sun, 16 Aug 2026 10:01:44 +0700
Subject: [PATCH 3/3] feat(web): add public downloads surface
---
apps/web/src/app/router.tsx | 6 +
apps/web/src/features/auth/auth-bootstrap.ts | 4 +-
.../src/features/downloads/downloads-page.tsx | 442 +++++++++
.../downloads/downloads-release-manifest.ts | 58 ++
apps/web/src/styles/downloads-page.css | 902 ++++++++++++++++++
apps/web/test/auth-bootstrap.test.ts | 10 +
apps/web/test/downloads-page.test.tsx | 84 ++
apps/web/test/downloads-routing.test.tsx | 52 +
docs/operations/downloads-release-runbook.md | 238 +++++
docs/plans/411-public-downloads-surface.md | 55 ++
10 files changed, 1849 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/features/downloads/downloads-page.tsx
create mode 100644 apps/web/src/features/downloads/downloads-release-manifest.ts
create mode 100644 apps/web/src/styles/downloads-page.css
create mode 100644 apps/web/test/downloads-page.test.tsx
create mode 100644 apps/web/test/downloads-routing.test.tsx
create mode 100644 docs/operations/downloads-release-runbook.md
create mode 100644 docs/plans/411-public-downloads-surface.md
diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx
index 4a22da8e..5cd21210 100644
--- a/apps/web/src/app/router.tsx
+++ b/apps/web/src/app/router.tsx
@@ -20,6 +20,7 @@ import {
import { DashboardPage } from '../features/dashboards/dashboard-page.tsx';
import { AnalysisRoutePage } from '../features/analysis/analysis-route-page.tsx';
import { DataRoutePage } from '../features/data/data-route-page.tsx';
+import { DownloadsRoutePage } from '../features/downloads/downloads-page.tsx';
import {
SignInRoutePage,
RegisterRoutePage,
@@ -69,6 +70,7 @@ const logicalRoots = new Set([
'sign-in',
'register',
'verify-email',
+ 'downloads',
]);
function canonicalPathname(pathname: string): string | undefined {
@@ -125,6 +127,10 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] {
errorElement: ,
hydrateFallbackElement:
,
children: [
+ {
+ path: 'downloads',
+ element: ,
+ },
{
element: ,
children: [
diff --git a/apps/web/src/features/auth/auth-bootstrap.ts b/apps/web/src/features/auth/auth-bootstrap.ts
index b846b52b..1e83bbaa 100644
--- a/apps/web/src/features/auth/auth-bootstrap.ts
+++ b/apps/web/src/features/auth/auth-bootstrap.ts
@@ -1,7 +1,7 @@
import type { AuthApiV1 } from './auth-api.ts';
import { clearAuthSessionV1, rememberAuthBootstrapV1, type WebAuthenticationStateV1 } from './auth-session.ts';
-const PUBLIC_AUTH_ROUTES_V1 = new Set(['sign-in', 'register', 'verify-email']);
+const PUBLIC_ROUTES_V1 = new Set(['sign-in', 'register', 'verify-email', 'downloads']);
export interface RecoverSessionBeforeAppStartInputV1 {
readonly api: Pick;
@@ -41,7 +41,7 @@ export async function recoverSessionBeforeAppStartV1(
clearAuthSessionV1();
const route = routeV1(input.pathname);
- if (route.section === undefined || !PUBLIC_AUTH_ROUTES_V1.has(route.section)) {
+ if (route.section === undefined || !PUBLIC_ROUTES_V1.has(route.section)) {
input.replace(`/${route.locale}/sign-in`);
}
return 'signed-out';
diff --git a/apps/web/src/features/downloads/downloads-page.tsx b/apps/web/src/features/downloads/downloads-page.tsx
new file mode 100644
index 00000000..e076e6c1
--- /dev/null
+++ b/apps/web/src/features/downloads/downloads-page.tsx
@@ -0,0 +1,442 @@
+import { useState } from 'react';
+import { useParams } from 'react-router-dom';
+import type { SupportedLocaleV1 } from '@databreeze/i18n/v1';
+import wordmarkUrl from '@databreeze/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png';
+
+import { normalizeRouteLocale } from '../../app/locale-context.tsx';
+import {
+ downloadArtifactForPlatformV1,
+ EMPTY_DOWNLOAD_RELEASE_MANIFEST_V1,
+ localizedDownloadsPathV1,
+ type DownloadPlatformV1,
+ type DownloadReleaseManifestV1,
+} from './downloads-release-manifest.ts';
+import '../../styles/downloads-page.css';
+
+const PLATFORM_ORDER: readonly DownloadPlatformV1[] = ['windows', 'android'];
+
+const COPY = {
+ 'vi-VN': {
+ signIn: 'Đăng nhập',
+ language: 'English',
+ pageLabel: 'Trung tâm tải xuống DataBreeze',
+ eyebrow: 'Trung tâm phát hành · 01',
+ title: 'DataBreeze, trên đúng thiết bị của bạn.',
+ introduction:
+ 'Chọn nền tảng để xem kênh phát hành, cách xác minh và trạng thái bản build mới nhất.',
+ liveStatus: 'Kênh phát hành đang được chuẩn bị',
+ releaseEyebrow: 'Bản phát hành có kiểm chứng',
+ releaseTitle: 'Bản phát hành, có dấu vết rõ ràng.',
+ releaseDescription:
+ 'Mỗi gói cài đặt sẽ đi cùng manifest, hash và chữ ký để bạn biết chính xác mình đang cài gì.',
+ selectPlatform: 'Chọn nền tảng',
+ desktop: 'Desktop',
+ desktopDetail: 'Windows · làm việc tại chỗ',
+ android: 'Android',
+ androidDetail: 'Ứng dụng di động · vận hành hiện trường',
+ windowsTitle: 'DataBreeze cho Windows',
+ windowsDescription:
+ 'Không gian làm việc lai cho các nhóm cần dữ liệu, tệp cục bộ và bằng chứng ở cùng một nơi.',
+ androidTitle: 'DataBreeze cho Android',
+ androidDescription:
+ 'Ứng dụng gọn cho các nhiệm vụ cần chụp, kiểm tra và đồng bộ ngay tại hiện trường.',
+ releasePreparing: 'Đang chuẩn bị bản phát hành',
+ releaseReady: 'Gói đã sẵn sàng',
+ notPublished: 'Chưa phát hành',
+ download: 'Tải bản cài đặt',
+ openStore: 'Mở Google Play',
+ channel: 'Kênh phân phối',
+ direct: 'Tải trực tiếp',
+ googlePlay: 'Google Play',
+ version: 'Phiên bản',
+ artifact: 'Gói phát hành',
+ checksum: 'Hash SHA-256',
+ signature: 'Chữ ký phát hành',
+ pending: 'Chờ manifest đã ký',
+ nextStep: 'Sẽ xuất hiện khi bản phát hành được ký và đẩy lên kho an toàn.',
+ readyNote: 'Luôn kiểm tra hash và chữ ký trước khi cài đặt.',
+ flowEyebrow: 'Release path',
+ flowTitle: 'Một đường đi gọn từ build đến thiết bị.',
+ flowDescription:
+ 'Trang này chỉ trỏ đến artifact đã được phát hành. S3 giữ file riêng tư; CloudFront phân phối đúng bản đã công bố.',
+ build: 'Build & sign',
+ buildDetail: 'Đóng gói, ký và tạo checksum.',
+ publish: 'Publish manifest',
+ publishDetail: 'Công bố version bất biến cùng metadata.',
+ verify: 'Verify & install',
+ verifyDetail: 'Thiết bị nhận đúng file đã được xác minh.',
+ supportEyebrow: 'Cần một tay?',
+ supportTitle: 'Chúng tôi sẽ giúp bạn đi vào không gian làm việc.',
+ supportDescription: 'Đăng nhập để tiếp tục hoặc tạo tài khoản mới cho nhóm của bạn.',
+ createAccount: 'Tạo tài khoản',
+ statusReady: 'READY',
+ statusWaiting: 'WAITING',
+ platformSignal: 'PLATFORM SIGNAL',
+ signedRelease: 'SIGNED RELEASE',
+ secureStorage: 'PRIVATE ARTIFACT STORAGE',
+ },
+ en: {
+ signIn: 'Sign in',
+ language: 'Tiếng Việt',
+ pageLabel: 'DataBreeze downloads',
+ eyebrow: 'Release control · 01',
+ title: 'DataBreeze, wherever your data moves.',
+ introduction:
+ 'Choose a platform to see its release channel, verification trail, and the latest build status.',
+ liveStatus: 'Release channel is being prepared',
+ releaseEyebrow: 'Verified releases',
+ releaseTitle: 'A release trail you can trust.',
+ releaseDescription:
+ 'Every installer will ship with a manifest, hash, and signature so you know exactly what you are installing.',
+ selectPlatform: 'Choose a platform',
+ desktop: 'Desktop',
+ desktopDetail: 'Windows · focused work',
+ android: 'Android',
+ androidDetail: 'Mobile app · field operations',
+ windowsTitle: 'DataBreeze for Windows',
+ windowsDescription:
+ 'A hybrid workspace for teams that need data, local files, and evidence in one place.',
+ androidTitle: 'DataBreeze for Android',
+ androidDescription:
+ 'A focused mobile app for capturing, checking, and syncing work in the field.',
+ releasePreparing: 'Release preparing',
+ releaseReady: 'Artifact ready',
+ notPublished: 'Not published',
+ download: 'Download installer',
+ openStore: 'Open Google Play',
+ channel: 'Distribution channel',
+ direct: 'Direct download',
+ googlePlay: 'Google Play',
+ version: 'Version',
+ artifact: 'Release artifact',
+ checksum: 'SHA-256 hash',
+ signature: 'Release signature',
+ pending: 'Waiting for signed manifest',
+ nextStep: 'It will appear once the release is signed and published to the secure store.',
+ readyNote: 'Always verify the hash and signature before installing.',
+ flowEyebrow: 'Release path',
+ flowTitle: 'A clean line from build to device.',
+ flowDescription:
+ 'This page points only to published artifacts. S3 keeps the files private; CloudFront distributes the exact version that was released.',
+ build: 'Build & sign',
+ buildDetail: 'Package, sign, and create checksums.',
+ publish: 'Publish manifest',
+ publishDetail: 'Publish an immutable version with metadata.',
+ verify: 'Verify & install',
+ verifyDetail: 'The device receives the verified file.',
+ supportEyebrow: 'Need a hand?',
+ supportTitle: 'We will help you get into the workspace.',
+ supportDescription: 'Sign in to continue or create a new account for your team.',
+ createAccount: 'Create an account',
+ statusReady: 'READY',
+ statusWaiting: 'WAITING',
+ platformSignal: 'PLATFORM SIGNAL',
+ signedRelease: 'SIGNED RELEASE',
+ secureStorage: 'PRIVATE ARTIFACT STORAGE',
+ },
+} as const satisfies Record>;
+
+function platformCopy(locale: SupportedLocaleV1, platform: DownloadPlatformV1) {
+ const copy = COPY[locale];
+ return platform === 'windows'
+ ? {
+ name: copy.desktop,
+ detail: copy.desktopDetail,
+ title: copy.windowsTitle,
+ description: copy.windowsDescription,
+ }
+ : {
+ name: copy.android,
+ detail: copy.androidDetail,
+ title: copy.androidTitle,
+ description: copy.androidDescription,
+ };
+}
+
+function otherLocale(locale: SupportedLocaleV1): SupportedLocaleV1 {
+ return locale === 'vi-VN' ? 'en' : 'vi-VN';
+}
+
+export function DownloadsPage({
+ locale,
+ manifest = EMPTY_DOWNLOAD_RELEASE_MANIFEST_V1,
+}: {
+ readonly locale: SupportedLocaleV1;
+ readonly manifest?: DownloadReleaseManifestV1;
+}) {
+ const copy = COPY[locale];
+ const [selectedPlatform, setSelectedPlatform] = useState('windows');
+ const selectedArtifact = downloadArtifactForPlatformV1(manifest, selectedPlatform);
+ const selectedPlatformCopy = platformCopy(locale, selectedPlatform);
+ const alternateLocale = otherLocale(locale);
+ const currentDownloadPath = localizedDownloadsPathV1(locale);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {copy.liveStatus}
+
+
{copy.title}
+
{copy.introduction}
+
+
+ 01 {copy.platformSignal}
+
+
+ 02 {copy.signedRelease}
+
+
+ 03 {copy.secureStorage}
+
+
+
+
+
+
+ release.manifest/v1
+ {copy.statusWaiting}
+
+
+ 01
+
+ 02
+
+ 03
+
+
+ build
+ sign
+ ship
+
+
{copy.nextStep}
+
+ CHANNEL / {manifest.channel.toUpperCase()}
+ {manifest.generatedAt === null ? '—' : manifest.generatedAt}
+
+
+
+
+
+
+
{copy.releaseEyebrow}
+
{copy.releaseTitle}
+
{copy.releaseDescription}
+
+
+
+
+ {PLATFORM_ORDER.map((platform) => {
+ const item = platformCopy(locale, platform);
+ const isSelected = platform === selectedPlatform;
+ return (
+ setSelectedPlatform(platform)}
+ >
+
+ 0{PLATFORM_ORDER.indexOf(platform) + 1}
+
+
+ {item.name}
+ {item.detail}
+
+
+ ↗
+
+
+ );
+ })}
+
+
+
+
+
+
+ {selectedPlatformCopy.name} / RELEASE CHANNEL
+
+
{selectedPlatformCopy.title}
+
{selectedPlatformCopy.description}
+
+
+
+ {selectedArtifact.availability === 'available'
+ ? copy.releaseReady
+ : copy.releasePreparing}
+
+
+
+
+
+
+
+
{copy.channel}
+
+ {selectedArtifact.distribution === 'google-play' ? copy.googlePlay : copy.direct}
+
+
+
+
{copy.version}
+
+ {selectedArtifact.availability === 'available'
+ ? selectedArtifact.version
+ : copy.notPublished}
+
+
+
+
{copy.artifact}
+
+ {selectedArtifact.availability === 'available'
+ ? selectedArtifact.sizeLabel
+ : copy.pending}
+
+
+
+
{copy.checksum}
+
+ {selectedArtifact.availability === 'available' ? (
+ SHA-256
+ ) : (
+ '—'
+ )}
+
+
+
+
{copy.signature}
+
+ {selectedArtifact.availability === 'available' ? (
+ Verified
+ ) : (
+ '—'
+ )}
+
+
+
+
+
+
+
+
+
+
{copy.flowEyebrow}
+
{copy.flowTitle}
+
{copy.flowDescription}
+
+
+
+ 01
+
+
{copy.build}
+
{copy.buildDetail}
+
+ →
+
+
+ 02
+
+
{copy.publish}
+
{copy.publishDetail}
+
+ →
+
+
+ 03
+
+
{copy.verify}
+
{copy.verifyDetail}
+
+ ✓
+
+
+
+
+
+
+
{copy.supportEyebrow}
+
{copy.supportTitle}
+
{copy.supportDescription}
+
+
+
+
+
+ © DataBreeze
+ {copy.eyebrow}
+
+
+ );
+}
+
+export function DownloadsRoutePage() {
+ const { locale: routeLocale } = useParams();
+ return ;
+}
diff --git a/apps/web/src/features/downloads/downloads-release-manifest.ts b/apps/web/src/features/downloads/downloads-release-manifest.ts
new file mode 100644
index 00000000..8eefa59d
--- /dev/null
+++ b/apps/web/src/features/downloads/downloads-release-manifest.ts
@@ -0,0 +1,58 @@
+import type { SupportedLocaleV1 } from '@databreeze/i18n/v1';
+
+export type DownloadPlatformV1 = 'windows' | 'android';
+export type DownloadDistributionV1 = 'direct' | 'google-play';
+
+interface DownloadArtifactBaseV1 {
+ readonly platform: DownloadPlatformV1;
+ readonly distribution: DownloadDistributionV1;
+}
+
+export interface PreparingDownloadArtifactV1 extends DownloadArtifactBaseV1 {
+ readonly availability: 'preparing';
+}
+
+export interface AvailableDownloadArtifactV1 extends DownloadArtifactBaseV1 {
+ readonly availability: 'available';
+ readonly version: string;
+ readonly releasedAt: string;
+ readonly sizeLabel: string;
+ readonly downloadUrl: string;
+ readonly checksumUrl: string;
+ readonly signatureUrl: string;
+}
+
+export type DownloadArtifactV1 = PreparingDownloadArtifactV1 | AvailableDownloadArtifactV1;
+
+export interface DownloadReleaseManifestV1 {
+ readonly schemaVersion: 1;
+ readonly generatedAt: string | null;
+ readonly channel: 'stable' | 'preview';
+ readonly artifacts: readonly DownloadArtifactV1[];
+}
+
+export const EMPTY_DOWNLOAD_RELEASE_MANIFEST_V1: DownloadReleaseManifestV1 = {
+ schemaVersion: 1,
+ generatedAt: null,
+ channel: 'stable',
+ artifacts: [
+ { platform: 'windows', distribution: 'direct', availability: 'preparing' },
+ { platform: 'android', distribution: 'google-play', availability: 'preparing' },
+ ],
+};
+
+export function downloadArtifactForPlatformV1(
+ manifest: DownloadReleaseManifestV1,
+ platform: DownloadPlatformV1,
+): DownloadArtifactV1 {
+ return (
+ manifest.artifacts.find((artifact) => artifact.platform === platform) ??
+ EMPTY_DOWNLOAD_RELEASE_MANIFEST_V1.artifacts.find((artifact) => artifact.platform === platform)!
+ );
+}
+
+export function localizedDownloadsPathV1(
+ locale: SupportedLocaleV1,
+): `/${SupportedLocaleV1}/downloads` {
+ return `/${locale}/downloads`;
+}
diff --git a/apps/web/src/styles/downloads-page.css b/apps/web/src/styles/downloads-page.css
new file mode 100644
index 00000000..c2ef643b
--- /dev/null
+++ b/apps/web/src/styles/downloads-page.css
@@ -0,0 +1,902 @@
+.downloads-page {
+ --downloads-ink: #f4f6ff;
+ --downloads-muted: #9aa7c6;
+ --downloads-soft: #c2cae5;
+ --downloads-line: rgb(154 167 198 / 22%);
+ --downloads-panel: rgb(12 17 49 / 70%);
+ position: relative;
+ isolation: isolate;
+ min-height: 100vh;
+ overflow: hidden;
+ color: var(--downloads-ink);
+ background:
+ radial-gradient(circle at 72% 12%, rgb(74 71 255 / 22%), transparent 31rem),
+ radial-gradient(circle at 13% 52%, rgb(0 214 255 / 9%), transparent 27rem), #07091d;
+}
+
+.downloads-page::after {
+ position: absolute;
+ z-index: -1;
+ inset: 0;
+ background: linear-gradient(180deg, transparent 0%, rgb(7 9 29 / 45%) 75%, #07091d 100%);
+ content: '';
+ pointer-events: none;
+}
+
+.downloads-page__grid {
+ position: absolute;
+ z-index: -2;
+ inset: 0;
+ opacity: 0.4;
+ background-image:
+ linear-gradient(rgb(140 157 205 / 7%) 1px, transparent 1px),
+ linear-gradient(90deg, rgb(140 157 205 / 7%) 1px, transparent 1px);
+ background-size: 56px 56px;
+ mask-image: linear-gradient(180deg, black, transparent 76%);
+ pointer-events: none;
+}
+
+.downloads-page__orb {
+ position: absolute;
+ z-index: -1;
+ border: 1px solid rgb(124 135 255 / 16%);
+ border-radius: 50%;
+ pointer-events: none;
+}
+
+.downloads-page__orb--one {
+ width: 42rem;
+ height: 42rem;
+ top: 6rem;
+ right: -21rem;
+}
+
+.downloads-page__orb--two {
+ width: 26rem;
+ height: 26rem;
+ top: 24rem;
+ left: -15rem;
+ border-color: rgb(0 213 255 / 12%);
+}
+
+.downloads-header,
+.downloads-hero,
+.downloads-release,
+.downloads-flow,
+.downloads-support,
+.downloads-footer {
+ width: min(1180px, calc(100% - 48px));
+ margin-inline: auto;
+}
+
+.downloads-header {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 92px;
+ border-bottom: 1px solid var(--downloads-line);
+}
+
+.downloads-header__brand,
+.downloads-header__nav,
+.downloads-header__nav a {
+ display: inline-flex;
+ align-items: center;
+}
+
+.downloads-header__brand {
+ gap: 16px;
+ color: var(--downloads-muted);
+ text-decoration: none;
+}
+
+.downloads-header__brand img {
+ display: block;
+ width: 153px;
+ height: auto;
+}
+
+.downloads-header__brand span,
+.downloads-panel-code,
+.downloads-eyebrow,
+.downloads-signal__topline,
+.downloads-signal__labels,
+.downloads-signal__footer,
+.downloads-hero__metadata,
+.downloads-footer {
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.13em;
+ line-height: 1.4;
+ text-transform: uppercase;
+}
+
+.downloads-header__nav {
+ gap: 24px;
+}
+
+.downloads-header__nav a {
+ min-height: 44px;
+ color: var(--downloads-soft);
+ font-size: 0.8rem;
+ font-weight: 600;
+ text-decoration: none;
+ transition: color 160ms ease;
+}
+
+.downloads-header__nav a:hover {
+ color: #fff;
+}
+
+.downloads-header__nav a:last-child {
+ padding: 0 12px;
+ border: 1px solid var(--downloads-line);
+ border-radius: 999px;
+}
+
+.downloads-hero {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1.06fr) minmax(360px, 0.94fr);
+ align-items: center;
+ gap: clamp(48px, 8vw, 122px);
+ min-height: 650px;
+ padding-block: 100px 120px;
+}
+
+.downloads-hero__copy {
+ max-width: 680px;
+}
+
+.downloads-eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ margin: 0;
+ color: #83d9ff;
+}
+
+.downloads-status-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #69e4ba;
+ box-shadow:
+ 0 0 0 5px rgb(105 228 186 / 10%),
+ 0 0 16px rgb(105 228 186 / 80%);
+}
+
+.downloads-hero h1 {
+ max-width: 10.5ch;
+ margin: 24px 0 0;
+ color: #fff;
+ font-size: clamp(3.3rem, 7vw, 6.4rem);
+ font-weight: 600;
+ letter-spacing: -0.075em;
+ line-height: 0.98;
+}
+
+.downloads-hero__introduction {
+ max-width: 44ch;
+ margin: 30px 0 0;
+ color: var(--downloads-soft);
+ font-size: clamp(1rem, 1.5vw, 1.16rem);
+ line-height: 1.75;
+}
+
+.downloads-hero__metadata {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 24px;
+ margin-top: 42px;
+ color: var(--downloads-muted);
+}
+
+.downloads-hero__metadata span {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.downloads-hero__metadata b {
+ color: #7186ff;
+ font-weight: 700;
+}
+
+.downloads-signal {
+ position: relative;
+ min-height: 310px;
+ padding: 24px;
+ border: 1px solid rgb(146 157 215 / 28%);
+ background: linear-gradient(135deg, rgb(30 36 93 / 48%), rgb(8 12 38 / 76%)), rgb(11 15 45 / 76%);
+ box-shadow:
+ 0 28px 90px rgb(0 0 0 / 24%),
+ inset 0 1px rgb(255 255 255 / 8%);
+ transform: rotate(1.3deg);
+}
+
+.downloads-signal::before,
+.downloads-signal::after {
+ position: absolute;
+ content: '';
+ pointer-events: none;
+}
+
+.downloads-signal::before {
+ width: 90px;
+ height: 90px;
+ top: -1px;
+ right: -1px;
+ border-top: 1px solid #7f8aff;
+ border-right: 1px solid #7f8aff;
+}
+
+.downloads-signal::after {
+ width: 56%;
+ height: 1px;
+ right: -18%;
+ bottom: 22%;
+ background: linear-gradient(90deg, transparent, #6f79ff, transparent);
+ opacity: 0.7;
+ transform: rotate(-19deg);
+}
+
+.downloads-signal__topline,
+.downloads-signal__footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ color: var(--downloads-muted);
+}
+
+.downloads-signal__state {
+ color: #f8c96f;
+}
+
+.downloads-signal__route {
+ display: flex;
+ align-items: center;
+ margin-top: 75px;
+}
+
+.downloads-signal__node {
+ display: grid;
+ place-items: center;
+ width: 52px;
+ height: 52px;
+ border: 1px solid rgb(143 156 225 / 44%);
+ border-radius: 50%;
+ color: var(--downloads-muted);
+ background: #0c1135;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.72rem;
+}
+
+.downloads-signal__node--active {
+ border-color: #8a8dff;
+ color: #fff;
+ background: linear-gradient(145deg, #4b5dff, #7138ff);
+ box-shadow:
+ 0 0 0 8px rgb(82 88 255 / 12%),
+ 0 0 34px rgb(82 88 255 / 44%);
+}
+
+.downloads-signal__line {
+ flex: 1;
+ height: 1px;
+ margin-inline: 12px;
+ background: linear-gradient(90deg, #6674ff, rgb(143 156 225 / 24%));
+}
+
+.downloads-signal__labels {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 14px;
+ color: var(--downloads-muted);
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.62rem;
+ letter-spacing: 0.09em;
+}
+
+.downloads-signal p {
+ max-width: 31ch;
+ margin: 52px 0 34px;
+ color: var(--downloads-soft);
+ font-size: 0.88rem;
+ line-height: 1.65;
+}
+
+.downloads-signal__footer {
+ padding-top: 14px;
+ border-top: 1px solid var(--downloads-line);
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.62rem;
+ letter-spacing: 0.08em;
+}
+
+.downloads-release,
+.downloads-flow {
+ padding-block: 72px 120px;
+}
+
+.downloads-section-intro {
+ max-width: 720px;
+}
+
+.downloads-section-intro h2,
+.downloads-support h2 {
+ max-width: 15ch;
+ margin: 18px 0 0;
+ color: #fff;
+ font-size: clamp(2.35rem, 4.8vw, 4.4rem);
+ font-weight: 600;
+ letter-spacing: -0.065em;
+ line-height: 1.02;
+}
+
+.downloads-section-intro > p:last-child,
+.downloads-support > div:first-child > p:last-child {
+ max-width: 54ch;
+ margin: 22px 0 0;
+ color: var(--downloads-muted);
+ line-height: 1.75;
+}
+
+.downloads-release__workspace {
+ display: grid;
+ grid-template-columns: minmax(190px, 0.32fr) minmax(0, 0.68fr);
+ gap: 34px;
+ margin-top: 62px;
+}
+
+.downloads-platform-picker {
+ display: flex;
+ flex-direction: column;
+ align-self: start;
+ border-top: 1px solid var(--downloads-line);
+}
+
+.downloads-platform-tab {
+ position: relative;
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr) 20px;
+ align-items: center;
+ gap: 12px;
+ min-height: 106px;
+ padding: 18px 0;
+ border: 0;
+ border-bottom: 1px solid var(--downloads-line);
+ color: var(--downloads-muted);
+ background: transparent;
+ text-align: start;
+ cursor: pointer;
+ transition:
+ color 160ms ease,
+ padding 160ms ease;
+}
+
+.downloads-platform-tab::before {
+ position: absolute;
+ inset-block: 0;
+ left: -20px;
+ width: 2px;
+ background: transparent;
+ content: '';
+}
+
+.downloads-platform-tab:hover,
+.downloads-platform-tab--selected {
+ color: #fff;
+}
+
+.downloads-platform-tab--selected {
+ padding-inline-start: 8px;
+}
+
+.downloads-platform-tab--selected::before {
+ background: #7180ff;
+ box-shadow: 0 0 20px rgb(113 128 255 / 80%);
+}
+
+.downloads-platform-tab__index {
+ align-self: start;
+ padding-top: 2px;
+ color: #6573de;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.7rem;
+}
+
+.downloads-platform-tab strong,
+.downloads-platform-tab small {
+ display: block;
+}
+
+.downloads-platform-tab strong {
+ font-size: 0.96rem;
+ font-weight: 600;
+}
+
+.downloads-platform-tab small {
+ margin-top: 7px;
+ color: currentColor;
+ font-size: 0.72rem;
+ line-height: 1.4;
+ opacity: 0.7;
+}
+
+.downloads-platform-tab__arrow {
+ color: #7180ff;
+ font-size: 1rem;
+ opacity: 0;
+ transition:
+ opacity 160ms ease,
+ transform 160ms ease;
+}
+
+.downloads-platform-tab:hover .downloads-platform-tab__arrow,
+.downloads-platform-tab--selected .downloads-platform-tab__arrow {
+ opacity: 1;
+ transform: translate(2px, -2px);
+}
+
+.downloads-release-panel {
+ min-width: 0;
+ padding: clamp(24px, 4vw, 48px);
+ border: 1px solid var(--downloads-line);
+ background: linear-gradient(145deg, rgb(20 27 72 / 76%), rgb(10 14 40 / 56%));
+ box-shadow: inset 0 1px rgb(255 255 255 / 6%);
+}
+
+.downloads-release-panel__heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.downloads-panel-code {
+ margin: 0;
+ color: #7888ff;
+}
+
+.downloads-release-panel h3 {
+ margin: 20px 0 0;
+ color: #fff;
+ font-size: clamp(1.7rem, 3vw, 2.75rem);
+ font-weight: 600;
+ letter-spacing: -0.05em;
+ line-height: 1.08;
+}
+
+.downloads-release-panel__heading > div > p:last-child {
+ max-width: 49ch;
+ margin: 16px 0 0;
+ color: var(--downloads-muted);
+ line-height: 1.7;
+}
+
+.downloads-release-status {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 11px;
+ border: 1px solid rgb(248 201 111 / 26%);
+ color: #f8c96f;
+ background: rgb(248 201 111 / 7%);
+ font-size: 0.67rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.downloads-release-status--ready {
+ border-color: rgb(105 228 186 / 30%);
+ color: #69e4ba;
+ background: rgb(105 228 186 / 7%);
+}
+
+.downloads-release-status .downloads-status-dot {
+ width: 6px;
+ height: 6px;
+ box-shadow: none;
+}
+
+.downloads-release-panel__action-row {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ margin-top: 48px;
+ padding-block: 22px;
+ border-block: 1px solid var(--downloads-line);
+}
+
+.downloads-primary-action,
+.downloads-text-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 13px;
+ min-height: 48px;
+ padding: 0 20px;
+ border: 1px solid transparent;
+ border-radius: 2px;
+ font-size: 0.78rem;
+ font-weight: 700;
+ text-decoration: none;
+ transition:
+ transform 160ms ease,
+ border-color 160ms ease,
+ background-color 160ms ease;
+}
+
+.downloads-primary-action {
+ color: #fff;
+ background: linear-gradient(135deg, #4a5aff, #7138ff);
+ box-shadow: 0 12px 28px rgb(74 90 255 / 18%);
+}
+
+.downloads-primary-action:hover {
+ transform: translateY(-2px);
+}
+
+.downloads-primary-action--disabled,
+.downloads-primary-action--disabled:hover {
+ color: #8993b2;
+ background: rgb(111 128 255 / 10%);
+ box-shadow: none;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.downloads-release-panel__action-row p {
+ max-width: 36ch;
+ margin: 0;
+ color: var(--downloads-muted);
+ font-size: 0.75rem;
+ line-height: 1.55;
+}
+
+.downloads-release-facts {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 26px 22px;
+ margin: 30px 0 0;
+}
+
+.downloads-release-facts div {
+ min-width: 0;
+}
+
+.downloads-release-facts dt {
+ color: #76819f;
+ font-size: 0.68rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.downloads-release-facts dd {
+ margin: 8px 0 0;
+ overflow-wrap: anywhere;
+ color: var(--downloads-soft);
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.76rem;
+}
+
+.downloads-release-facts a {
+ color: #83d9ff;
+}
+
+.downloads-flow {
+ display: grid;
+ grid-template-columns: minmax(260px, 0.76fr) minmax(0, 1.24fr);
+ gap: clamp(52px, 10vw, 160px);
+ border-top: 1px solid var(--downloads-line);
+}
+
+.downloads-section-intro--flow h2 {
+ max-width: 11ch;
+}
+
+.downloads-flow__list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border-top: 1px solid var(--downloads-line);
+}
+
+.downloads-flow__list li {
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) 24px;
+ align-items: center;
+ gap: 18px;
+ min-height: 110px;
+ border-bottom: 1px solid var(--downloads-line);
+}
+
+.downloads-flow__list li > span,
+.downloads-flow__list li > b {
+ color: #6977e6;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.72rem;
+ font-weight: 500;
+}
+
+.downloads-flow__list li > b {
+ color: #a0a9c8;
+ font-size: 1rem;
+ text-align: right;
+}
+
+.downloads-flow__list strong {
+ color: #fff;
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.downloads-flow__list p {
+ margin: 6px 0 0;
+ color: var(--downloads-muted);
+ font-size: 0.78rem;
+ line-height: 1.5;
+}
+
+.downloads-support {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 42px;
+ padding-block: 90px 80px;
+ border-top: 1px solid var(--downloads-line);
+}
+
+.downloads-support h2 {
+ max-width: 12ch;
+ font-size: clamp(2rem, 4vw, 3.6rem);
+}
+
+.downloads-support__actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 24px;
+}
+
+.downloads-text-action {
+ min-height: 48px;
+ padding-inline: 0;
+ color: var(--downloads-soft);
+ border-color: transparent;
+}
+
+.downloads-text-action:hover {
+ color: #fff;
+}
+
+.downloads-footer {
+ display: flex;
+ justify-content: space-between;
+ padding-block: 22px 32px;
+ border-top: 1px solid var(--downloads-line);
+ color: #65708e;
+}
+
+@media (max-width: 900px) {
+ .downloads-hero {
+ grid-template-columns: 1fr;
+ min-height: auto;
+ padding-block: 80px 96px;
+ }
+
+ .downloads-signal {
+ width: min(100%, 580px);
+ margin-left: auto;
+ }
+
+ .downloads-release__workspace,
+ .downloads-flow {
+ grid-template-columns: 1fr;
+ }
+
+ .downloads-platform-picker {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px;
+ border-top: 0;
+ }
+
+ .downloads-platform-tab {
+ min-height: 92px;
+ padding: 16px;
+ border: 1px solid var(--downloads-line);
+ }
+
+ .downloads-platform-tab::before {
+ inset: auto 0 0;
+ width: auto;
+ height: 2px;
+ }
+
+ .downloads-platform-tab--selected {
+ padding-inline-start: 16px;
+ border-color: rgb(113 128 255 / 55%);
+ }
+
+ .downloads-flow {
+ gap: 48px;
+ }
+}
+
+@media (max-width: 620px) {
+ .downloads-header,
+ .downloads-hero,
+ .downloads-release,
+ .downloads-flow,
+ .downloads-support,
+ .downloads-footer {
+ width: min(100% - 32px, 1180px);
+ }
+
+ .downloads-header {
+ min-height: 76px;
+ }
+
+ .downloads-header__brand {
+ gap: 0;
+ }
+
+ .downloads-header__brand img {
+ width: 132px;
+ }
+
+ .downloads-header__brand span {
+ display: none;
+ }
+
+ .downloads-header__nav {
+ gap: 10px;
+ }
+
+ .downloads-header__nav a {
+ font-size: 0.72rem;
+ }
+
+ .downloads-header__nav a:last-child {
+ padding-inline: 9px;
+ }
+
+ .downloads-hero {
+ gap: 54px;
+ padding-block: 70px 82px;
+ }
+
+ .downloads-hero h1 {
+ font-size: clamp(3rem, 15vw, 4.6rem);
+ }
+
+ .downloads-hero__metadata {
+ display: grid;
+ gap: 12px;
+ margin-top: 32px;
+ }
+
+ .downloads-signal {
+ min-height: 282px;
+ padding: 18px;
+ transform: none;
+ }
+
+ .downloads-signal__route {
+ margin-top: 58px;
+ }
+
+ .downloads-signal p {
+ margin-block: 38px 25px;
+ }
+
+ .downloads-release,
+ .downloads-flow {
+ padding-block: 62px 82px;
+ }
+
+ .downloads-release__workspace {
+ gap: 22px;
+ margin-top: 42px;
+ }
+
+ .downloads-platform-picker {
+ gap: 8px;
+ }
+
+ .downloads-platform-tab {
+ grid-template-columns: 20px minmax(0, 1fr);
+ gap: 8px;
+ min-height: 84px;
+ padding: 12px;
+ }
+
+ .downloads-platform-tab__arrow {
+ display: none;
+ }
+
+ .downloads-platform-tab--selected {
+ padding-inline-start: 12px;
+ }
+
+ .downloads-platform-tab strong {
+ font-size: 0.8rem;
+ }
+
+ .downloads-platform-tab small {
+ font-size: 0.63rem;
+ }
+
+ .downloads-release-panel {
+ padding: 22px 18px;
+ }
+
+ .downloads-release-panel__heading {
+ display: block;
+ }
+
+ .downloads-release-status {
+ margin-top: 24px;
+ }
+
+ .downloads-release-panel__action-row {
+ display: block;
+ margin-top: 34px;
+ }
+
+ .downloads-release-panel__action-row p {
+ margin-top: 16px;
+ }
+
+ .downloads-release-facts {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 22px 14px;
+ }
+
+ .downloads-flow__list li {
+ grid-template-columns: 34px minmax(0, 1fr) 18px;
+ gap: 12px;
+ min-height: 100px;
+ }
+
+ .downloads-support {
+ display: block;
+ padding-block: 64px 58px;
+ }
+
+ .downloads-support__actions {
+ margin-top: 32px;
+ }
+
+ .downloads-footer {
+ display: block;
+ font-size: 0.58rem;
+ }
+
+ .downloads-footer span + span {
+ display: block;
+ margin-top: 8px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .downloads-header__nav a,
+ .downloads-platform-tab,
+ .downloads-platform-tab__arrow,
+ .downloads-primary-action {
+ transition: none;
+ }
+
+ .downloads-primary-action:hover {
+ transform: none;
+ }
+}
diff --git a/apps/web/test/auth-bootstrap.test.ts b/apps/web/test/auth-bootstrap.test.ts
index 69de31ba..db415732 100644
--- a/apps/web/test/auth-bootstrap.test.ts
+++ b/apps/web/test/auth-bootstrap.test.ts
@@ -116,6 +116,16 @@ describe('Web authentication bootstrap [IAM-023, WEB-002, WEB-004]', () => {
expect(replace).not.toHaveBeenCalled();
});
+ it('does not redirect a signed-out user away from the public downloads route', async () => {
+ const replace = vi.fn();
+ await recoverSessionBeforeAppStartV1({
+ api: { recoverWebSession: vi.fn(async () => Promise.reject(new Error('offline'))), loadBootstrap: vi.fn() },
+ pathname: '/en/downloads',
+ replace,
+ });
+ expect(replace).not.toHaveBeenCalled();
+ });
+
it('does not mount application routes until session recovery settles', async () => {
let settle: ((value: { readonly accepted: true }) => void) | undefined;
const recovery = new Promise<{ readonly accepted: true }>((resolve) => {
diff --git a/apps/web/test/downloads-page.test.tsx b/apps/web/test/downloads-page.test.tsx
new file mode 100644
index 00000000..186b29ce
--- /dev/null
+++ b/apps/web/test/downloads-page.test.tsx
@@ -0,0 +1,84 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+
+import { DownloadsPage } from '../src/features/downloads/downloads-page.tsx';
+import type { DownloadReleaseManifestV1 } from '../src/features/downloads/downloads-release-manifest.ts';
+
+describe('public downloads surface [WEB-002, WEB-003, DSK-208, DSK-271]', () => {
+ it('renders complete Vietnamese release guidance without inventing an installer URL', () => {
+ render( );
+
+ expect(
+ screen.getByRole('heading', { name: 'DataBreeze, trên đúng thiết bị của bạn.' }),
+ ).toBeTruthy();
+ expect(
+ screen.getByRole('heading', { name: 'Bản phát hành, có dấu vết rõ ràng.' }),
+ ).toBeTruthy();
+ expect(screen.getByRole('tab', { name: /Desktop/u }).getAttribute('aria-selected')).toBe(
+ 'true',
+ );
+ expect(screen.getByRole('button', { name: 'Đang chuẩn bị bản phát hành' })).toHaveProperty(
+ 'disabled',
+ true,
+ );
+ expect(screen.queryByRole('link', { name: 'Tải bản cài đặt' })).toBeNull();
+ expect(
+ within(screen.getByRole('navigation'))
+ .getByRole('link', { name: 'Đăng nhập' })
+ .getAttribute('href'),
+ ).toBe('/vi-VN/sign-in');
+ });
+
+ it('changes the release panel when Android is selected', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('tab', { name: /Android/u }));
+
+ expect(screen.getByRole('tab', { name: /Android/u }).getAttribute('aria-selected')).toBe(
+ 'true',
+ );
+ expect(screen.getByRole('heading', { name: 'DataBreeze for Android' })).toBeTruthy();
+ expect(screen.getByText('Google Play')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Release preparing' })).toHaveProperty(
+ 'disabled',
+ true,
+ );
+ });
+
+ it('renders an artifact action only when the manifest marks the release available', () => {
+ const manifest: DownloadReleaseManifestV1 = {
+ schemaVersion: 1,
+ generatedAt: '2026-08-16T00:00:00.000Z',
+ channel: 'stable',
+ artifacts: [
+ {
+ platform: 'windows',
+ distribution: 'direct',
+ availability: 'available',
+ version: '1.0.0',
+ releasedAt: '2026-08-16T00:00:00.000Z',
+ sizeLabel: '84 MB',
+ downloadUrl: 'https://downloads.example.test/desktop/1.0.0/DataBreeze-Setup.exe',
+ checksumUrl: 'https://downloads.example.test/desktop/1.0.0/SHA256SUMS',
+ signatureUrl: 'https://downloads.example.test/desktop/1.0.0/signature.sig',
+ },
+ { platform: 'android', distribution: 'google-play', availability: 'preparing' },
+ ],
+ };
+
+ render( );
+
+ expect(screen.getByRole('link', { name: 'Download installer' }).getAttribute('href')).toBe(
+ 'https://downloads.example.test/desktop/1.0.0/DataBreeze-Setup.exe',
+ );
+ expect(screen.getByText('1.0.0')).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'SHA-256' }).getAttribute('href')).toBe(
+ 'https://downloads.example.test/desktop/1.0.0/SHA256SUMS',
+ );
+ expect(screen.getByRole('link', { name: 'Verified' }).getAttribute('href')).toBe(
+ 'https://downloads.example.test/desktop/1.0.0/signature.sig',
+ );
+ });
+});
diff --git a/apps/web/test/downloads-routing.test.tsx b/apps/web/test/downloads-routing.test.tsx
new file mode 100644
index 00000000..f5ab830b
--- /dev/null
+++ b/apps/web/test/downloads-routing.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
+import { clearAuthSessionV1 } from '../src/features/auth/auth-session.ts';
+
+afterEach(clearAuthSessionV1);
+
+describe('public downloads routing [WEB-002, WEB-003]', () => {
+ it('renders for signed-out users without mounting the protected shell', async () => {
+ const router = createAppRouter({
+ authenticationState: 'signed-out',
+ initialEntries: ['/vi-VN/downloads'],
+ });
+
+ render( );
+
+ expect(
+ await screen.findByRole('heading', { name: 'DataBreeze, trên đúng thiết bị của bạn.' }),
+ ).toBeTruthy();
+ expect(screen.queryByRole('navigation', { name: 'Điều hướng chính' })).toBeNull();
+ });
+
+ it('keeps signed-in users on the public downloads route', async () => {
+ const router = createAppRouter({
+ authenticationState: 'signed-in',
+ initialEntries: ['/en/downloads'],
+ });
+
+ render( );
+
+ expect(
+ await screen.findByRole('heading', { name: 'DataBreeze, wherever your data moves.' }),
+ ).toBeTruthy();
+ await waitFor(() => expect(router.state.location.pathname).toBe('/en/downloads'));
+ expect(screen.queryByRole('navigation', { name: 'Primary navigation' })).toBeNull();
+ });
+
+ it('canonicalizes the non-localized downloads path to the default Vietnamese route', async () => {
+ const router = createAppRouter({
+ authenticationState: 'signed-out',
+ initialEntries: ['/downloads'],
+ });
+
+ render( );
+
+ await waitFor(() => expect(router.state.location.pathname).toBe('/vi-VN/downloads'));
+ expect(
+ await screen.findByRole('heading', { name: 'DataBreeze, trên đúng thiết bị của bạn.' }),
+ ).toBeTruthy();
+ });
+});
diff --git a/docs/operations/downloads-release-runbook.md b/docs/operations/downloads-release-runbook.md
new file mode 100644
index 00000000..413e0e0d
--- /dev/null
+++ b/docs/operations/downloads-release-runbook.md
@@ -0,0 +1,238 @@
+# DataBreeze Downloads Release Runbook
+
+**Status:** setup guide for the public Web downloads surface
+
+The downloads page lives on the Lightsail Web origin at:
+
+- `https://databreeze.tech/vi-VN/downloads`
+- `https://databreeze.tech/en/downloads`
+
+The binary artifacts should live in a separate private S3 bucket and be
+distributed through CloudFront. The browser should receive CloudFront release
+URLs, never an S3 URL and never AWS credentials.
+
+## Current repository state
+
+The page currently renders a truthful `Release preparing` state. Its
+`DownloadReleaseManifestV1` input is ready for the first signed release, but no
+installer or APK is claimed to be published yet.
+
+Do not upload the current unsigned Windows package or an Android debug APK as a
+customer-facing release. The Desktop and Android foundations still need their
+release signing/package gates completed.
+
+## 1. Create the private artifact bucket
+
+Use the same AWS region as the pilot where practical, for example
+`ap-southeast-1`. Choose a globally unique name such as
+`databreeze-downloads-prod-`.
+
+In the S3 console:
+
+1. Create the bucket with **Object Ownership: Bucket owner enforced**.
+2. Keep **Block all public access** enabled.
+3. Enable bucket versioning.
+4. Keep default encryption enabled; use SSE-KMS if the organization already
+ operates a customer-managed KMS key.
+5. Add a lifecycle rule that aborts incomplete multipart uploads after 7 days.
+6. Do not enable the S3 website endpoint.
+
+The bucket should have no public `s3:GetObject` statement. CloudFront will be
+the only reader.
+
+## 2. Create the CloudFront distribution
+
+Create a distribution with:
+
+1. The S3 **REST bucket origin**, not the S3 website endpoint.
+2. A CloudFront Origin Access Control (OAC) with signing behavior **Sign
+ requests** and signing protocol **sigv4**.
+3. Viewer protocol policy **Redirect HTTP to HTTPS**.
+4. Allowed methods `GET, HEAD`.
+5. A custom domain such as `downloads.databreeze.tech`.
+6. An ACM certificate for `downloads.databreeze.tech` in `us-east-1`.
+7. A cache policy that caches versioned artifact paths for a long time. Keep
+ `releases/manifest.json` short-lived or invalidate that object after each
+ release.
+
+After CloudFront creates the distribution, copy its distribution ID and add a
+bucket policy like this. Replace every placeholder before saving it:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Sid": "AllowCloudFrontReadOnly",
+ "Effect": "Allow",
+ "Principal": { "Service": "cloudfront.amazonaws.com" },
+ "Action": "s3:GetObject",
+ "Resource": "arn:aws:s3:::BUCKET_NAME/releases/*",
+ "Condition": {
+ "StringEquals": {
+ "AWS:SourceArn": "arn:aws:cloudfront::AWS_ACCOUNT_ID:distribution/CLOUDFRONT_DISTRIBUTION_ID"
+ }
+ }
+ }
+ ]
+}
+```
+
+## 3. Add DNS
+
+Keep the existing `databreeze.tech` record pointing at the Lightsail static IP.
+Add a separate record:
+
+```text
+downloads.databreeze.tech CNAME
+```
+
+If DNS is hosted in Route 53, use an Alias record instead of CNAME at the
+zone apex. Wait for the certificate and DNS status to become issued before
+testing HTTPS.
+
+## 4. Use immutable object keys
+
+Upload each release under a versioned prefix. Never overwrite a published
+version:
+
+```text
+releases/manifest.json
+releases/desktop/1.0.0/DataBreeze-Setup-1.0.0.exe
+releases/desktop/1.0.0/SHA256SUMS
+releases/desktop/1.0.0/signature.sig
+releases/android/1.0.0/databreeze-1.0.0.apk
+releases/android/1.0.0/SHA256SUMS
+```
+
+Generate the Windows checksum with PowerShell:
+
+```powershell
+Get-FileHash .\DataBreeze-Setup-1.0.0.exe -Algorithm SHA256
+```
+
+Generate the APK checksum with the platform toolchain or a trusted CI runner.
+The Windows installer must be Authenticode-signed, and the Android artifact
+must be release-signed. Keep private signing keys outside the repository.
+
+## 5. Publish a signed manifest
+
+The page’s typed boundary corresponds to this shape:
+
+```json
+{
+ "schemaVersion": 1,
+ "generatedAt": "2026-08-16T00:00:00.000Z",
+ "channel": "stable",
+ "artifacts": [
+ {
+ "platform": "windows",
+ "distribution": "direct",
+ "availability": "available",
+ "version": "1.0.0",
+ "releasedAt": "2026-08-16T00:00:00.000Z",
+ "sizeLabel": "84 MB",
+ "downloadUrl": "https://downloads.databreeze.tech/releases/desktop/1.0.0/DataBreeze-Setup-1.0.0.exe",
+ "checksumUrl": "https://downloads.databreeze.tech/releases/desktop/1.0.0/SHA256SUMS",
+ "signatureUrl": "https://downloads.databreeze.tech/releases/desktop/1.0.0/signature.sig"
+ },
+ {
+ "platform": "android",
+ "distribution": "google-play",
+ "availability": "preparing"
+ }
+ ]
+}
+```
+
+Publish the versioned artifacts first, verify their hashes/signatures, then
+publish `manifest.json` last. A future release integration should validate the
+JSON at the Web boundary before rendering any link.
+
+For Android, use Google Play as the primary customer distribution. Keep the S3
+APK path for internal, enterprise, or controlled sideload testing until Play
+release signing and policy review are complete.
+
+## 6. Give GitHub Actions short-lived AWS access
+
+Use GitHub Actions OIDC. Do not create an IAM user with a permanent access key
+for this workflow.
+
+1. In GitHub, create a protected environment such as `downloads-publish`.
+2. In AWS IAM, create an OIDC provider for
+ `https://token.actions.githubusercontent.com` with audience
+ `sts.amazonaws.com`.
+3. Create a role whose trust policy restricts the `sub` claim to this
+ repository and environment:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": {
+ "Federated": "arn:aws:iam::AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
+ },
+ "Action": "sts:AssumeRoleWithWebIdentity",
+ "Condition": {
+ "StringEquals": {
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
+ },
+ "StringLike": {
+ "token.actions.githubusercontent.com:sub": "repo:OWNER/REPOSITORY:environment:downloads-publish"
+ }
+ }
+ }
+ ]
+}
+```
+
+4. Grant the role only the release bucket actions it needs: `s3:PutObject`
+ under `releases/*`, `s3:AbortMultipartUpload`, and
+ `cloudfront:CreateInvalidation` for the one distribution. Avoid delete
+ permissions so a published release remains recoverable.
+5. Add the non-secret role ARN, bucket name, region, and distribution ID as
+ GitHub environment variables or environment secrets according to the
+ repository workflow.
+6. Require review for that environment and restrict the workflow to protected
+ branches/tags.
+
+The workflow should upload a version prefix, verify object checksums, upload
+the manifest last, and invalidate only `/releases/manifest.json`.
+
+## 7. Connect the first real release to the page
+
+The current page defaults to the empty manifest so deployment is safe before
+artifacts exist. When the first signed release is ready, the implementation
+should replace that default with a validated manifest loader or a build-time
+manifest injection from the CloudFront URL. Keep the loader outside the
+tenant-data API path and never fetch S3 directly from the browser.
+
+Before enabling an available action, verify:
+
+```powershell
+curl.exe -I https://downloads.databreeze.tech/releases/manifest.json
+curl.exe -I https://downloads.databreeze.tech/releases/desktop/1.0.0/DataBreeze-Setup-1.0.0.exe
+```
+
+The responses should be HTTPS, come from CloudFront, and not expose an S3
+endpoint. Then test the page from both localized URLs and confirm that the
+displayed version, checksum link, signature link, and binary path match the
+manifest.
+
+## 8. Lightsail deployment sequence
+
+Once the page branch is merged into `main`:
+
+1. Let the existing `lightsail-pilot.yml` build and publish the immutable Web
+ image.
+2. Confirm the Lightsail host has the latest release manifest and runs the
+ corresponding `WEB_IMAGE` digest.
+3. Run the existing host health check.
+4. Open `/vi-VN/downloads` and `/en/downloads` over HTTPS.
+5. Only after the S3/CloudFront smoke checks pass should the release manifest
+ mark an artifact `available`.
+
+If the page deploys before the bucket is ready, that is expected: users will
+see the preparing state and no dead download button.
diff --git a/docs/plans/411-public-downloads-surface.md b/docs/plans/411-public-downloads-surface.md
new file mode 100644
index 00000000..7cb1e35d
--- /dev/null
+++ b/docs/plans/411-public-downloads-surface.md
@@ -0,0 +1,55 @@
+# Public Downloads Surface and Release Artifact Plan
+
+**Status:** approved for implementation
+
+**Goal:** Add a localized public downloads page to the Web app and define the
+typed release-manifest boundary that will later be populated by the signed
+Windows and Android artifacts published behind CloudFront/S3.
+
+**Primary requirements:** WEB-002, WEB-003; DSK-208, DSK-271
+
+## Scope
+
+- Add a public `/vi-VN/downloads` and `/en/downloads` route that remains
+ available to both signed-out and signed-in users.
+- Keep the current product voice: Vietnamese-first, calm utility copy, strong
+ blue brand signal, and a dark data-led visual layer that connects to the
+ landing page.
+- Add Windows and Android release rows with a platform selector, truthful
+ unavailable states, and a typed manifest seam for future artifact URLs,
+ checksums, and signatures.
+- Add focused component and routing tests.
+- Document the S3 private bucket, CloudFront Origin Access Control, release
+ signing, and GitHub Actions OIDC setup without committing credentials or
+ pretending that unsigned local builds are production releases.
+
+## Explicit non-goals
+
+- Do not upload binaries or create AWS resources in this change.
+- Do not expose S3 directly or put customer/data-plane downloads through this
+ public page.
+- Do not claim a Windows release until the installer is Authenticode-signed.
+- Do not claim an Android release until a release-signed artifact is prepared;
+ Google Play remains the preferred consumer distribution channel.
+
+## Implementation sequence
+
+1. Define the versioned release-manifest types and a safe empty manifest.
+2. Build the localized page with the selected-platform action, release
+ verification details, and responsive/reduced-motion styling.
+3. Add the route outside the authentication gate and include routing tests for
+ both session states and both locales.
+4. Add the operator runbook for S3/CloudFront, release signing, OIDC upload,
+ manifest publication, and smoke verification.
+5. Run focused Web tests, typecheck, production build, and inspect the isolated
+ diff. Leave the parent worktree untouched.
+
+## Verification mapping
+
+- `WEB-002`: route and artifact action are public product-release presentation;
+ future artifact endpoints must still be server-authorized where they carry
+ tenant data.
+- `WEB-003`: manifest input is explicitly typed and versioned at the Web
+ boundary.
+- `DSK-208` / `DSK-271`: the runbook requires pinned release-manifest and
+ Windows package signatures plus checksum verification before distribution.