@open-mercato/onboarding 0.7.1-develop.7105.1.0d2f4c6651 → 0.7.1-develop.7111.1.c0d5d7286f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/onboarding/__integration__/TC-ONB-003-signup-account-privacy.spec.js +124 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-003-signup-account-privacy.spec.js.map +7 -0
- package/dist/modules/onboarding/api/post/onboarding.js +59 -36
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/emails/ExistingAccountEmail.js +36 -0
- package/dist/modules/onboarding/emails/ExistingAccountEmail.js.map +7 -0
- package/dist/modules/onboarding/lib/existing-account-notice.js +47 -0
- package/dist/modules/onboarding/lib/existing-account-notice.js.map +7 -0
- package/package.json +3 -3
- package/src/__tests__/existing-account-notice-rate-limit.test.ts +134 -0
- package/src/__tests__/onboarding-start-account-privacy.test.ts +277 -0
- package/src/modules/onboarding/__integration__/TC-ONB-003-signup-account-privacy.spec.ts +176 -0
- package/src/modules/onboarding/api/post/onboarding.ts +76 -35
- package/src/modules/onboarding/emails/ExistingAccountEmail.tsx +54 -0
- package/src/modules/onboarding/i18n/de.json +8 -3
- package/src/modules/onboarding/i18n/en.json +8 -3
- package/src/modules/onboarding/i18n/es.json +8 -3
- package/src/modules/onboarding/i18n/ko.json +8 -3
- package/src/modules/onboarding/i18n/pl.json +8 -3
- package/src/modules/onboarding/lib/existing-account-notice.ts +109 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:onboarding] found
|
|
1
|
+
[build:onboarding] found 38 entry points
|
|
2
2
|
[build:onboarding] built successfully
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { expect, test } from "@playwright/test";
|
|
5
|
+
import { getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
6
|
+
import { getTokenContext } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
7
|
+
import { createUserFixture, deleteUserIfExists } from "@open-mercato/core/helpers/integration/authFixtures";
|
|
8
|
+
import { withClient } from "@open-mercato/core/helpers/integration/dbFixtures";
|
|
9
|
+
import { lookupHashCandidates } from "@open-mercato/shared/lib/encryption/aes";
|
|
10
|
+
const integrationMeta = {
|
|
11
|
+
description: "Public signup answers identically whether or not the address already has an account",
|
|
12
|
+
dependsOnModules: ["onboarding", "auth"],
|
|
13
|
+
requiredEnvVars: ["SELF_SERVICE_ONBOARDING_ENABLED"]
|
|
14
|
+
};
|
|
15
|
+
const BASE_URL = process.env.BASE_URL?.trim() || "http://localhost:3000";
|
|
16
|
+
const EMAIL_CAPTURE_PATH = process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || join(process.cwd(), ".ai", "qa", "email-capture.jsonl");
|
|
17
|
+
const EXISTING_ACCOUNT_PASSWORD = "Valid1!Pass";
|
|
18
|
+
const SUBMITTED_PASSWORD = "Str0ng!Passw0rd";
|
|
19
|
+
const SUBMITTER_FIRST_NAME = "Mallory";
|
|
20
|
+
const SUBMITTER_ORGANIZATION = "Probe Industries";
|
|
21
|
+
function submission(email) {
|
|
22
|
+
return {
|
|
23
|
+
email,
|
|
24
|
+
firstName: SUBMITTER_FIRST_NAME,
|
|
25
|
+
lastName: "Prober",
|
|
26
|
+
organizationName: SUBMITTER_ORGANIZATION,
|
|
27
|
+
password: SUBMITTED_PASSWORD,
|
|
28
|
+
confirmPassword: SUBMITTED_PASSWORD,
|
|
29
|
+
termsAccepted: true,
|
|
30
|
+
marketingConsent: false
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function probeSignup(request, baseUrl, email) {
|
|
34
|
+
const response = await request.post(`${baseUrl}/api/onboarding/onboarding`, {
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
data: submission(email)
|
|
37
|
+
});
|
|
38
|
+
return { status: response.status(), rawBody: await response.text() };
|
|
39
|
+
}
|
|
40
|
+
function maskSubmittedAddress(rawBody, email) {
|
|
41
|
+
return rawBody.split(email).join("<submitted-address>");
|
|
42
|
+
}
|
|
43
|
+
async function readCapturedEmails() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await readFile(EMAIL_CAPTURE_PATH, "utf8");
|
|
46
|
+
return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function waitForCapturedEmails(to) {
|
|
52
|
+
const deadline = Date.now() + 1e4;
|
|
53
|
+
let captured = [];
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
captured = (await readCapturedEmails()).filter((entry) => entry.to?.toLowerCase() === to.toLowerCase());
|
|
56
|
+
if (captured.length > 0) return captured;
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
58
|
+
}
|
|
59
|
+
return captured;
|
|
60
|
+
}
|
|
61
|
+
async function deleteOnboardingRequest(email) {
|
|
62
|
+
await withClient(async (client) => {
|
|
63
|
+
await client.query("delete from onboarding_requests where email_hash = any($1::text[])", [lookupHashCandidates(email)]);
|
|
64
|
+
}).catch(() => void 0);
|
|
65
|
+
}
|
|
66
|
+
test.describe("TC-ONB-003: signup does not reveal whether an email already has an account", () => {
|
|
67
|
+
test("answers identically for a registered and an unregistered address, and notifies the owner out of band", async ({ request, baseURL }) => {
|
|
68
|
+
const resolvedBaseUrl = baseURL ?? BASE_URL;
|
|
69
|
+
const unique = randomUUID().slice(0, 8);
|
|
70
|
+
const registeredEmail = `qa-onb-003-registered-${unique}@example.test`;
|
|
71
|
+
const unregisteredEmail = `qa-onb-003-fresh-${unique}@example.test`;
|
|
72
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
73
|
+
const { organizationId } = getTokenContext(adminToken);
|
|
74
|
+
let registeredUserId = null;
|
|
75
|
+
try {
|
|
76
|
+
registeredUserId = await createUserFixture(request, adminToken, {
|
|
77
|
+
email: registeredEmail,
|
|
78
|
+
password: EXISTING_ACCOUNT_PASSWORD,
|
|
79
|
+
organizationId,
|
|
80
|
+
roles: []
|
|
81
|
+
});
|
|
82
|
+
const registeredFirst = await probeSignup(request, resolvedBaseUrl, registeredEmail);
|
|
83
|
+
const unregisteredFirst = await probeSignup(request, resolvedBaseUrl, unregisteredEmail);
|
|
84
|
+
expect(registeredFirst.status, "a submission for a registered address should be accepted").toBe(200);
|
|
85
|
+
expect(unregisteredFirst.status, "both branches should answer with the same status").toBe(registeredFirst.status);
|
|
86
|
+
expect(maskSubmittedAddress(unregisteredFirst.rawBody, unregisteredEmail)).toBe(maskSubmittedAddress(registeredFirst.rawBody, registeredEmail));
|
|
87
|
+
const registeredMail = await waitForCapturedEmails(registeredEmail);
|
|
88
|
+
expect(registeredMail.length, "the account owner should be notified out of band").toBeGreaterThan(0);
|
|
89
|
+
const noticeLinks = registeredMail.flatMap((entry) => entry.links ?? []);
|
|
90
|
+
expect(noticeLinks.some((link) => link.includes("/login")), "the notice should link to sign-in").toBeTruthy();
|
|
91
|
+
expect(noticeLinks.some((link) => link.includes("/api/onboarding/onboarding/verify?token=")), "the account owner must never receive a verification link").toBeFalsy();
|
|
92
|
+
const noticeText = registeredMail.map((entry) => entry.text ?? "").join(" ");
|
|
93
|
+
expect(noticeText, "the notice must not carry the submitter-supplied name").not.toContain(SUBMITTER_FIRST_NAME);
|
|
94
|
+
expect(noticeText, "the notice must not carry the submitter-supplied organization").not.toContain(SUBMITTER_ORGANIZATION);
|
|
95
|
+
const unregisteredMail = await waitForCapturedEmails(unregisteredEmail);
|
|
96
|
+
expect(unregisteredMail.length, "a new address should receive the verification email").toBeGreaterThan(0);
|
|
97
|
+
const verificationLinks = unregisteredMail.flatMap((entry) => entry.links ?? []);
|
|
98
|
+
expect(verificationLinks.some((link) => link.includes("/api/onboarding/onboarding/verify?token=")), "a new address should receive a verification link").toBeTruthy();
|
|
99
|
+
const unregisteredSubjects = unregisteredMail.map((entry) => entry.subject ?? "").join(" ");
|
|
100
|
+
expect(unregisteredSubjects, "a new address must not receive the existing-account notice").not.toContain("About your Open Mercato account");
|
|
101
|
+
const registeredSecond = await probeSignup(request, resolvedBaseUrl, registeredEmail);
|
|
102
|
+
const unregisteredSecond = await probeSignup(request, resolvedBaseUrl, unregisteredEmail);
|
|
103
|
+
expect(registeredSecond.status, "a repeated probe should stay accepted").toBe(200);
|
|
104
|
+
expect(unregisteredSecond.status, "both throttled branches should answer with the same status").toBe(registeredSecond.status);
|
|
105
|
+
expect(maskSubmittedAddress(unregisteredSecond.rawBody, unregisteredEmail)).toBe(maskSubmittedAddress(registeredSecond.rawBody, registeredEmail));
|
|
106
|
+
expect(maskSubmittedAddress(registeredSecond.rawBody, registeredEmail), "a repeated probe should look like a first one").toBe(maskSubmittedAddress(registeredFirst.rawBody, registeredEmail));
|
|
107
|
+
const rowsForRegistered = await withClient(async (client) => {
|
|
108
|
+
const result = await client.query(
|
|
109
|
+
"select id from onboarding_requests where email_hash = any($1::text[])",
|
|
110
|
+
[lookupHashCandidates(registeredEmail)]
|
|
111
|
+
);
|
|
112
|
+
return result.rowCount ?? 0;
|
|
113
|
+
});
|
|
114
|
+
expect(rowsForRegistered, "no onboarding request should exist for a registered address").toBe(0);
|
|
115
|
+
} finally {
|
|
116
|
+
await deleteOnboardingRequest(unregisteredEmail);
|
|
117
|
+
await deleteUserIfExists(request, adminToken, registeredUserId);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
export {
|
|
122
|
+
integrationMeta
|
|
123
|
+
};
|
|
124
|
+
//# sourceMappingURL=TC-ONB-003-signup-account-privacy.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/onboarding/__integration__/TC-ONB-003-signup-account-privacy.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { expect, test, type APIRequestContext } from '@playwright/test';\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenContext } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { createUserFixture, deleteUserIfExists } from '@open-mercato/core/helpers/integration/authFixtures';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';\n\nexport const integrationMeta = {\n description: 'Public signup answers identically whether or not the address already has an account',\n dependsOnModules: ['onboarding', 'auth'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n};\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\nconst EMAIL_CAPTURE_PATH = process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || join(process.cwd(), '.ai', 'qa', 'email-capture.jsonl');\nconst EXISTING_ACCOUNT_PASSWORD = 'Valid1!Pass';\nconst SUBMITTED_PASSWORD = 'Str0ng!Passw0rd';\nconst SUBMITTER_FIRST_NAME = 'Mallory';\nconst SUBMITTER_ORGANIZATION = 'Probe Industries';\n\ntype CapturedEmail = {\n to?: string;\n subject?: string;\n links?: string[];\n text?: string;\n};\n\ntype Probe = {\n status: number;\n rawBody: string;\n};\n\nfunction submission(email: string) {\n return {\n email,\n firstName: SUBMITTER_FIRST_NAME,\n lastName: 'Prober',\n organizationName: SUBMITTER_ORGANIZATION,\n password: SUBMITTED_PASSWORD,\n confirmPassword: SUBMITTED_PASSWORD,\n termsAccepted: true,\n marketingConsent: false,\n };\n}\n\nasync function probeSignup(request: APIRequestContext, baseUrl: string, email: string): Promise<Probe> {\n const response = await request.post(`${baseUrl}/api/onboarding/onboarding`, {\n headers: { 'content-type': 'application/json' },\n data: submission(email),\n });\n return { status: response.status(), rawBody: await response.text() };\n}\n\n/**\n * The submitted address is the one field the two branches may legitimately differ on, because the\n * caller supplied it. Masking it turns \"same shape\" into a byte comparison of everything else.\n */\nfunction maskSubmittedAddress(rawBody: string, email: string): string {\n return rawBody.split(email).join('<submitted-address>');\n}\n\nasync function readCapturedEmails(): Promise<CapturedEmail[]> {\n try {\n const raw = await readFile(EMAIL_CAPTURE_PATH, 'utf8');\n return raw.split('\\n').filter(Boolean).map((line) => JSON.parse(line) as CapturedEmail);\n } catch {\n return [];\n }\n}\n\nasync function waitForCapturedEmails(to: string): Promise<CapturedEmail[]> {\n const deadline = Date.now() + 10_000;\n let captured: CapturedEmail[] = [];\n while (Date.now() < deadline) {\n captured = (await readCapturedEmails()).filter((entry) => entry.to?.toLowerCase() === to.toLowerCase());\n if (captured.length > 0) return captured;\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n return captured;\n}\n\nasync function deleteOnboardingRequest(email: string): Promise<void> {\n await withClient(async (client) => {\n await client.query('delete from onboarding_requests where email_hash = any($1::text[])', [lookupHashCandidates(email)]);\n }).catch(() => undefined);\n}\n\n/**\n * TC-ONB-003: public signup does not disclose whether an address already has an account\n * Source: GitHub issue #5505, PR #5677\n * Surface: POST /api/onboarding/onboarding (requireAuth: false)\n *\n * The unit suite pins the property at the handler level with every collaborator mocked. This one\n * proves it against a real instance: a real user row created in setup, the real encryption/lookup\n * path that finds it, the real OnboardingService pending-request window, and the real outbound\n * mail (captured on disk) that carries the out-of-band notice.\n */\ntest.describe('TC-ONB-003: signup does not reveal whether an email already has an account', () => {\n test('answers identically for a registered and an unregistered address, and notifies the owner out of band', async ({ request, baseURL }) => {\n const resolvedBaseUrl = baseURL ?? BASE_URL;\n const unique = randomUUID().slice(0, 8);\n const registeredEmail = `qa-onb-003-registered-${unique}@example.test`;\n const unregisteredEmail = `qa-onb-003-fresh-${unique}@example.test`;\n\n const adminToken = await getAuthToken(request, 'admin');\n const { organizationId } = getTokenContext(adminToken);\n let registeredUserId: string | null = null;\n\n try {\n registeredUserId = await createUserFixture(request, adminToken, {\n email: registeredEmail,\n password: EXISTING_ACCOUNT_PASSWORD,\n organizationId,\n roles: [],\n });\n\n // (a) First probe of each address: same status, and the same body once the caller's own\n // address is masked out.\n const registeredFirst = await probeSignup(request, resolvedBaseUrl, registeredEmail);\n const unregisteredFirst = await probeSignup(request, resolvedBaseUrl, unregisteredEmail);\n\n expect(registeredFirst.status, 'a submission for a registered address should be accepted').toBe(200);\n expect(unregisteredFirst.status, 'both branches should answer with the same status').toBe(registeredFirst.status);\n expect(maskSubmittedAddress(unregisteredFirst.rawBody, unregisteredEmail))\n .toBe(maskSubmittedAddress(registeredFirst.rawBody, registeredEmail));\n\n // (c) The registered address is told out of band: a notice, never a verification link.\n const registeredMail = await waitForCapturedEmails(registeredEmail);\n expect(registeredMail.length, 'the account owner should be notified out of band').toBeGreaterThan(0);\n const noticeLinks = registeredMail.flatMap((entry) => entry.links ?? []);\n expect(noticeLinks.some((link) => link.includes('/login')), 'the notice should link to sign-in').toBeTruthy();\n expect(noticeLinks.some((link) => link.includes('/api/onboarding/onboarding/verify?token=')), 'the account owner must never receive a verification link').toBeFalsy();\n const noticeText = registeredMail.map((entry) => entry.text ?? '').join(' ');\n expect(noticeText, 'the notice must not carry the submitter-supplied name').not.toContain(SUBMITTER_FIRST_NAME);\n expect(noticeText, 'the notice must not carry the submitter-supplied organization').not.toContain(SUBMITTER_ORGANIZATION);\n\n // (c) The unregistered address gets the verification mail and no notice.\n const unregisteredMail = await waitForCapturedEmails(unregisteredEmail);\n expect(unregisteredMail.length, 'a new address should receive the verification email').toBeGreaterThan(0);\n const verificationLinks = unregisteredMail.flatMap((entry) => entry.links ?? []);\n expect(verificationLinks.some((link) => link.includes('/api/onboarding/onboarding/verify?token=')), 'a new address should receive a verification link').toBeTruthy();\n const unregisteredSubjects = unregisteredMail.map((entry) => entry.subject ?? '').join(' ');\n expect(unregisteredSubjects, 'a new address must not receive the existing-account notice').not.toContain('About your Open Mercato account');\n\n // (b) Second probe of each, inside the ten-minute windows: the registered address is inside\n // the notice throttle and the new one is inside the pending-verification window, and those\n // two throttles must not become a way to tell the branches apart across probes.\n const registeredSecond = await probeSignup(request, resolvedBaseUrl, registeredEmail);\n const unregisteredSecond = await probeSignup(request, resolvedBaseUrl, unregisteredEmail);\n\n expect(registeredSecond.status, 'a repeated probe should stay accepted').toBe(200);\n expect(unregisteredSecond.status, 'both throttled branches should answer with the same status').toBe(registeredSecond.status);\n expect(maskSubmittedAddress(unregisteredSecond.rawBody, unregisteredEmail))\n .toBe(maskSubmittedAddress(registeredSecond.rawBody, registeredEmail));\n expect(maskSubmittedAddress(registeredSecond.rawBody, registeredEmail), 'a repeated probe should look like a first one')\n .toBe(maskSubmittedAddress(registeredFirst.rawBody, registeredEmail));\n\n // No onboarding request may be created for an address that already has an account: the\n // submitter's strings must never reach a row that belongs to the account owner's address.\n const rowsForRegistered = await withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n 'select id from onboarding_requests where email_hash = any($1::text[])',\n [lookupHashCandidates(registeredEmail)],\n );\n return result.rowCount ?? 0;\n });\n expect(rowsForRegistered, 'no onboarding request should exist for a registered address').toBe(0);\n } finally {\n await deleteOnboardingRequest(unregisteredEmail);\n await deleteUserIfExists(request, adminToken, registeredUserId);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -11,6 +11,7 @@ import { createFallbackTranslator } from "@open-mercato/shared/lib/i18n/translat
|
|
|
11
11
|
import { sendEmail } from "@open-mercato/shared/lib/email/send";
|
|
12
12
|
import { onboardingStartSchema } from "@open-mercato/onboarding/modules/onboarding/data/validators";
|
|
13
13
|
import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/lib/service";
|
|
14
|
+
import { sendExistingAccountNotice } from "@open-mercato/onboarding/modules/onboarding/lib/existing-account-notice";
|
|
14
15
|
import VerificationEmail from "@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail";
|
|
15
16
|
import AdminNotificationEmail from "@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail";
|
|
16
17
|
import { User } from "@open-mercato/core/modules/auth/data/entities";
|
|
@@ -92,9 +93,28 @@ async function POST(req) {
|
|
|
92
93
|
fieldErrors
|
|
93
94
|
}, { status: 400 });
|
|
94
95
|
}
|
|
96
|
+
const acceptedResponse = () => NextResponse.json({ ok: true, email: parsed.data.email });
|
|
97
|
+
const emailSendFailedResponse = () => NextResponse.json({
|
|
98
|
+
ok: false,
|
|
99
|
+
error: translate(
|
|
100
|
+
"onboarding.errors.emailSendFailed",
|
|
101
|
+
"We could not send the verification email. Please try again or contact support."
|
|
102
|
+
)
|
|
103
|
+
}, { status: 502 });
|
|
95
104
|
try {
|
|
96
105
|
const container = await createRequestContainer();
|
|
97
106
|
const em = container.resolve("em");
|
|
107
|
+
let baseUrl;
|
|
108
|
+
try {
|
|
109
|
+
baseUrl = getSecurityEmailBaseUrl(req);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
112
|
+
scope: "onboarding.start",
|
|
113
|
+
configMessage: "Self-service onboarding is not configured."
|
|
114
|
+
});
|
|
115
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status });
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
98
118
|
const existingUser = await findOneWithDecryption(em, User, {
|
|
99
119
|
deletedAt: null,
|
|
100
120
|
$or: [
|
|
@@ -103,12 +123,37 @@ async function POST(req) {
|
|
|
103
123
|
]
|
|
104
124
|
});
|
|
105
125
|
if (existingUser) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
126
|
+
const noticeTranslate = locale === defaultLocale ? translate : createFallbackTranslator(await loadDictionary(defaultLocale));
|
|
127
|
+
try {
|
|
128
|
+
await sendExistingAccountNotice({
|
|
129
|
+
container,
|
|
130
|
+
email: parsed.data.email,
|
|
131
|
+
loginUrl: `${baseUrl}/login`,
|
|
132
|
+
subject: noticeTranslate("onboarding.existingAccountEmail.subject", "About your Open Mercato account"),
|
|
133
|
+
copy: {
|
|
134
|
+
preview: noticeTranslate(
|
|
135
|
+
"onboarding.existingAccountEmail.preview",
|
|
136
|
+
"Someone tried to create an Open Mercato workspace with this email address."
|
|
137
|
+
),
|
|
138
|
+
heading: noticeTranslate("onboarding.existingAccountEmail.heading", "You already have an account"),
|
|
139
|
+
greeting: noticeTranslate("onboarding.existingAccountEmail.greeting", "Hello,"),
|
|
140
|
+
body: noticeTranslate(
|
|
141
|
+
"onboarding.existingAccountEmail.body",
|
|
142
|
+
"Someone just used this email address to start creating an Open Mercato workspace. This address already has an account, so we did not create a new one."
|
|
143
|
+
),
|
|
144
|
+
cta: noticeTranslate("onboarding.existingAccountEmail.cta", "Sign in"),
|
|
145
|
+
ignore: noticeTranslate(
|
|
146
|
+
"onboarding.existingAccountEmail.ignore",
|
|
147
|
+
"If this was you, sign in or reset your password. If it wasn't, you can safely ignore this message \u2014 nothing about your account has changed."
|
|
148
|
+
),
|
|
149
|
+
footer: noticeTranslate("onboarding.existingAccountEmail.footer", "Open Mercato \xB7 Onboarding service")
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
logger.error("Existing account notice email failed", { err });
|
|
154
|
+
return emailSendFailedResponse();
|
|
155
|
+
}
|
|
156
|
+
return acceptedResponse();
|
|
112
157
|
}
|
|
113
158
|
const service = new OnboardingService(em);
|
|
114
159
|
let request, token;
|
|
@@ -118,27 +163,11 @@ async function POST(req) {
|
|
|
118
163
|
token = result.token;
|
|
119
164
|
} catch (err) {
|
|
120
165
|
if (err instanceof Error && err.message.startsWith("PENDING_REQUEST:")) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return NextResponse.json({
|
|
124
|
-
ok: false,
|
|
125
|
-
error: message,
|
|
126
|
-
fieldErrors: { email: message }
|
|
127
|
-
}, { status: 409 });
|
|
166
|
+
logger.info("Onboarding request throttled by pending verification");
|
|
167
|
+
return acceptedResponse();
|
|
128
168
|
}
|
|
129
169
|
throw err;
|
|
130
170
|
}
|
|
131
|
-
let baseUrl;
|
|
132
|
-
try {
|
|
133
|
-
baseUrl = getSecurityEmailBaseUrl(req);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
const mapped = mapSecurityEmailUrlError(error, {
|
|
136
|
-
scope: "onboarding.start",
|
|
137
|
-
configMessage: "Self-service onboarding is not configured."
|
|
138
|
-
});
|
|
139
|
-
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status });
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
171
|
const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`;
|
|
143
172
|
const firstName = request.firstName || parsed.data.firstName;
|
|
144
173
|
const hasMarketingConsent = request.marketingConsent === true;
|
|
@@ -168,13 +197,7 @@ async function POST(req) {
|
|
|
168
197
|
request.lastEmailSentAt = null;
|
|
169
198
|
await em.flush();
|
|
170
199
|
logger.error("Verification email failed", { err });
|
|
171
|
-
return
|
|
172
|
-
ok: false,
|
|
173
|
-
error: translate(
|
|
174
|
-
"onboarding.errors.emailSendFailed",
|
|
175
|
-
"We could not send the verification email. Please try again or contact support."
|
|
176
|
-
)
|
|
177
|
-
}, { status: 502 });
|
|
200
|
+
return emailSendFailedResponse();
|
|
178
201
|
}
|
|
179
202
|
const adminEmail = process.env.ADMIN_EMAIL || "piotr@catchthetornado.com";
|
|
180
203
|
const adminSubject = translate("onboarding.email.adminSubject", "New self-service onboarding request");
|
|
@@ -199,7 +222,7 @@ async function POST(req) {
|
|
|
199
222
|
} catch (err) {
|
|
200
223
|
logger.error("Admin email failed", { err });
|
|
201
224
|
}
|
|
202
|
-
return
|
|
225
|
+
return acceptedResponse();
|
|
203
226
|
} catch (error) {
|
|
204
227
|
logger.error("Onboarding start failed", { err: error });
|
|
205
228
|
return NextResponse.json({
|
|
@@ -221,7 +244,7 @@ const onboardingErrorSchema = z.object({
|
|
|
221
244
|
});
|
|
222
245
|
const onboardingPostDoc = {
|
|
223
246
|
summary: "Submit onboarding request",
|
|
224
|
-
description: "Accepts a self-service onboarding form submission and triggers email verification.",
|
|
247
|
+
description: "Accepts a self-service onboarding form submission and triggers email verification. The response never reveals whether the submitted address already has an account: an address that does receives an out-of-band notice email instead of a verification link, and the caller sees the same accepted response either way. The guarantee covers the response status and body, not response latency \u2014 the new-address branch hashes a password and sends two emails where the existing-account branch sends one, so a caller that measures timing can still tell them apart. Deployments should also keep rate limiting enabled (and use the redis strategy when running more than one replica): the per-address window on the existing-account notice is backed by the rate limiter, so with rate limiting disabled the address owner receives one notice per accepted submission instead of one per ten minutes.",
|
|
225
248
|
tags: [onboardingTag],
|
|
226
249
|
requestBody: {
|
|
227
250
|
contentType: "application/json",
|
|
@@ -229,14 +252,14 @@ const onboardingPostDoc = {
|
|
|
229
252
|
description: "Onboarding form payload with contact and organization information."
|
|
230
253
|
},
|
|
231
254
|
responses: [
|
|
232
|
-
{ status: 200, description: "Onboarding request accepted.", schema: onboardingSuccessSchema }
|
|
255
|
+
{ status: 200, description: "Onboarding request accepted. Returned for a new address, for an address that already has an account, and for a submission inside the pending-verification window.", schema: onboardingSuccessSchema }
|
|
233
256
|
],
|
|
234
257
|
errors: [
|
|
235
258
|
{ status: 400, description: "Validation failed", schema: onboardingErrorSchema },
|
|
236
259
|
{ status: 404, description: "Self-service onboarding disabled", schema: onboardingErrorSchema },
|
|
237
|
-
{ status: 409, description: "Existing account or pending request", schema: onboardingErrorSchema },
|
|
238
260
|
{ status: 429, description: "Too many onboarding submissions from this IP", schema: rateLimitErrorSchema },
|
|
239
|
-
{ status: 500, description: "Unexpected server error", schema: onboardingErrorSchema }
|
|
261
|
+
{ status: 500, description: "Unexpected server error", schema: onboardingErrorSchema },
|
|
262
|
+
{ status: 502, description: "Outbound email could not be delivered", schema: onboardingErrorSchema }
|
|
240
263
|
]
|
|
241
264
|
};
|
|
242
265
|
const openApi = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/onboarding/api/post/onboarding.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { loadDictionary } from '@open-mercato/shared/lib/i18n/server'\nimport { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { onboardingStartSchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport VerificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail'\nimport AdminNotificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\n\nconst logger = createLogger('onboarding').child({ component: 'start' })\n\nexport const metadata = {\n path: '/onboarding/onboarding',\n POST: {\n requireAuth: false,\n rateLimit: readEndpointRateLimitConfig('ONBOARDING', {\n points: 10,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'onboarding',\n }),\n },\n}\n\nexport async function POST(req: Request) {\n if (parseBooleanToken(process.env.SELF_SERVICE_ONBOARDING_ENABLED ?? '') !== true) {\n return NextResponse.json({ ok: false, error: 'Self-service onboarding is disabled.' }, { status: 404 })\n }\n let payload: unknown\n try {\n payload = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid payload' }, { status: 400 })\n }\n\n const rawLocale =\n payload && typeof payload === 'object' && 'locale' in payload && typeof (payload as any).locale === 'string'\n ? (payload as any).locale as string\n : null\n const locale: Locale = rawLocale && locales.includes(rawLocale as Locale)\n ? (rawLocale as Locale)\n : defaultLocale\n const dict = await loadDictionary(locale)\n const translate = createFallbackTranslator(dict)\n const passwordRequirements = formatPasswordRequirements(\n getPasswordPolicy(),\n translate,\n 'onboarding.password.requirements',\n )\n\n const parsed = onboardingStartSchema.safeParse(payload)\n if (!parsed.success) {\n const fieldErrors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (!path) continue\n switch (path) {\n case 'email':\n fieldErrors.email = translate('onboarding.errors.emailInvalid', 'Enter a valid work email.')\n break\n case 'firstName':\n fieldErrors.firstName = translate('onboarding.errors.firstNameRequired', 'First name is required.')\n break\n case 'lastName':\n fieldErrors.lastName = translate('onboarding.errors.lastNameRequired', 'Last name is required.')\n break\n case 'organizationName':\n fieldErrors.organizationName = translate('onboarding.errors.organizationNameRequired', 'Organization name is required.')\n break\n case 'password':\n fieldErrors.password = translate(\n 'onboarding.errors.passwordRequired',\n 'Password must meet the requirements: {requirements}.',\n { requirements: passwordRequirements },\n )\n break\n case 'confirmPassword':\n fieldErrors.confirmPassword = translate('onboarding.errors.passwordMismatch', 'Passwords must match.')\n break\n case 'termsAccepted':\n fieldErrors.termsAccepted = translate('onboarding.form.termsRequired', 'Please accept the terms to continue.')\n break\n default:\n break\n }\n }\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Please check the form and try again.'),\n fieldErrors,\n }, { status: 400 })\n }\n\n try {\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n\n const existingUser = await findOneWithDecryption(em, User, {\n deletedAt: null,\n $or: [\n { email: parsed.data.email },\n { emailHash: { $in: lookupHashCandidates(parsed.data.email) } },\n ],\n })\n if (existingUser) {\n const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n\n const service = new OnboardingService(em)\n let request, token\n try {\n const result = await service.createOrUpdateRequest(parsed.data)\n request = result.request\n token = result.token\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('PENDING_REQUEST:')) {\n const minutes = Number(err.message.split(':')[1] || '10')\n const message = translate('onboarding.errors.pendingRequest', 'We already have a pending verification. Please try again in about {minutes} minutes or contact the administrator.', { minutes })\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n throw err\n }\n\n let baseUrl: string\n try {\n baseUrl = getSecurityEmailBaseUrl(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'onboarding.start',\n configMessage: 'Self-service onboarding is not configured.',\n })\n if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })\n throw error\n }\n const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`\n\n const firstName = request.firstName || parsed.data.firstName\n const hasMarketingConsent = request.marketingConsent === true\n const marketingConsentText = hasMarketingConsent\n ? translate('onboarding.email.marketingConsentYes', 'Marketing consent: Yes')\n : translate('onboarding.email.marketingConsentNo', 'Marketing consent: No')\n const subject = translate('onboarding.email.subject', 'Confirm your email to finish onboarding')\n const emailCopy = {\n preview: translate('onboarding.email.preview', 'Confirm your email to activate your Open Mercato workspace'),\n heading: translate('onboarding.email.heading', 'Welcome to Open Mercato'),\n greeting: translate('onboarding.email.greeting', 'Hi {firstName},', { firstName }),\n body: translate(\n 'onboarding.email.body',\n 'We just need to confirm your email address to finish setting up the organization {organizationName}.',\n { organizationName: request.organizationName },\n ),\n cta: translate('onboarding.email.cta', 'Confirm email & activate workspace'),\n expiry: translate(\n 'onboarding.email.expiry',\n \"The link will expire in 24 hours. If you didn't request this, you can safely ignore this message.\",\n ),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.footer', 'Open Mercato \u00B7 Onboarding service'),\n }\n const emailReact = VerificationEmail({ verifyUrl, copy: emailCopy })\n try {\n await sendEmail({ to: request.email, subject, react: emailReact })\n } catch (err) {\n request.lastEmailSentAt = null\n await em.flush()\n logger.error('Verification email failed', { err })\n return NextResponse.json({\n ok: false,\n error: translate(\n 'onboarding.errors.emailSendFailed',\n 'We could not send the verification email. Please try again or contact support.',\n ),\n }, { status: 502 })\n }\n\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n const adminSubject = translate('onboarding.email.adminSubject', 'New self-service onboarding request')\n const adminCopy = {\n preview: translate('onboarding.email.adminPreview', 'New onboarding request submitted'),\n heading: translate('onboarding.email.adminHeading', 'New onboarding request'),\n body: translate('onboarding.email.adminBody', '{firstName} {lastName} ({email}) submitted an onboarding request for {organizationName}.', {\n firstName: request.firstName,\n lastName: request.lastName,\n email: request.email,\n organizationName: request.organizationName,\n }),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.adminFooter', 'You can review the tenant after verification is complete.'),\n }\n try {\n await sendEmail({\n to: adminEmail,\n subject: adminSubject,\n react: AdminNotificationEmail({ copy: adminCopy }),\n })\n } catch (err) {\n logger.error('Admin email failed', { err })\n }\n\n return NextResponse.json({ ok: true, email: request.email })\n } catch (error) {\n logger.error('Onboarding start failed', { err: error })\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Something went wrong. Please try again later.'),\n }, { status: 500 })\n }\n}\n\nexport default POST\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingSuccessSchema = z.object({\n ok: z.literal(true),\n email: z.string().email(),\n})\n\nconst onboardingErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n fieldErrors: z.record(z.string(), z.string()).optional(),\n})\n\nconst onboardingPostDoc: OpenApiMethodDoc = {\n summary: 'Submit onboarding request',\n description: 'Accepts a self-service onboarding form submission and triggers email verification.',\n tags: [onboardingTag],\n requestBody: {\n contentType: 'application/json',\n schema: onboardingStartSchema,\n description: 'Onboarding form payload with contact and organization information.',\n },\n responses: [\n { status: 200, description: 'Onboarding request accepted.', schema: onboardingSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: onboardingErrorSchema },\n { status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },\n { status: 409, description: 'Existing account or pending request', schema: onboardingErrorSchema },\n { status: 429, description: 'Too many onboarding submissions from this IP', schema: rateLimitErrorSchema },\n { status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Self-service onboarding submission',\n methods: {\n POST: onboardingPostDoc,\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B;AACrC,SAAS,6BAA6B;AACtC,SAAS,yBAAyB,gCAAgC;AAClE,SAAS,sBAAsB;AAC/B,SAAS,eAAe,eAA4B;AACpD,SAAS,gCAAgC;AACzC,SAAS,iBAAiB;AAC1B,SAAS,6BAA6B;AACtC,SAAS,yBAAyB;AAClC,OAAO,uBAAuB;AAC9B,OAAO,4BAA4B;AACnC,SAAS,YAAY;AAErB,SAAS,4BAA4B,yBAAyB;AAC9D,SAAS,yBAAyB;AAClC,SAAS,mCAAmC;AAC5C,SAAS,4BAA4B;AAErC,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAE/D,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA,IACJ,aAAa;AAAA,IACb,WAAW,4BAA4B,cAAc;AAAA,MACnD,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,eAAe;AAAA,MACf,WAAW;AAAA,IACb,CAAC;AAAA,EACH;AACF;AAEA,eAAsB,KAAK,KAAc;AACvC,MAAI,kBAAkB,QAAQ,IAAI,mCAAmC,EAAE,MAAM,MAAM;AACjF,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,uCAAuC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxG;AACA,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,IAAI,KAAK;AAAA,EAC3B,QAAQ;AACN,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnF;AAEA,QAAM,YACJ,WAAW,OAAO,YAAY,YAAY,YAAY,WAAW,OAAQ,QAAgB,WAAW,WAC/F,QAAgB,SACjB;AACN,QAAM,SAAiB,aAAa,QAAQ,SAAS,SAAmB,IACnE,YACD;AACJ,QAAM,OAAO,MAAM,eAAe,MAAM;AACxC,QAAM,YAAY,yBAAyB,IAAI;AAC/C,QAAM,uBAAuB;AAAA,IAC3B,kBAAkB;AAAA,IAClB;AAAA,IACA;AAAA,EACF;AAEA,QAAM,SAAS,sBAAsB,UAAU,OAAO;AACtD,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,cAAsC,CAAC;AAC7C,eAAW,SAAS,OAAO,MAAM,QAAQ;AACvC,YAAM,OAAO,MAAM,KAAK,CAAC;AACzB,UAAI,CAAC,KAAM;AACX,cAAQ,MAAM;AAAA,QACZ,KAAK;AACH,sBAAY,QAAQ,UAAU,kCAAkC,2BAA2B;AAC3F;AAAA,QACF,KAAK;AACH,sBAAY,YAAY,UAAU,uCAAuC,yBAAyB;AAClG;AAAA,QACF,KAAK;AACH,sBAAY,WAAW,UAAU,sCAAsC,wBAAwB;AAC/F;AAAA,QACF,KAAK;AACH,sBAAY,mBAAmB,UAAU,8CAA8C,gCAAgC;AACvH;AAAA,QACF,KAAK;AACH,sBAAY,WAAW;AAAA,YACrB;AAAA,YACA;AAAA,YACA,EAAE,cAAc,qBAAqB;AAAA,UACvC;AACA;AAAA,QACF,KAAK;AACH,sBAAY,kBAAkB,UAAU,sCAAsC,uBAAuB;AACrG;AAAA,QACF,KAAK;AACH,sBAAY,gBAAgB,UAAU,iCAAiC,sCAAsC;AAC7G;AAAA,QACF;AACE;AAAA,MACJ;AAAA,IACF;AACA,WAAO,aAAa,KAAK;AAAA,MACvB,IAAI;AAAA,MACJ,OAAO,UAAU,gCAAgC,sCAAsC;AAAA,MACvF;AAAA,IACF,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { loadDictionary } from '@open-mercato/shared/lib/i18n/server'\nimport { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { onboardingStartSchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendExistingAccountNotice } from '@open-mercato/onboarding/modules/onboarding/lib/existing-account-notice'\nimport VerificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail'\nimport AdminNotificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\n\nconst logger = createLogger('onboarding').child({ component: 'start' })\n\nexport const metadata = {\n path: '/onboarding/onboarding',\n POST: {\n requireAuth: false,\n rateLimit: readEndpointRateLimitConfig('ONBOARDING', {\n points: 10,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'onboarding',\n }),\n },\n}\n\nexport async function POST(req: Request) {\n if (parseBooleanToken(process.env.SELF_SERVICE_ONBOARDING_ENABLED ?? '') !== true) {\n return NextResponse.json({ ok: false, error: 'Self-service onboarding is disabled.' }, { status: 404 })\n }\n let payload: unknown\n try {\n payload = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid payload' }, { status: 400 })\n }\n\n const rawLocale =\n payload && typeof payload === 'object' && 'locale' in payload && typeof (payload as any).locale === 'string'\n ? (payload as any).locale as string\n : null\n const locale: Locale = rawLocale && locales.includes(rawLocale as Locale)\n ? (rawLocale as Locale)\n : defaultLocale\n const dict = await loadDictionary(locale)\n const translate = createFallbackTranslator(dict)\n const passwordRequirements = formatPasswordRequirements(\n getPasswordPolicy(),\n translate,\n 'onboarding.password.requirements',\n )\n\n const parsed = onboardingStartSchema.safeParse(payload)\n if (!parsed.success) {\n const fieldErrors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (!path) continue\n switch (path) {\n case 'email':\n fieldErrors.email = translate('onboarding.errors.emailInvalid', 'Enter a valid work email.')\n break\n case 'firstName':\n fieldErrors.firstName = translate('onboarding.errors.firstNameRequired', 'First name is required.')\n break\n case 'lastName':\n fieldErrors.lastName = translate('onboarding.errors.lastNameRequired', 'Last name is required.')\n break\n case 'organizationName':\n fieldErrors.organizationName = translate('onboarding.errors.organizationNameRequired', 'Organization name is required.')\n break\n case 'password':\n fieldErrors.password = translate(\n 'onboarding.errors.passwordRequired',\n 'Password must meet the requirements: {requirements}.',\n { requirements: passwordRequirements },\n )\n break\n case 'confirmPassword':\n fieldErrors.confirmPassword = translate('onboarding.errors.passwordMismatch', 'Passwords must match.')\n break\n case 'termsAccepted':\n fieldErrors.termsAccepted = translate('onboarding.form.termsRequired', 'Please accept the terms to continue.')\n break\n default:\n break\n }\n }\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Please check the form and try again.'),\n fieldErrors,\n }, { status: 400 })\n }\n\n // An unauthenticated caller must not be able to tell whether the submitted address\n // already has an account (#5505), so every accepted submission answers with this exact\n // body and a caller-visible email failure looks the same on both branches.\n const acceptedResponse = () => NextResponse.json({ ok: true, email: parsed.data.email })\n const emailSendFailedResponse = () => NextResponse.json({\n ok: false,\n error: translate(\n 'onboarding.errors.emailSendFailed',\n 'We could not send the verification email. Please try again or contact support.',\n ),\n }, { status: 502 })\n\n try {\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n\n // Resolved before the account lookup so a misconfigured APP_URL fails the same way for\n // both branches instead of becoming a second way to probe for an existing account.\n let baseUrl: string\n try {\n baseUrl = getSecurityEmailBaseUrl(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'onboarding.start',\n configMessage: 'Self-service onboarding is not configured.',\n })\n if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })\n throw error\n }\n\n const existingUser = await findOneWithDecryption(em, User, {\n deletedAt: null,\n $or: [\n { email: parsed.data.email },\n { emailHash: { $in: lookupHashCandidates(parsed.data.email) } },\n ],\n })\n if (existingUser) {\n // The notice goes to the account owner, not to the submitter, so it is rendered in the\n // instance default locale rather than the one this request carried \u2014 the submitter must\n // not get to choose which language lands in someone else's inbox.\n const noticeTranslate = locale === defaultLocale\n ? translate\n : createFallbackTranslator(await loadDictionary(defaultLocale))\n // Never create an onboarding request for an address that already has an account \u2014\n // the submitter's name and organization must not reach the account owner's inbox.\n // The neutral notice below is the only, out-of-band acknowledgement of the account.\n try {\n await sendExistingAccountNotice({\n container,\n email: parsed.data.email,\n loginUrl: `${baseUrl}/login`,\n subject: noticeTranslate('onboarding.existingAccountEmail.subject', 'About your Open Mercato account'),\n copy: {\n preview: noticeTranslate(\n 'onboarding.existingAccountEmail.preview',\n 'Someone tried to create an Open Mercato workspace with this email address.',\n ),\n heading: noticeTranslate('onboarding.existingAccountEmail.heading', 'You already have an account'),\n greeting: noticeTranslate('onboarding.existingAccountEmail.greeting', 'Hello,'),\n body: noticeTranslate(\n 'onboarding.existingAccountEmail.body',\n 'Someone just used this email address to start creating an Open Mercato workspace. This address already has an account, so we did not create a new one.',\n ),\n cta: noticeTranslate('onboarding.existingAccountEmail.cta', 'Sign in'),\n ignore: noticeTranslate(\n 'onboarding.existingAccountEmail.ignore',\n \"If this was you, sign in or reset your password. If it wasn't, you can safely ignore this message \u2014 nothing about your account has changed.\",\n ),\n footer: noticeTranslate('onboarding.existingAccountEmail.footer', 'Open Mercato \u00B7 Onboarding service'),\n },\n })\n } catch (err) {\n logger.error('Existing account notice email failed', { err })\n return emailSendFailedResponse()\n }\n return acceptedResponse()\n }\n\n const service = new OnboardingService(em)\n let request, token\n try {\n const result = await service.createOrUpdateRequest(parsed.data)\n request = result.request\n token = result.token\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('PENDING_REQUEST:')) {\n // The pending-request window is an anti-spam throttle, not a caller-visible state:\n // reporting it would let two probes rebuild the enumeration oracle, because the\n // window only ever exists for an address that has no account.\n logger.info('Onboarding request throttled by pending verification')\n return acceptedResponse()\n }\n throw err\n }\n\n const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`\n\n const firstName = request.firstName || parsed.data.firstName\n const hasMarketingConsent = request.marketingConsent === true\n const marketingConsentText = hasMarketingConsent\n ? translate('onboarding.email.marketingConsentYes', 'Marketing consent: Yes')\n : translate('onboarding.email.marketingConsentNo', 'Marketing consent: No')\n const subject = translate('onboarding.email.subject', 'Confirm your email to finish onboarding')\n const emailCopy = {\n preview: translate('onboarding.email.preview', 'Confirm your email to activate your Open Mercato workspace'),\n heading: translate('onboarding.email.heading', 'Welcome to Open Mercato'),\n greeting: translate('onboarding.email.greeting', 'Hi {firstName},', { firstName }),\n body: translate(\n 'onboarding.email.body',\n 'We just need to confirm your email address to finish setting up the organization {organizationName}.',\n { organizationName: request.organizationName },\n ),\n cta: translate('onboarding.email.cta', 'Confirm email & activate workspace'),\n expiry: translate(\n 'onboarding.email.expiry',\n \"The link will expire in 24 hours. If you didn't request this, you can safely ignore this message.\",\n ),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.footer', 'Open Mercato \u00B7 Onboarding service'),\n }\n const emailReact = VerificationEmail({ verifyUrl, copy: emailCopy })\n try {\n await sendEmail({ to: request.email, subject, react: emailReact })\n } catch (err) {\n request.lastEmailSentAt = null\n await em.flush()\n logger.error('Verification email failed', { err })\n return emailSendFailedResponse()\n }\n\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n const adminSubject = translate('onboarding.email.adminSubject', 'New self-service onboarding request')\n const adminCopy = {\n preview: translate('onboarding.email.adminPreview', 'New onboarding request submitted'),\n heading: translate('onboarding.email.adminHeading', 'New onboarding request'),\n body: translate('onboarding.email.adminBody', '{firstName} {lastName} ({email}) submitted an onboarding request for {organizationName}.', {\n firstName: request.firstName,\n lastName: request.lastName,\n email: request.email,\n organizationName: request.organizationName,\n }),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.adminFooter', 'You can review the tenant after verification is complete.'),\n }\n try {\n await sendEmail({\n to: adminEmail,\n subject: adminSubject,\n react: AdminNotificationEmail({ copy: adminCopy }),\n })\n } catch (err) {\n logger.error('Admin email failed', { err })\n }\n\n return acceptedResponse()\n } catch (error) {\n logger.error('Onboarding start failed', { err: error })\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Something went wrong. Please try again later.'),\n }, { status: 500 })\n }\n}\n\nexport default POST\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingSuccessSchema = z.object({\n ok: z.literal(true),\n email: z.string().email(),\n})\n\nconst onboardingErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n fieldErrors: z.record(z.string(), z.string()).optional(),\n})\n\nconst onboardingPostDoc: OpenApiMethodDoc = {\n summary: 'Submit onboarding request',\n description: 'Accepts a self-service onboarding form submission and triggers email verification. The response never reveals whether the submitted address already has an account: an address that does receives an out-of-band notice email instead of a verification link, and the caller sees the same accepted response either way. The guarantee covers the response status and body, not response latency \u2014 the new-address branch hashes a password and sends two emails where the existing-account branch sends one, so a caller that measures timing can still tell them apart. Deployments should also keep rate limiting enabled (and use the redis strategy when running more than one replica): the per-address window on the existing-account notice is backed by the rate limiter, so with rate limiting disabled the address owner receives one notice per accepted submission instead of one per ten minutes.',\n tags: [onboardingTag],\n requestBody: {\n contentType: 'application/json',\n schema: onboardingStartSchema,\n description: 'Onboarding form payload with contact and organization information.',\n },\n responses: [\n { status: 200, description: 'Onboarding request accepted. Returned for a new address, for an address that already has an account, and for a submission inside the pending-verification window.', schema: onboardingSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: onboardingErrorSchema },\n { status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },\n { status: 429, description: 'Too many onboarding submissions from this IP', schema: rateLimitErrorSchema },\n { status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },\n { status: 502, description: 'Outbound email could not be delivered', schema: onboardingErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Self-service onboarding submission',\n methods: {\n POST: onboardingPostDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Html, Head, Preview, Body, Container, Heading, Text, Section, Button, Hr } from "@react-email/components";
|
|
3
|
+
function ExistingAccountEmail({ loginUrl, copy }) {
|
|
4
|
+
return /* @__PURE__ */ jsxs(Html, { children: [
|
|
5
|
+
/* @__PURE__ */ jsx(Head, { children: /* @__PURE__ */ jsx("title", { children: copy.heading }) }),
|
|
6
|
+
/* @__PURE__ */ jsx(Preview, { children: copy.preview }),
|
|
7
|
+
/* @__PURE__ */ jsx(Body, { style: { backgroundColor: "#f1f5f9", fontFamily: "Helvetica, Arial, sans-serif", padding: "24px 0" }, children: /* @__PURE__ */ jsxs(Container, { style: { backgroundColor: "#ffffff", padding: "32px", borderRadius: "12px", margin: "0 auto", maxWidth: "520px" }, children: [
|
|
8
|
+
/* @__PURE__ */ jsx(Heading, { style: { fontSize: "24px", fontWeight: 600, margin: "0 0 16px" }, children: copy.heading }),
|
|
9
|
+
/* @__PURE__ */ jsx(Text, { style: { fontSize: "16px", color: "#334155", marginBottom: "16px" }, children: copy.greeting }),
|
|
10
|
+
/* @__PURE__ */ jsx(Text, { style: { fontSize: "16px", color: "#334155", marginBottom: "16px", lineHeight: "24px" }, children: copy.body }),
|
|
11
|
+
/* @__PURE__ */ jsx(Section, { style: { textAlign: "center", margin: "32px 0" }, children: /* @__PURE__ */ jsx(
|
|
12
|
+
Button,
|
|
13
|
+
{
|
|
14
|
+
href: loginUrl,
|
|
15
|
+
style: {
|
|
16
|
+
backgroundColor: "#111827",
|
|
17
|
+
color: "#ffffff",
|
|
18
|
+
padding: "12px 24px",
|
|
19
|
+
borderRadius: "8px",
|
|
20
|
+
fontSize: "15px",
|
|
21
|
+
textDecoration: "none",
|
|
22
|
+
display: "inline-block"
|
|
23
|
+
},
|
|
24
|
+
children: copy.cta
|
|
25
|
+
}
|
|
26
|
+
) }),
|
|
27
|
+
/* @__PURE__ */ jsx(Text, { style: { fontSize: "14px", color: "#64748b", marginBottom: "16px", lineHeight: "22px" }, children: copy.ignore }),
|
|
28
|
+
/* @__PURE__ */ jsx(Hr, { style: { borderColor: "#e2e8f0", margin: "24px 0" } }),
|
|
29
|
+
/* @__PURE__ */ jsx(Text, { style: { fontSize: "12px", color: "#94a3b8" }, children: copy.footer })
|
|
30
|
+
] }) })
|
|
31
|
+
] });
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
ExistingAccountEmail as default
|
|
35
|
+
};
|
|
36
|
+
//# sourceMappingURL=ExistingAccountEmail.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/onboarding/emails/ExistingAccountEmail.tsx"],
|
|
4
|
+
"sourcesContent": ["import React from 'react'\nimport { Html, Head, Preview, Body, Container, Heading, Text, Section, Button, Hr } from '@react-email/components'\n\nexport type ExistingAccountEmailCopy = {\n preview: string\n heading: string\n greeting: string\n body: string\n cta: string\n ignore: string\n footer: string\n}\n\ntype ExistingAccountEmailProps = {\n loginUrl: string\n copy: ExistingAccountEmailCopy\n}\n\nexport default function ExistingAccountEmail({ loginUrl, copy }: ExistingAccountEmailProps) {\n return (\n <Html>\n <Head>\n <title>{copy.heading}</title>\n </Head>\n <Preview>{copy.preview}</Preview>\n <Body style={{ backgroundColor: '#f1f5f9', fontFamily: 'Helvetica, Arial, sans-serif', padding: '24px 0' }}>\n <Container style={{ backgroundColor: '#ffffff', padding: '32px', borderRadius: '12px', margin: '0 auto', maxWidth: '520px' }}>\n <Heading style={{ fontSize: '24px', fontWeight: 600, margin: '0 0 16px' }}>{copy.heading}</Heading>\n <Text style={{ fontSize: '16px', color: '#334155', marginBottom: '16px' }}>{copy.greeting}</Text>\n <Text style={{ fontSize: '16px', color: '#334155', marginBottom: '16px', lineHeight: '24px' }}>{copy.body}</Text>\n <Section style={{ textAlign: 'center', margin: '32px 0' }}>\n <Button\n href={loginUrl}\n style={{\n backgroundColor: '#111827',\n color: '#ffffff',\n padding: '12px 24px',\n borderRadius: '8px',\n fontSize: '15px',\n textDecoration: 'none',\n display: 'inline-block',\n }}\n >\n {copy.cta}\n </Button>\n </Section>\n <Text style={{ fontSize: '14px', color: '#64748b', marginBottom: '16px', lineHeight: '22px' }}>{copy.ignore}</Text>\n <Hr style={{ borderColor: '#e2e8f0', margin: '24px 0' }} />\n <Text style={{ fontSize: '12px', color: '#94a3b8' }}>{copy.footer}</Text>\n </Container>\n </Body>\n </Html>\n )\n}\n"],
|
|
5
|
+
"mappings": "AAsBQ,cAIA,YAJA;AArBR,SAAS,MAAM,MAAM,SAAS,MAAM,WAAW,SAAS,MAAM,SAAS,QAAQ,UAAU;AAiB1E,SAAR,qBAAsC,EAAE,UAAU,KAAK,GAA8B;AAC1F,SACE,qBAAC,QACC;AAAA,wBAAC,QACC,8BAAC,WAAO,eAAK,SAAQ,GACvB;AAAA,IACA,oBAAC,WAAS,eAAK,SAAQ;AAAA,IACvB,oBAAC,QAAK,OAAO,EAAE,iBAAiB,WAAW,YAAY,gCAAgC,SAAS,SAAS,GACvG,+BAAC,aAAU,OAAO,EAAE,iBAAiB,WAAW,SAAS,QAAQ,cAAc,QAAQ,QAAQ,UAAU,UAAU,QAAQ,GACzH;AAAA,0BAAC,WAAQ,OAAO,EAAE,UAAU,QAAQ,YAAY,KAAK,QAAQ,WAAW,GAAI,eAAK,SAAQ;AAAA,MACzF,oBAAC,QAAK,OAAO,EAAE,UAAU,QAAQ,OAAO,WAAW,cAAc,OAAO,GAAI,eAAK,UAAS;AAAA,MAC1F,oBAAC,QAAK,OAAO,EAAE,UAAU,QAAQ,OAAO,WAAW,cAAc,QAAQ,YAAY,OAAO,GAAI,eAAK,MAAK;AAAA,MAC1G,oBAAC,WAAQ,OAAO,EAAE,WAAW,UAAU,QAAQ,SAAS,GACtD;AAAA,QAAC;AAAA;AAAA,UACC,MAAM;AAAA,UACN,OAAO;AAAA,YACL,iBAAiB;AAAA,YACjB,OAAO;AAAA,YACP,SAAS;AAAA,YACT,cAAc;AAAA,YACd,UAAU;AAAA,YACV,gBAAgB;AAAA,YAChB,SAAS;AAAA,UACX;AAAA,UAEC,eAAK;AAAA;AAAA,MACR,GACF;AAAA,MACA,oBAAC,QAAK,OAAO,EAAE,UAAU,QAAQ,OAAO,WAAW,cAAc,QAAQ,YAAY,OAAO,GAAI,eAAK,QAAO;AAAA,MAC5G,oBAAC,MAAG,OAAO,EAAE,aAAa,WAAW,QAAQ,SAAS,GAAG;AAAA,MACzD,oBAAC,QAAK,OAAO,EAAE,UAAU,QAAQ,OAAO,UAAU,GAAI,eAAK,QAAO;AAAA,OACpE,GACF;AAAA,KACF;AAEJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { hashForLookup } from "@open-mercato/shared/lib/encryption/aes";
|
|
2
|
+
import { sendEmail } from "@open-mercato/shared/lib/email/send";
|
|
3
|
+
import ExistingAccountEmail from "../emails/ExistingAccountEmail.js";
|
|
4
|
+
const NOTICE_WINDOW_SECONDS = 10 * 60;
|
|
5
|
+
const noticeRateLimitConfig = {
|
|
6
|
+
points: 1,
|
|
7
|
+
duration: NOTICE_WINDOW_SECONDS,
|
|
8
|
+
blockDuration: 0,
|
|
9
|
+
keyPrefix: "onboarding-existing-account"
|
|
10
|
+
};
|
|
11
|
+
function resolveRateLimiter(container) {
|
|
12
|
+
try {
|
|
13
|
+
const resolved = container.resolve("rateLimiterService");
|
|
14
|
+
if (!resolved || typeof resolved !== "object") return null;
|
|
15
|
+
const candidate = resolved;
|
|
16
|
+
if (typeof candidate.consume !== "function" || typeof candidate.delete !== "function") return null;
|
|
17
|
+
return candidate;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function sendExistingAccountNotice(args) {
|
|
23
|
+
const rateLimiterService = resolveRateLimiter(args.container);
|
|
24
|
+
const throttleKey = hashForLookup(args.email);
|
|
25
|
+
if (rateLimiterService) {
|
|
26
|
+
const consumed = await rateLimiterService.consume(throttleKey, noticeRateLimitConfig);
|
|
27
|
+
if (!consumed.allowed) return "throttled";
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await sendEmail({
|
|
31
|
+
to: args.email,
|
|
32
|
+
subject: args.subject,
|
|
33
|
+
react: ExistingAccountEmail({ loginUrl: args.loginUrl, copy: args.copy })
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (rateLimiterService) {
|
|
37
|
+
await rateLimiterService.delete(throttleKey, noticeRateLimitConfig).catch(() => {
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
return "sent";
|
|
43
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
sendExistingAccountNotice
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=existing-account-notice.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/onboarding/lib/existing-account-notice.ts"],
|
|
4
|
+
"sourcesContent": ["import { hashForLookup } from '@open-mercato/shared/lib/encryption/aes'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport type { RateLimitConfig } from '@open-mercato/shared/lib/ratelimit/types'\nimport ExistingAccountEmail, { type ExistingAccountEmailCopy } from '../emails/ExistingAccountEmail'\n\n/**\n * Best-effort ten-minute anti-spam window on the notice, backed by `rateLimiterService`.\n *\n * The duration matches the window `OnboardingService.createOrUpdateRequest` enforces on\n * verification emails, but the mechanism is deliberately weaker and must not be read as\n * equivalent: that window is a database column (`OnboardingRequest.lastEmailSentAt`), which is\n * authoritative, shared by every process and independent of the rate-limiting configuration.\n * This one is a limiter counter, so it is per-process under the default `memory` strategy\n * (`RATE_LIMIT_STRATEGY=redis` restores a shared window across replicas) and absent entirely\n * when rate limiting is switched off \u2014 `RateLimiterService.consume()` returns `allowed: true`\n * unconditionally while `RATE_LIMIT_ENABLED=false`. An instance exposing self-service\n * onboarding should therefore keep rate limiting enabled, or the address owner can be mailed\n * once per accepted submission instead of once per window.\n *\n * Failing closed when the limiter is unavailable is not an option here: skipping the send would\n * make the has-account branch answer 200 while the no-account branch still answers 502 on an\n * instance with broken mail delivery, which re-opens the enumeration oracle (#5505) the uniform\n * response shape closes. The window is a spam brake, never a disclosure control.\n */\nconst NOTICE_WINDOW_SECONDS = 10 * 60\n\nconst noticeRateLimitConfig: RateLimitConfig = {\n points: 1,\n duration: NOTICE_WINDOW_SECONDS,\n blockDuration: 0,\n keyPrefix: 'onboarding-existing-account',\n}\n\nexport type ExistingAccountNoticeOutcome = 'sent' | 'throttled'\n\ntype RateLimiterLike = {\n consume: (key: string, config: RateLimitConfig) => Promise<{ allowed: boolean }>\n delete: (key: string, config: RateLimitConfig) => Promise<void>\n}\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\nexport type SendExistingAccountNoticeArgs = {\n container: ContainerLike\n email: string\n loginUrl: string\n subject: string\n copy: ExistingAccountEmailCopy\n}\n\n/**\n * The limiter is registered only when rate limiting is configured, so an unregistered token\n * is a normal deployment state rather than an error. Resolved from the request container\n * instead of the core bootstrap singleton so this route keeps its lean module graph.\n *\n * Returning `null` means the notice is sent unthrottled \u2014 the fail-open half of the trade-off\n * documented on `NOTICE_WINDOW_SECONDS`, chosen because a skipped send would answer differently\n * from the no-account branch and re-open the enumeration oracle. The same reasoning is why\n * `consume()`'s `degraded` flag is not read: a degraded limiter still has to send.\n */\nfunction resolveRateLimiter(container: ContainerLike): RateLimiterLike | null {\n try {\n const resolved = container.resolve('rateLimiterService')\n if (!resolved || typeof resolved !== 'object') return null\n const candidate = resolved as Partial<RateLimiterLike>\n if (typeof candidate.consume !== 'function' || typeof candidate.delete !== 'function') return null\n return candidate as RateLimiterLike\n } catch {\n return null\n }\n}\n\n/**\n * Tell the address owner \u2014 out of band \u2014 that someone tried to create a workspace with an\n * email that already has an account. The HTTP caller never learns which branch ran, so the\n * copy carries nothing the submitter supplied (no name, no organization name) and the mail\n * is the only place the existing account is acknowledged.\n *\n * Throws whatever `sendEmail` throws so the caller can answer with the same 502 body the\n * verification-email branch already returns.\n */\nexport async function sendExistingAccountNotice(\n args: SendExistingAccountNoticeArgs,\n): Promise<ExistingAccountNoticeOutcome> {\n const rateLimiterService = resolveRateLimiter(args.container)\n const throttleKey = hashForLookup(args.email)\n\n if (rateLimiterService) {\n const consumed = await rateLimiterService.consume(throttleKey, noticeRateLimitConfig)\n if (!consumed.allowed) return 'throttled'\n }\n\n try {\n await sendEmail({\n to: args.email,\n subject: args.subject,\n react: ExistingAccountEmail({ loginUrl: args.loginUrl, copy: args.copy }),\n })\n } catch (error) {\n // Release the window the same way the verification branch clears `lastEmailSentAt` on a\n // failed send, so a retry behaves identically on both branches of a broken-mail instance.\n if (rateLimiterService) {\n await rateLimiterService.delete(throttleKey, noticeRateLimitConfig).catch(() => {})\n }\n throw error\n }\n\n return 'sent'\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,qBAAqB;AAC9B,SAAS,iBAAiB;AAE1B,OAAO,0BAA6D;AAqBpE,MAAM,wBAAwB,KAAK;AAEnC,MAAM,wBAAyC;AAAA,EAC7C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AACb;AA6BA,SAAS,mBAAmB,WAAkD;AAC5E,MAAI;AACF,UAAM,WAAW,UAAU,QAAQ,oBAAoB;AACvD,QAAI,CAAC,YAAY,OAAO,aAAa,SAAU,QAAO;AACtD,UAAM,YAAY;AAClB,QAAI,OAAO,UAAU,YAAY,cAAc,OAAO,UAAU,WAAW,WAAY,QAAO;AAC9F,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAWA,eAAsB,0BACpB,MACuC;AACvC,QAAM,qBAAqB,mBAAmB,KAAK,SAAS;AAC5D,QAAM,cAAc,cAAc,KAAK,KAAK;AAE5C,MAAI,oBAAoB;AACtB,UAAM,WAAW,MAAM,mBAAmB,QAAQ,aAAa,qBAAqB;AACpF,QAAI,CAAC,SAAS,QAAS,QAAO;AAAA,EAChC;AAEA,MAAI;AACF,UAAM,UAAU;AAAA,MACd,IAAI,KAAK;AAAA,MACT,SAAS,KAAK;AAAA,MACd,OAAO,qBAAqB,EAAE,UAAU,KAAK,UAAU,MAAM,KAAK,KAAK,CAAC;AAAA,IAC1E,CAAC;AAAA,EACH,SAAS,OAAO;AAGd,QAAI,oBAAoB;AACtB,YAAM,mBAAmB,OAAO,aAAa,qBAAqB,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACpF;AACA,UAAM;AAAA,EACR;AAEA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/onboarding",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7111.1.c0d5d7286f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
73
|
+
"@open-mercato/shared": "0.7.1-develop.7111.1.c0d5d7286f"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
76
|
+
"@open-mercato/shared": "0.7.1-develop.7111.1.c0d5d7286f",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"jest": "^30.4.2",
|
|
79
79
|
"ts-jest": "^29.4.12",
|