@open-mercato/onboarding 0.6.7-develop.6851.1.c1680b00d8 → 0.6.7-develop.6860.1.f796b22e80

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.
Files changed (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +25 -2
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +2 -2
  4. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +3 -2
  5. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +2 -2
  6. package/dist/modules/onboarding/api/post/onboarding.js +9 -1
  7. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  8. package/dist/modules/onboarding/data/entities.js +4 -0
  9. package/dist/modules/onboarding/data/entities.js.map +2 -2
  10. package/dist/modules/onboarding/encryption.js +4 -2
  11. package/dist/modules/onboarding/encryption.js.map +2 -2
  12. package/dist/modules/onboarding/lib/service.js +18 -6
  13. package/dist/modules/onboarding/lib/service.js.map +2 -2
  14. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js +15 -0
  15. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js.map +7 -0
  16. package/generated/entities/onboarding_request/index.ts +1 -0
  17. package/generated/entity-fields-registry.ts +1 -0
  18. package/package.json +3 -3
  19. package/src/__tests__/backfill-legacy-request.test.ts +213 -0
  20. package/src/__tests__/encryption.test.ts +19 -0
  21. package/src/__tests__/service.test.ts +16 -0
  22. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +37 -2
  23. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +3 -2
  24. package/src/modules/onboarding/api/post/onboarding.ts +9 -1
  25. package/src/modules/onboarding/data/entities.ts +4 -0
  26. package/src/modules/onboarding/encryption.ts +3 -1
  27. package/src/modules/onboarding/lib/service.ts +17 -5
  28. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +19 -0
  29. package/src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts +15 -0
@@ -1,2 +1,2 @@
1
- [build:onboarding] found 33 entry points
1
+ [build:onboarding] found 34 entry points
2
2
  [build:onboarding] built successfully
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { expect, test } from "@playwright/test";
3
3
  import { withClient } from "@open-mercato/core/helpers/integration/dbFixtures";
4
+ import { lookupHashCandidates } from "@open-mercato/shared/lib/encryption/aes";
4
5
  const integrationMeta = {
5
6
  dependsOnModules: ["onboarding"],
6
7
  requiredEnvVars: ["SELF_SERVICE_ONBOARDING_ENABLED"],
@@ -18,14 +19,29 @@ async function replaceVerificationToken(email, token) {
18
19
  set token_hash = $2,
19
20
  expires_at = now() + interval '24 hours',
20
21
  updated_at = now()
21
- where email = $1
22
+ where email_hash = any($1::text[])
22
23
  returning id`,
23
- [email, hashToken(token)]
24
+ [lookupHashCandidates(email), hashToken(token)]
24
25
  );
25
26
  expect(result.rowCount, "onboarding request should exist after submitting the form").toBe(1);
26
27
  return result.rows[0].id;
27
28
  });
28
29
  }
30
+ async function readPendingRequestAtRest(email) {
31
+ return withClient(async (client) => {
32
+ const result = await client.query(
33
+ `select email, email_hash, first_name, last_name, organization_name, password_hash
34
+ from onboarding_requests
35
+ where email_hash = any($1::text[])`,
36
+ [lookupHashCandidates(email)]
37
+ );
38
+ expect(result.rowCount, "onboarding request should be queryable by its lookup hash").toBe(1);
39
+ return result.rows[0];
40
+ });
41
+ }
42
+ function expectEncryptedPayload(value) {
43
+ expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/);
44
+ }
29
45
  async function readCompletedRequest(requestId) {
30
46
  return withClient(async (client) => {
31
47
  const result = await client.query(
@@ -107,6 +123,13 @@ test.describe("TC-ONB-001: self-service onboarding with marketing consent", () =
107
123
  await page.getByRole("button", { name: "Send verification email" }).click();
108
124
  await expect(page.getByRole("status")).toContainText("Check your inbox");
109
125
  await expect(page.getByRole("status")).toContainText(email);
126
+ const pendingAtRest = await readPendingRequestAtRest(email);
127
+ expect(lookupHashCandidates(email)).toContain(pendingAtRest.email_hash);
128
+ expectEncryptedPayload(pendingAtRest.email);
129
+ expectEncryptedPayload(pendingAtRest.first_name);
130
+ expectEncryptedPayload(pendingAtRest.last_name);
131
+ expectEncryptedPayload(pendingAtRest.organization_name);
132
+ expectEncryptedPayload(pendingAtRest.password_hash);
110
133
  const requestId = await replaceVerificationToken(email, token);
111
134
  const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;
112
135
  await page.setContent(`<a href="${verifyUrl}">Verify workspace</a>`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts"],
4
- "sourcesContent": ["import { createHash, randomUUID } from 'node:crypto';\nimport { expect, test } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingRequestRow = {\n id: string;\n status: string;\n tenant_id: string | null;\n organization_id: string | null;\n user_id: string | null;\n marketing_consent: boolean | null;\n completed_at: Date | null;\n};\n\ntype UserConsentRow = {\n consent_type: string;\n is_granted: boolean;\n source: string | null;\n integrity_hash: string | null;\n granted_at: Date | null;\n};\n\nconst ONBOARDING_PASSWORD = 'IntegrationPass123!';\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email = $1\n returning id`,\n [email, hashToken(token)],\n );\n expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {\n return withClient(async (client) => {\n const result = await client.query<OnboardingRequestRow>(\n `select id, status, tenant_id, organization_id, user_id, marketing_consent, completed_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, 'completed onboarding request should remain queryable').toBe(1);\n return result.rows[0];\n });\n}\n\nasync function readMarketingConsent(userId: string): Promise<UserConsentRow> {\n return withClient(async (client) => {\n const result = await client.query<UserConsentRow>(\n `select consent_type, is_granted, source, integrity_hash, granted_at\n from user_consents\n where user_id = $1 and consent_type = 'marketing_email'\n order by created_at desc\n limit 1`,\n [userId],\n );\n expect(result.rowCount, 'marketing consent should be persisted for the onboarded user').toBe(1);\n return result.rows[0];\n });\n}\n\ntest.describe('TC-ONB-001: self-service onboarding with marketing consent', () => {\n test('does not offer tenant login while workspace preparation is still running', async ({ page }) => {\n const tenantId = '33333333-3333-4333-8333-333333333333';\n await page.route('**/api/onboarding/onboarding/status?**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n ok: true,\n status: 'processing',\n ready: false,\n emailSent: false,\n tenantId,\n loginUrl: null,\n }),\n });\n });\n\n await page.goto(`/onboarding/preparing?tenant=${tenantId}`);\n\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);\n await expect(page.getByRole('link', { name: 'Go to home page' })).toBeVisible();\n });\n\n test('shows a retryable error when workspace status polling fails', async ({ page }) => {\n const tenantId = '33333333-3333-4333-8333-333333333333';\n await page.route('**/api/onboarding/onboarding/status?**', async (route) => {\n await route.fulfill({\n status: 400,\n contentType: 'application/json',\n body: JSON.stringify({ ok: false, error: 'Invalid request origin' }),\n });\n });\n\n await page.goto(`/onboarding/preparing?tenant=${tenantId}`);\n\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const statusAlert = page.getByText('Workspace status check failed').locator('..');\n await expect(statusAlert).toContainText('Invalid request origin');\n await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);\n });\n\n test('creates the workspace, verifies from the email link, and logs in to the new tenant', async ({ page }) => {\n const unique = randomUUID().slice(0, 8);\n const email = `qa-onboarding-${unique}@example.test`;\n const organizationName = `QA Onboarding ${unique}`;\n const token = `integration-onboarding-${randomUUID().replace(/-/g, '')}`;\n\n await page.goto('/onboarding');\n await expect(page.getByText('Create your Open Mercato workspace')).toBeVisible();\n\n await page.getByLabel('Work email').fill(email);\n await page.getByLabel('First name').fill('QA');\n await page.getByLabel('Last name').fill('Onboarding');\n await page.getByLabel('Organization name').fill(organizationName);\n await page.locator('input[name=\"password\"]').fill(ONBOARDING_PASSWORD);\n await page.locator('input[name=\"confirmPassword\"]').fill(ONBOARDING_PASSWORD);\n await page.locator('#terms').click();\n await page.locator('#marketingConsent').click();\n await page.getByRole('button', { name: 'Send verification email' }).click();\n\n await expect(page.getByRole('status')).toContainText('Check your inbox');\n await expect(page.getByRole('status')).toContainText(email);\n\n const requestId = await replaceVerificationToken(email, token);\n const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;\n\n await page.setContent(`<a href=\"${verifyUrl}\">Verify workspace</a>`);\n await page.getByRole('link', { name: 'Verify workspace' }).click();\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n\n const completed = await readCompletedRequest(requestId);\n expect(completed.status).toBe('completed');\n expect(completed.marketing_consent).toBe(true);\n expect(completed.completed_at).toBeTruthy();\n expect(completed.tenant_id, 'tenant id should be recorded').toBeTruthy();\n expect(completed.organization_id, 'organization id should be recorded').toBeTruthy();\n expect(completed.user_id, 'user id should be recorded').toBeTruthy();\n\n const consent = await readMarketingConsent(completed.user_id!);\n expect(consent.consent_type).toBe('marketing_email');\n expect(consent.is_granted).toBe(true);\n expect(consent.source).toBeTruthy();\n expect(consent.granted_at).toBeTruthy();\n expect(consent.integrity_hash, 'consent integrity hash should be computed instead of redirecting to status=error').toBeTruthy();\n\n await page.goto(`/login?tenant=${encodeURIComponent(completed.tenant_id!)}`);\n await expect(page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await expect(page.getByText(/You're logging in to/i)).toBeVisible();\n await page.getByLabel('Email').fill(email);\n await page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await page.getByRole('button', { name: 'Sign in' }).click();\n await expect(page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n });\n});\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createHash, randomUUID } from 'node:crypto';\nimport { expect, test } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingRequestRow = {\n id: string;\n status: string;\n tenant_id: string | null;\n organization_id: string | null;\n user_id: string | null;\n marketing_consent: boolean | null;\n completed_at: Date | null;\n};\n\ntype UserConsentRow = {\n consent_type: string;\n is_granted: boolean;\n source: string | null;\n integrity_hash: string | null;\n granted_at: Date | null;\n};\n\ntype PendingRequestAtRest = {\n email: string;\n email_hash: string | null;\n first_name: string;\n last_name: string;\n organization_name: string;\n password_hash: string;\n};\n\nconst ONBOARDING_PASSWORD = 'IntegrationPass123!';\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email_hash = any($1::text[])\n returning id`,\n [lookupHashCandidates(email), hashToken(token)],\n );\n expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readPendingRequestAtRest(email: string): Promise<PendingRequestAtRest> {\n return withClient(async (client) => {\n const result = await client.query<PendingRequestAtRest>(\n `select email, email_hash, first_name, last_name, organization_name, password_hash\n from onboarding_requests\n where email_hash = any($1::text[])`,\n [lookupHashCandidates(email)],\n );\n expect(result.rowCount, 'onboarding request should be queryable by its lookup hash').toBe(1);\n return result.rows[0];\n });\n}\n\nfunction expectEncryptedPayload(value: string): void {\n expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/);\n}\n\nasync function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {\n return withClient(async (client) => {\n const result = await client.query<OnboardingRequestRow>(\n `select id, status, tenant_id, organization_id, user_id, marketing_consent, completed_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, 'completed onboarding request should remain queryable').toBe(1);\n return result.rows[0];\n });\n}\n\nasync function readMarketingConsent(userId: string): Promise<UserConsentRow> {\n return withClient(async (client) => {\n const result = await client.query<UserConsentRow>(\n `select consent_type, is_granted, source, integrity_hash, granted_at\n from user_consents\n where user_id = $1 and consent_type = 'marketing_email'\n order by created_at desc\n limit 1`,\n [userId],\n );\n expect(result.rowCount, 'marketing consent should be persisted for the onboarded user').toBe(1);\n return result.rows[0];\n });\n}\n\ntest.describe('TC-ONB-001: self-service onboarding with marketing consent', () => {\n test('does not offer tenant login while workspace preparation is still running', async ({ page }) => {\n const tenantId = '33333333-3333-4333-8333-333333333333';\n await page.route('**/api/onboarding/onboarding/status?**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n ok: true,\n status: 'processing',\n ready: false,\n emailSent: false,\n tenantId,\n loginUrl: null,\n }),\n });\n });\n\n await page.goto(`/onboarding/preparing?tenant=${tenantId}`);\n\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);\n await expect(page.getByRole('link', { name: 'Go to home page' })).toBeVisible();\n });\n\n test('shows a retryable error when workspace status polling fails', async ({ page }) => {\n const tenantId = '33333333-3333-4333-8333-333333333333';\n await page.route('**/api/onboarding/onboarding/status?**', async (route) => {\n await route.fulfill({\n status: 400,\n contentType: 'application/json',\n body: JSON.stringify({ ok: false, error: 'Invalid request origin' }),\n });\n });\n\n await page.goto(`/onboarding/preparing?tenant=${tenantId}`);\n\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const statusAlert = page.getByText('Workspace status check failed').locator('..');\n await expect(statusAlert).toContainText('Invalid request origin');\n await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);\n });\n\n test('creates the workspace, verifies from the email link, and logs in to the new tenant', async ({ page }) => {\n const unique = randomUUID().slice(0, 8);\n const email = `qa-onboarding-${unique}@example.test`;\n const organizationName = `QA Onboarding ${unique}`;\n const token = `integration-onboarding-${randomUUID().replace(/-/g, '')}`;\n\n await page.goto('/onboarding');\n await expect(page.getByText('Create your Open Mercato workspace')).toBeVisible();\n\n await page.getByLabel('Work email').fill(email);\n await page.getByLabel('First name').fill('QA');\n await page.getByLabel('Last name').fill('Onboarding');\n await page.getByLabel('Organization name').fill(organizationName);\n await page.locator('input[name=\"password\"]').fill(ONBOARDING_PASSWORD);\n await page.locator('input[name=\"confirmPassword\"]').fill(ONBOARDING_PASSWORD);\n await page.locator('#terms').click();\n await page.locator('#marketingConsent').click();\n await page.getByRole('button', { name: 'Send verification email' }).click();\n\n await expect(page.getByRole('status')).toContainText('Check your inbox');\n await expect(page.getByRole('status')).toContainText(email);\n\n const pendingAtRest = await readPendingRequestAtRest(email);\n expect(lookupHashCandidates(email)).toContain(pendingAtRest.email_hash);\n expectEncryptedPayload(pendingAtRest.email);\n expectEncryptedPayload(pendingAtRest.first_name);\n expectEncryptedPayload(pendingAtRest.last_name);\n expectEncryptedPayload(pendingAtRest.organization_name);\n expectEncryptedPayload(pendingAtRest.password_hash);\n\n const requestId = await replaceVerificationToken(email, token);\n const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;\n\n await page.setContent(`<a href=\"${verifyUrl}\">Verify workspace</a>`);\n await page.getByRole('link', { name: 'Verify workspace' }).click();\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n\n const completed = await readCompletedRequest(requestId);\n expect(completed.status).toBe('completed');\n expect(completed.marketing_consent).toBe(true);\n expect(completed.completed_at).toBeTruthy();\n expect(completed.tenant_id, 'tenant id should be recorded').toBeTruthy();\n expect(completed.organization_id, 'organization id should be recorded').toBeTruthy();\n expect(completed.user_id, 'user id should be recorded').toBeTruthy();\n\n const consent = await readMarketingConsent(completed.user_id!);\n expect(consent.consent_type).toBe('marketing_email');\n expect(consent.is_granted).toBe(true);\n expect(consent.source).toBeTruthy();\n expect(consent.granted_at).toBeTruthy();\n expect(consent.integrity_hash, 'consent integrity hash should be computed instead of redirecting to status=error').toBeTruthy();\n\n await page.goto(`/login?tenant=${encodeURIComponent(completed.tenant_id!)}`);\n await expect(page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await expect(page.getByText(/You're logging in to/i)).toBeVisible();\n await page.getByLabel('Email').fill(email);\n await page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await page.getByRole('button', { name: 'Sign in' }).click();\n await expect(page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n });\n});\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { expect, test } from "@playwright/test";
3
3
  import { withClient } from "@open-mercato/core/helpers/integration/dbFixtures";
4
+ import { lookupHashCandidates } from "@open-mercato/shared/lib/encryption/aes";
4
5
  const integrationMeta = {
5
6
  dependsOnModules: ["onboarding"],
6
7
  requiredEnvVars: ["SELF_SERVICE_ONBOARDING_ENABLED"],
@@ -18,9 +19,9 @@ async function replaceVerificationToken(email, token) {
18
19
  set token_hash = $2,
19
20
  expires_at = now() + interval '24 hours',
20
21
  updated_at = now()
21
- where email = $1
22
+ where email_hash = any($1::text[])
22
23
  returning id`,
23
- [email, hashToken(token)]
24
+ [lookupHashCandidates(email), hashToken(token)]
24
25
  );
25
26
  expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
26
27
  return result.rows[0].id;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts"],
4
- "sourcesContent": ["// Adapted from PR #3007 (pkarw) \u2014 multi-tenant parallel onboarding repro for the\n// 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at\n// lease column introduced by the single-flight deferred-provisioning fix.\nimport { createHash, randomUUID } from 'node:crypto';\nimport { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingTenant = {\n email: string;\n organizationName: string;\n token: string;\n requestId?: string;\n tenantId?: string;\n};\n\ntype BrowserTenantSession = {\n context: BrowserContext;\n page: Page;\n refreshRequests: string[];\n};\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\nconst ONBOARDING_PASSWORD = 'ParallelPass123!';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email = $1\n returning id`,\n [email, hashToken(token)],\n );\n expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readPreparationState(requestId: string): Promise<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n}> {\n return withClient(async (client) => {\n const result = await client.query<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n }>(\n `select preparation_completed_at, preparation_started_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);\n return result.rows[0];\n });\n}\n\nasync function waitForPreparationComplete(\n request: APIRequestContext,\n tenant: OnboardingTenant,\n): Promise<void> {\n const deadline = Date.now() + 60_000;\n let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;\n while (Date.now() < deadline) {\n // Drive the same recovery the real preparing page provides: it polls the\n // status endpoint every ~1s, and each poll re-schedules deferred\n // provisioning when no fresh claim is held. Watching only the DB would never\n // recover a deferred runner that crashed after claiming its lease.\n await pollOnboardingStatus(request, tenant.tenantId!).catch(() => undefined);\n lastState = await readPreparationState(tenant.requestId!);\n if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();\n expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();\n}\n\nasync function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {\n const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {\n data: {\n email: tenant.email,\n firstName: 'Parallel',\n lastName: 'Login',\n organizationName: tenant.organizationName,\n password: ONBOARDING_PASSWORD,\n confirmPassword: ONBOARDING_PASSWORD,\n termsAccepted: true,\n marketingConsent: true,\n locale: 'en',\n },\n });\n expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);\n}\n\nasync function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {\n const context = await browser.newContext({ baseURL: BASE_URL });\n const page = await context.newPage();\n const refreshRequests: string[] = [];\n page.on('request', (browserRequest) => {\n const url = browserRequest.url();\n if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);\n });\n\n await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const tenantId = new URL(page.url()).searchParams.get('tenant');\n expect(tenantId, 'verify redirect should include tenant id').toMatch(\n /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,\n );\n return { context, page, refreshRequests, tenantId: tenantId! };\n}\n\nasync function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {\n return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {\n headers: {\n Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,\n },\n });\n}\n\nasync function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {\n expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();\n await expect(session.page).toHaveURL(new RegExp(`/login\\\\?tenant=${tenant.tenantId}`));\n await expect(session.page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await session.page.getByLabel('Email').fill(tenant.email);\n await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await session.page.getByRole('button', { name: 'Sign in' }).click();\n await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\\/backend(?:\\/.*)?$/);\n await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);\n\n const profileResult = await session.page.evaluate(async () => {\n const response = await fetch('/api/auth/profile', { credentials: 'include' });\n const body = await response.json().catch(() => null);\n return { status: response.status, body };\n });\n expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);\n const profile = profileResult.body;\n expect(profile.email).toBe(tenant.email);\n expect(profile.roles).toContain('admin');\n\n await session.page.goto('/backend');\n await expect(session.page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);\n}\n\ntest.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {\n test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({\n browser,\n request,\n }) => {\n // Two full onboardings + parallel browser logins + DB-polled preparation\n // far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).\n test.setTimeout(120_000);\n const unique = randomUUID().slice(0, 8);\n const tenants: OnboardingTenant[] = [1, 2].map((index) => ({\n email: `qa-onboarding-parallel-${unique}-${index}@example.test`,\n organizationName: `QA Onboarding Parallel ${unique} ${index}`,\n token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,\n }));\n\n await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));\n\n for (const tenant of tenants) {\n tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);\n }\n\n const browserSessions = await Promise.all(\n tenants.map(async (tenant) => {\n const session = await verifyTenantInBrowser(browser, tenant.token);\n tenant.tenantId = session.tenantId;\n return session;\n }),\n );\n\n try {\n const statusResponses = await Promise.all(\n tenants.flatMap((tenant) =>\n Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),\n ),\n );\n for (const response of statusResponses) {\n expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);\n }\n\n await Promise.all(tenants.map((tenant) => waitForPreparationComplete(request, tenant)));\n await Promise.all(\n tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),\n );\n } finally {\n await Promise.all(browserSessions.map((session) => session.context.close()));\n }\n });\n});\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// Adapted from PR #3007 (pkarw) \u2014 multi-tenant parallel onboarding repro for the\n// 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at\n// lease column introduced by the single-flight deferred-provisioning fix.\nimport { createHash, randomUUID } from 'node:crypto';\nimport { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingTenant = {\n email: string;\n organizationName: string;\n token: string;\n requestId?: string;\n tenantId?: string;\n};\n\ntype BrowserTenantSession = {\n context: BrowserContext;\n page: Page;\n refreshRequests: string[];\n};\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\nconst ONBOARDING_PASSWORD = 'ParallelPass123!';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email_hash = any($1::text[])\n returning id`,\n [lookupHashCandidates(email), hashToken(token)],\n );\n expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readPreparationState(requestId: string): Promise<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n}> {\n return withClient(async (client) => {\n const result = await client.query<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n }>(\n `select preparation_completed_at, preparation_started_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);\n return result.rows[0];\n });\n}\n\nasync function waitForPreparationComplete(\n request: APIRequestContext,\n tenant: OnboardingTenant,\n): Promise<void> {\n const deadline = Date.now() + 60_000;\n let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;\n while (Date.now() < deadline) {\n // Drive the same recovery the real preparing page provides: it polls the\n // status endpoint every ~1s, and each poll re-schedules deferred\n // provisioning when no fresh claim is held. Watching only the DB would never\n // recover a deferred runner that crashed after claiming its lease.\n await pollOnboardingStatus(request, tenant.tenantId!).catch(() => undefined);\n lastState = await readPreparationState(tenant.requestId!);\n if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();\n expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();\n}\n\nasync function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {\n const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {\n data: {\n email: tenant.email,\n firstName: 'Parallel',\n lastName: 'Login',\n organizationName: tenant.organizationName,\n password: ONBOARDING_PASSWORD,\n confirmPassword: ONBOARDING_PASSWORD,\n termsAccepted: true,\n marketingConsent: true,\n locale: 'en',\n },\n });\n expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);\n}\n\nasync function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {\n const context = await browser.newContext({ baseURL: BASE_URL });\n const page = await context.newPage();\n const refreshRequests: string[] = [];\n page.on('request', (browserRequest) => {\n const url = browserRequest.url();\n if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);\n });\n\n await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const tenantId = new URL(page.url()).searchParams.get('tenant');\n expect(tenantId, 'verify redirect should include tenant id').toMatch(\n /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,\n );\n return { context, page, refreshRequests, tenantId: tenantId! };\n}\n\nasync function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {\n return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {\n headers: {\n Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,\n },\n });\n}\n\nasync function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {\n expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();\n await expect(session.page).toHaveURL(new RegExp(`/login\\\\?tenant=${tenant.tenantId}`));\n await expect(session.page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await session.page.getByLabel('Email').fill(tenant.email);\n await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await session.page.getByRole('button', { name: 'Sign in' }).click();\n await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\\/backend(?:\\/.*)?$/);\n await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);\n\n const profileResult = await session.page.evaluate(async () => {\n const response = await fetch('/api/auth/profile', { credentials: 'include' });\n const body = await response.json().catch(() => null);\n return { status: response.status, body };\n });\n expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);\n const profile = profileResult.body;\n expect(profile.email).toBe(tenant.email);\n expect(profile.roles).toContain('admin');\n\n await session.page.goto('/backend');\n await expect(session.page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);\n}\n\ntest.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {\n test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({\n browser,\n request,\n }) => {\n // Two full onboardings + parallel browser logins + DB-polled preparation\n // far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).\n test.setTimeout(120_000);\n const unique = randomUUID().slice(0, 8);\n const tenants: OnboardingTenant[] = [1, 2].map((index) => ({\n email: `qa-onboarding-parallel-${unique}-${index}@example.test`,\n organizationName: `QA Onboarding Parallel ${unique} ${index}`,\n token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,\n }));\n\n await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));\n\n for (const tenant of tenants) {\n tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);\n }\n\n const browserSessions = await Promise.all(\n tenants.map(async (tenant) => {\n const session = await verifyTenantInBrowser(browser, tenant.token);\n tenant.tenantId = session.tenantId;\n return session;\n }),\n );\n\n try {\n const statusResponses = await Promise.all(\n tenants.flatMap((tenant) =>\n Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),\n ),\n );\n for (const response of statusResponses) {\n expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);\n }\n\n await Promise.all(tenants.map((tenant) => waitForPreparationComplete(request, tenant)));\n await Promise.all(\n tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),\n );\n } finally {\n await Promise.all(browserSessions.map((session) => session.context.close()));\n }\n });\n});\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
4
4
  import { createLogger } from "@open-mercato/shared/lib/logger";
5
+ import { lookupHashCandidates } from "@open-mercato/shared/lib/encryption/aes";
6
+ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
5
7
  import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from "@open-mercato/shared/lib/url";
6
8
  import { loadDictionary } from "@open-mercato/shared/lib/i18n/server";
7
9
  import { defaultLocale, locales } from "@open-mercato/shared/lib/i18n/config";
@@ -93,7 +95,13 @@ async function POST(req) {
93
95
  try {
94
96
  const container = await createRequestContainer();
95
97
  const em = container.resolve("em");
96
- const existingUser = await em.findOne(User, { email: parsed.data.email });
98
+ const existingUser = await findOneWithDecryption(em, User, {
99
+ deletedAt: null,
100
+ $or: [
101
+ { email: parsed.data.email },
102
+ { emailHash: { $in: lookupHashCandidates(parsed.data.email) } }
103
+ ]
104
+ });
97
105
  if (existingUser) {
98
106
  const message = translate("onboarding.errors.emailExists", "We already have an account with this email. Try signing in or resetting your password.");
99
107
  return NextResponse.json({
@@ -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 { 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 em.findOne(User, { email: parsed.data.email })\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": "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",
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": "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",
6
6
  "names": []
7
7
  }
@@ -23,6 +23,9 @@ __decorateClass([
23
23
  __decorateClass([
24
24
  Property({ type: "text" })
25
25
  ], OnboardingRequest.prototype, "email", 2);
26
+ __decorateClass([
27
+ Property({ name: "email_hash", type: "text", nullable: true })
28
+ ], OnboardingRequest.prototype, "emailHash", 2);
26
29
  __decorateClass([
27
30
  Property({ name: "token_hash", type: "text" })
28
31
  ], OnboardingRequest.prototype, "tokenHash", 2);
@@ -92,6 +95,7 @@ __decorateClass([
92
95
  OnboardingRequest = __decorateClass([
93
96
  Entity({ tableName: "onboarding_requests" }),
94
97
  Unique({ properties: ["email"] }),
98
+ Unique({ properties: ["emailHash"] }),
95
99
  Unique({ properties: ["tokenHash"] })
96
100
  ], OnboardingRequest);
97
101
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/data/entities.ts"],
4
- "sourcesContent": ["import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\ntype OnboardingStatus = 'pending' | 'processing' | 'completed' | 'expired'\n\n@Entity({ tableName: 'onboarding_requests' })\n@Unique({ properties: ['email'] })\n@Unique({ properties: ['tokenHash'] })\nexport class OnboardingRequest {\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ type: 'text' })\n email!: string\n\n @Property({ name: 'token_hash', type: 'text' })\n tokenHash!: string\n\n @Property({ type: 'text', default: 'pending' })\n status: OnboardingStatus = 'pending'\n\n @Property({ name: 'first_name', type: 'text' })\n firstName!: string\n\n @Property({ name: 'last_name', type: 'text' })\n lastName!: string\n\n @Property({ name: 'organization_name', type: 'text' })\n organizationName!: string\n\n @Property({ type: 'text', nullable: true })\n locale?: string | null\n\n @Property({ name: 'terms_accepted', type: 'boolean', default: false })\n termsAccepted: boolean = false\n\n @Property({ name: 'marketing_consent', type: 'boolean', default: false, nullable: true })\n marketingConsent?: boolean | null = false\n\n @Property({ name: 'password_hash', type: 'text', nullable: true })\n passwordHash?: string | null\n\n @Property({ name: 'processing_started_at', type: Date, nullable: true })\n processingStartedAt?: Date | null\n\n @Property({ name: 'expires_at', type: Date })\n expiresAt!: Date\n\n @Property({ name: 'completed_at', type: Date, nullable: true })\n completedAt?: Date | null\n\n @Property({ name: 'tenant_id', type: 'uuid', nullable: true })\n tenantId?: string | null\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'user_id', type: 'uuid', nullable: true })\n userId?: string | null\n\n @Property({ name: 'last_email_sent_at', type: Date, nullable: true })\n lastEmailSentAt?: Date | null\n\n @Property({ name: 'preparation_started_at', type: Date, nullable: true })\n preparationStartedAt?: Date | null\n\n @Property({ name: 'preparation_completed_at', type: Date, nullable: true })\n preparationCompletedAt?: Date | null\n\n @Property({ name: 'ready_email_sent_at', type: Date, nullable: true })\n readyEmailSentAt?: Date | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date(), nullable: true })\n updatedAt?: Date\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n\nexport type { OnboardingStatus }\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\ntype OnboardingStatus = 'pending' | 'processing' | 'completed' | 'expired'\n\n@Entity({ tableName: 'onboarding_requests' })\n@Unique({ properties: ['email'] })\n@Unique({ properties: ['emailHash'] })\n@Unique({ properties: ['tokenHash'] })\nexport class OnboardingRequest {\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ type: 'text' })\n email!: string\n\n @Property({ name: 'email_hash', type: 'text', nullable: true })\n emailHash?: string | null\n\n @Property({ name: 'token_hash', type: 'text' })\n tokenHash!: string\n\n @Property({ type: 'text', default: 'pending' })\n status: OnboardingStatus = 'pending'\n\n @Property({ name: 'first_name', type: 'text' })\n firstName!: string\n\n @Property({ name: 'last_name', type: 'text' })\n lastName!: string\n\n @Property({ name: 'organization_name', type: 'text' })\n organizationName!: string\n\n @Property({ type: 'text', nullable: true })\n locale?: string | null\n\n @Property({ name: 'terms_accepted', type: 'boolean', default: false })\n termsAccepted: boolean = false\n\n @Property({ name: 'marketing_consent', type: 'boolean', default: false, nullable: true })\n marketingConsent?: boolean | null = false\n\n @Property({ name: 'password_hash', type: 'text', nullable: true })\n passwordHash?: string | null\n\n @Property({ name: 'processing_started_at', type: Date, nullable: true })\n processingStartedAt?: Date | null\n\n @Property({ name: 'expires_at', type: Date })\n expiresAt!: Date\n\n @Property({ name: 'completed_at', type: Date, nullable: true })\n completedAt?: Date | null\n\n @Property({ name: 'tenant_id', type: 'uuid', nullable: true })\n tenantId?: string | null\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'user_id', type: 'uuid', nullable: true })\n userId?: string | null\n\n @Property({ name: 'last_email_sent_at', type: Date, nullable: true })\n lastEmailSentAt?: Date | null\n\n @Property({ name: 'preparation_started_at', type: Date, nullable: true })\n preparationStartedAt?: Date | null\n\n @Property({ name: 'preparation_completed_at', type: Date, nullable: true })\n preparationCompletedAt?: Date | null\n\n @Property({ name: 'ready_email_sent_at', type: Date, nullable: true })\n readyEmailSentAt?: Date | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date(), nullable: true })\n updatedAt?: Date\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n\nexport type { OnboardingStatus }\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,11 +1,13 @@
1
1
  const defaultEncryptionMaps = [
2
2
  {
3
3
  entityId: "onboarding:onboarding_request",
4
+ keyScope: "system",
4
5
  fields: [
5
- { field: "email" },
6
+ { field: "email", hashField: "email_hash" },
6
7
  { field: "first_name" },
7
8
  { field: "last_name" },
8
- { field: "organization_name" }
9
+ { field: "organization_name" },
10
+ { field: "password_hash" }
9
11
  ]
10
12
  }
11
13
  ];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/onboarding/encryption.ts"],
4
- "sourcesContent": ["import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'\n\nexport const defaultEncryptionMaps: ModuleEncryptionMap[] = [\n {\n entityId: 'onboarding:onboarding_request',\n fields: [\n { field: 'email' },\n { field: 'first_name' },\n { field: 'last_name' },\n { field: 'organization_name' },\n ],\n },\n]\n\nexport default defaultEncryptionMaps\n"],
5
- "mappings": "AAEO,MAAM,wBAA+C;AAAA,EAC1D;AAAA,IACE,UAAU;AAAA,IACV,QAAQ;AAAA,MACN,EAAE,OAAO,QAAQ;AAAA,MACjB,EAAE,OAAO,aAAa;AAAA,MACtB,EAAE,OAAO,YAAY;AAAA,MACrB,EAAE,OAAO,oBAAoB;AAAA,IAC/B;AAAA,EACF;AACF;AAEA,IAAO,qBAAQ;",
4
+ "sourcesContent": ["import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'\n\nexport const defaultEncryptionMaps: ModuleEncryptionMap[] = [\n {\n entityId: 'onboarding:onboarding_request',\n keyScope: 'system',\n fields: [\n { field: 'email', hashField: 'email_hash' },\n { field: 'first_name' },\n { field: 'last_name' },\n { field: 'organization_name' },\n { field: 'password_hash' },\n ],\n },\n]\n\nexport default defaultEncryptionMaps\n"],
5
+ "mappings": "AAEO,MAAM,wBAA+C;AAAA,EAC1D;AAAA,IACE,UAAU;AAAA,IACV,UAAU;AAAA,IACV,QAAQ;AAAA,MACN,EAAE,OAAO,SAAS,WAAW,aAAa;AAAA,MAC1C,EAAE,OAAO,aAAa;AAAA,MACtB,EAAE,OAAO,YAAY;AAAA,MACrB,EAAE,OAAO,oBAAoB;AAAA,MAC7B,EAAE,OAAO,gBAAgB;AAAA,IAC3B;AAAA,EACF;AACF;AAEA,IAAO,qBAAQ;",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,7 @@
1
1
  import { randomBytes, createHash } from "node:crypto";
2
2
  import { hash } from "bcryptjs";
3
+ import { hashForLookup, lookupHashCandidates } from "@open-mercato/shared/lib/encryption/aes";
4
+ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
5
  import { OnboardingRequest } from "../data/entities.js";
4
6
  class OnboardingService {
5
7
  constructor(em) {
@@ -12,7 +14,13 @@ class OnboardingService {
12
14
  const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1e3);
13
15
  const now = /* @__PURE__ */ new Date();
14
16
  const passwordHash = await hash(input.password, 10);
15
- const existing = await this.em.findOne(OnboardingRequest, { email: input.email });
17
+ const emailHash = hashForLookup(input.email);
18
+ const existing = await findOneWithDecryption(this.em, OnboardingRequest, {
19
+ $or: [
20
+ { emailHash: { $in: lookupHashCandidates(input.email) } },
21
+ { email: input.email, emailHash: null }
22
+ ]
23
+ });
16
24
  if (existing) {
17
25
  const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt;
18
26
  if (["pending", "processing"].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1e3) {
@@ -29,6 +37,7 @@ class OnboardingService {
29
37
  existing.termsAccepted = true;
30
38
  existing.marketingConsent = input.marketingConsent ?? false;
31
39
  existing.passwordHash = passwordHash;
40
+ existing.emailHash = emailHash;
32
41
  existing.expiresAt = expiresAt;
33
42
  existing.completedAt = null;
34
43
  existing.processingStartedAt = null;
@@ -44,6 +53,7 @@ class OnboardingService {
44
53
  }
45
54
  const request = this.em.create(OnboardingRequest, {
46
55
  email: input.email,
56
+ emailHash,
47
57
  tokenHash,
48
58
  status: "pending",
49
59
  firstName: input.firstName,
@@ -65,7 +75,7 @@ class OnboardingService {
65
75
  async findPendingByToken(token) {
66
76
  const tokenHash = hashToken(token);
67
77
  const now = /* @__PURE__ */ new Date();
68
- return this.em.findOne(OnboardingRequest, {
78
+ return findOneWithDecryption(this.em, OnboardingRequest, {
69
79
  tokenHash,
70
80
  status: "pending",
71
81
  expiresAt: { $gt: now }
@@ -73,16 +83,18 @@ class OnboardingService {
73
83
  }
74
84
  async findByToken(token) {
75
85
  const tokenHash = hashToken(token);
76
- return this.em.findOne(OnboardingRequest, { tokenHash });
86
+ return findOneWithDecryption(this.em, OnboardingRequest, { tokenHash });
77
87
  }
78
88
  async findById(id) {
79
- return this.em.findOne(OnboardingRequest, { id });
89
+ return findOneWithDecryption(this.em, OnboardingRequest, { id });
80
90
  }
81
91
  async findLatestByTenantId(tenantId) {
82
- return this.em.findOne(
92
+ return findOneWithDecryption(
93
+ this.em,
83
94
  OnboardingRequest,
84
95
  { tenantId, deletedAt: null },
85
- { orderBy: { updatedAt: "DESC", createdAt: "DESC" } }
96
+ { orderBy: { updatedAt: "DESC", createdAt: "DESC" } },
97
+ { tenantId, organizationId: null }
86
98
  );
87
99
  }
88
100
  async startProcessing(request, startedAt) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/lib/service.ts"],
4
- "sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport { hash } from 'bcryptjs'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { OnboardingRequest } from '../data/entities'\nimport type { OnboardingStartInput } from '../data/validators'\n\ntype CreateRequestOptions = {\n expiresInHours?: number\n}\n\nexport class OnboardingService {\n constructor(private readonly em: EntityManager) {}\n\n async createOrUpdateRequest(input: OnboardingStartInput, options: CreateRequestOptions = {}) {\n const expiresInHours = options.expiresInHours ?? 24\n const token = randomBytes(32).toString('hex')\n const tokenHash = hashToken(token)\n const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)\n const now = new Date()\n const passwordHash = await hash(input.password, 10)\n\n const existing = await this.em.findOne(OnboardingRequest, { email: input.email })\n if (existing) {\n const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt\n if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {\n const remainingMs = 10 * 60 * 1000 - (Date.now() - lastSentAt.getTime())\n const waitMinutes = Math.max(1, Math.ceil(remainingMs / (60 * 1000)))\n throw new Error(`PENDING_REQUEST:${waitMinutes}`)\n }\n existing.tokenHash = tokenHash\n existing.status = 'pending'\n existing.firstName = input.firstName\n existing.lastName = input.lastName\n existing.organizationName = input.organizationName\n existing.locale = input.locale ?? existing.locale ?? 'en'\n existing.termsAccepted = true\n existing.marketingConsent = input.marketingConsent ?? false\n existing.passwordHash = passwordHash\n existing.expiresAt = expiresAt\n existing.completedAt = null\n existing.processingStartedAt = null\n existing.tenantId = null\n existing.organizationId = null\n existing.userId = null\n existing.lastEmailSentAt = now\n existing.preparationStartedAt = null\n existing.preparationCompletedAt = null\n existing.readyEmailSentAt = null\n await this.em.flush()\n return { request: existing, token }\n }\n\n const request = this.em.create(OnboardingRequest, {\n email: input.email,\n tokenHash,\n status: 'pending',\n firstName: input.firstName,\n lastName: input.lastName,\n organizationName: input.organizationName,\n locale: input.locale ?? 'en',\n termsAccepted: true,\n marketingConsent: input.marketingConsent ?? false,\n passwordHash,\n expiresAt,\n processingStartedAt: null,\n lastEmailSentAt: now,\n createdAt: now,\n updatedAt: now,\n })\n await this.em.persist(request).flush()\n return { request, token }\n }\n\n async findPendingByToken(token: string) {\n const tokenHash = hashToken(token)\n const now = new Date()\n return this.em.findOne(OnboardingRequest, {\n tokenHash,\n status: 'pending',\n expiresAt: { $gt: now } as any,\n })\n }\n\n async findByToken(token: string) {\n const tokenHash = hashToken(token)\n return this.em.findOne(OnboardingRequest, { tokenHash })\n }\n\n async findById(id: string) {\n return this.em.findOne(OnboardingRequest, { id })\n }\n\n async findLatestByTenantId(tenantId: string) {\n return this.em.findOne(\n OnboardingRequest,\n { tenantId, deletedAt: null },\n { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },\n )\n }\n\n async startProcessing(request: OnboardingRequest, startedAt: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'pending' },\n { status: 'processing', processingStartedAt: startedAt, updatedAt: new Date() },\n )\n if (claimedRows === 0) return false\n request.status = 'processing'\n request.processingStartedAt = startedAt\n return true\n }\n\n async resetProcessing(request: OnboardingRequest): Promise<boolean> {\n const revertedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'processing' },\n { status: 'pending', processingStartedAt: null, updatedAt: new Date() },\n )\n if (revertedRows === 0) return false\n request.status = 'pending'\n request.processingStartedAt = null\n return true\n }\n\n async updateProvisioningIds(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n await this.em.flush()\n }\n\n async markCompleted(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.status = 'completed'\n request.completedAt = new Date()\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n request.processingStartedAt = null\n request.passwordHash = null\n await this.em.flush()\n }\n\n async markReadyEmailSent(request: OnboardingRequest, sentAt: Date) {\n request.readyEmailSentAt = sentAt\n await this.em.flush()\n }\n\n async claimPreparation(requestId: string, claimedAt: Date, staleBefore: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n {\n id: requestId,\n status: 'completed',\n preparationCompletedAt: null,\n $or: [\n { preparationStartedAt: null },\n { preparationStartedAt: { $lt: staleBefore } },\n ],\n },\n { preparationStartedAt: claimedAt, updatedAt: new Date() },\n )\n return claimedRows > 0\n }\n\n async renewPreparation(requestId: string, renewedAt: Date): Promise<boolean> {\n const renewedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n {\n id: requestId,\n status: 'completed',\n preparationCompletedAt: null,\n preparationStartedAt: { $ne: null },\n },\n { preparationStartedAt: renewedAt, updatedAt: new Date() },\n )\n return renewedRows > 0\n }\n\n async markPreparationCompleted(request: OnboardingRequest, completedAt: Date) {\n request.preparationCompletedAt = completedAt\n request.preparationStartedAt = null\n await this.em.flush()\n }\n}\n\nfunction hashToken(token: string) {\n return createHash('sha256').update(token).digest('hex')\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport { hash } from 'bcryptjs'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { OnboardingRequest } from '../data/entities'\nimport type { OnboardingStartInput } from '../data/validators'\n\ntype CreateRequestOptions = {\n expiresInHours?: number\n}\n\nexport class OnboardingService {\n constructor(private readonly em: EntityManager) {}\n\n async createOrUpdateRequest(input: OnboardingStartInput, options: CreateRequestOptions = {}) {\n const expiresInHours = options.expiresInHours ?? 24\n const token = randomBytes(32).toString('hex')\n const tokenHash = hashToken(token)\n const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)\n const now = new Date()\n const passwordHash = await hash(input.password, 10)\n const emailHash = hashForLookup(input.email)\n\n const existing = await findOneWithDecryption(this.em, OnboardingRequest, {\n $or: [\n { emailHash: { $in: lookupHashCandidates(input.email) } },\n { email: input.email, emailHash: null },\n ],\n })\n if (existing) {\n const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt\n if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {\n const remainingMs = 10 * 60 * 1000 - (Date.now() - lastSentAt.getTime())\n const waitMinutes = Math.max(1, Math.ceil(remainingMs / (60 * 1000)))\n throw new Error(`PENDING_REQUEST:${waitMinutes}`)\n }\n existing.tokenHash = tokenHash\n existing.status = 'pending'\n existing.firstName = input.firstName\n existing.lastName = input.lastName\n existing.organizationName = input.organizationName\n existing.locale = input.locale ?? existing.locale ?? 'en'\n existing.termsAccepted = true\n existing.marketingConsent = input.marketingConsent ?? false\n existing.passwordHash = passwordHash\n existing.emailHash = emailHash\n existing.expiresAt = expiresAt\n existing.completedAt = null\n existing.processingStartedAt = null\n existing.tenantId = null\n existing.organizationId = null\n existing.userId = null\n existing.lastEmailSentAt = now\n existing.preparationStartedAt = null\n existing.preparationCompletedAt = null\n existing.readyEmailSentAt = null\n await this.em.flush()\n return { request: existing, token }\n }\n\n const request = this.em.create(OnboardingRequest, {\n email: input.email,\n emailHash,\n tokenHash,\n status: 'pending',\n firstName: input.firstName,\n lastName: input.lastName,\n organizationName: input.organizationName,\n locale: input.locale ?? 'en',\n termsAccepted: true,\n marketingConsent: input.marketingConsent ?? false,\n passwordHash,\n expiresAt,\n processingStartedAt: null,\n lastEmailSentAt: now,\n createdAt: now,\n updatedAt: now,\n })\n await this.em.persist(request).flush()\n return { request, token }\n }\n\n async findPendingByToken(token: string) {\n const tokenHash = hashToken(token)\n const now = new Date()\n return findOneWithDecryption(this.em, OnboardingRequest, {\n tokenHash,\n status: 'pending',\n expiresAt: { $gt: now } as any,\n })\n }\n\n async findByToken(token: string) {\n const tokenHash = hashToken(token)\n return findOneWithDecryption(this.em, OnboardingRequest, { tokenHash })\n }\n\n async findById(id: string) {\n return findOneWithDecryption(this.em, OnboardingRequest, { id })\n }\n\n async findLatestByTenantId(tenantId: string) {\n return findOneWithDecryption(\n this.em,\n OnboardingRequest,\n { tenantId, deletedAt: null },\n { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },\n { tenantId, organizationId: null },\n )\n }\n\n async startProcessing(request: OnboardingRequest, startedAt: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'pending' },\n { status: 'processing', processingStartedAt: startedAt, updatedAt: new Date() },\n )\n if (claimedRows === 0) return false\n request.status = 'processing'\n request.processingStartedAt = startedAt\n return true\n }\n\n async resetProcessing(request: OnboardingRequest): Promise<boolean> {\n const revertedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'processing' },\n { status: 'pending', processingStartedAt: null, updatedAt: new Date() },\n )\n if (revertedRows === 0) return false\n request.status = 'pending'\n request.processingStartedAt = null\n return true\n }\n\n async updateProvisioningIds(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n await this.em.flush()\n }\n\n async markCompleted(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.status = 'completed'\n request.completedAt = new Date()\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n request.processingStartedAt = null\n request.passwordHash = null\n await this.em.flush()\n }\n\n async markReadyEmailSent(request: OnboardingRequest, sentAt: Date) {\n request.readyEmailSentAt = sentAt\n await this.em.flush()\n }\n\n async claimPreparation(requestId: string, claimedAt: Date, staleBefore: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n {\n id: requestId,\n status: 'completed',\n preparationCompletedAt: null,\n $or: [\n { preparationStartedAt: null },\n { preparationStartedAt: { $lt: staleBefore } },\n ],\n },\n { preparationStartedAt: claimedAt, updatedAt: new Date() },\n )\n return claimedRows > 0\n }\n\n async renewPreparation(requestId: string, renewedAt: Date): Promise<boolean> {\n const renewedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n {\n id: requestId,\n status: 'completed',\n preparationCompletedAt: null,\n preparationStartedAt: { $ne: null },\n },\n { preparationStartedAt: renewedAt, updatedAt: new Date() },\n )\n return renewedRows > 0\n }\n\n async markPreparationCompleted(request: OnboardingRequest, completedAt: Date) {\n request.preparationCompletedAt = completedAt\n request.preparationStartedAt = null\n await this.em.flush()\n }\n}\n\nfunction hashToken(token: string) {\n return createHash('sha256').update(token).digest('hex')\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,15 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20260710133207_onboarding extends Migration {
3
+ up() {
4
+ this.addSql(`alter table "onboarding_requests" add "email_hash" text null;`);
5
+ this.addSql(`alter table "onboarding_requests" add constraint "onboarding_requests_email_hash_unique" unique ("email_hash");`);
6
+ }
7
+ down() {
8
+ this.addSql(`alter table "onboarding_requests" drop constraint if exists "onboarding_requests_email_hash_unique";`);
9
+ this.addSql(`alter table "onboarding_requests" drop column "email_hash";`);
10
+ }
11
+ }
12
+ export {
13
+ Migration20260710133207_onboarding
14
+ };
15
+ //# sourceMappingURL=Migration20260710133207_onboarding.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260710133207_onboarding extends Migration {\n\n override up(): void | Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" add \"email_hash\" text null;`);\n this.addSql(`alter table \"onboarding_requests\" add constraint \"onboarding_requests_email_hash_unique\" unique (\"email_hash\");`);\n }\n\n override down(): void | Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" drop constraint if exists \"onboarding_requests_email_hash_unique\";`);\n this.addSql(`alter table \"onboarding_requests\" drop column \"email_hash\";`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,2CAA2C,UAAU;AAAA,EAEvD,KAA2B;AAClC,SAAK,OAAO,+DAA+D;AAC3E,SAAK,OAAO,iHAAiH;AAAA,EAC/H;AAAA,EAES,OAA6B;AACpC,SAAK,OAAO,sGAAsG;AAClH,SAAK,OAAO,6DAA6D;AAAA,EAC3E;AAEF;",
6
+ "names": []
7
+ }
@@ -1,5 +1,6 @@
1
1
  export const id = "id";
2
2
  export const email = "email";
3
+ export const email_hash = "email_hash";
3
4
  export const token_hash = "token_hash";
4
5
  export const status = "status";
5
6
  export const first_name = "first_name";
@@ -4,6 +4,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
4
4
  "onboarding_request": {
5
5
  "id": "id",
6
6
  "email": "email",
7
+ "email_hash": "email_hash",
7
8
  "token_hash": "token_hash",
8
9
  "status": "status",
9
10
  "first_name": "first_name",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/onboarding",
3
- "version": "0.6.7-develop.6851.1.c1680b00d8",
3
+ "version": "0.6.7-develop.6860.1.f796b22e80",
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.6.7-develop.6851.1.c1680b00d8"
73
+ "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.7-develop.6851.1.c1680b00d8",
76
+ "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.12",
@@ -0,0 +1,213 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
4
+ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
5
+ import type { TenantDek } from '@open-mercato/shared/lib/encryption/kms'
6
+ import { OnboardingRequest } from '../modules/onboarding/data/entities'
7
+ import { OnboardingService } from '../modules/onboarding/lib/service'
8
+ import { defaultEncryptionMaps } from '../modules/onboarding/encryption'
9
+
10
+ jest.mock('bcryptjs', () => ({
11
+ hash: jest.fn().mockResolvedValue('freshly_hashed_password'),
12
+ }))
13
+
14
+ const LEGACY_EMAIL = 'legacy.signup@example.com'
15
+ const SYSTEM_KEY = randomBytes(32).toString('base64')
16
+
17
+ function hashToken(token: string) {
18
+ return createHash('sha256').update(token).digest('hex')
19
+ }
20
+
21
+ function makeLegacyPlaintextRow(): OnboardingRequest {
22
+ return Object.assign(new OnboardingRequest(), {
23
+ id: 'legacy-row',
24
+ email: LEGACY_EMAIL,
25
+ emailHash: null,
26
+ tokenHash: hashToken('legacy-token'),
27
+ status: 'pending',
28
+ firstName: 'Ada',
29
+ lastName: 'Lovelace',
30
+ organizationName: 'Analytical Engines',
31
+ locale: 'en',
32
+ termsAccepted: true,
33
+ marketingConsent: false,
34
+ passwordHash: 'legacy_bcrypt_hash',
35
+ expiresAt: new Date(Date.now() + 86_400_000),
36
+ completedAt: null,
37
+ processingStartedAt: null,
38
+ tenantId: null,
39
+ organizationId: null,
40
+ userId: null,
41
+ lastEmailSentAt: new Date(Date.now() - 60 * 60 * 1000),
42
+ preparationStartedAt: null,
43
+ preparationCompletedAt: null,
44
+ readyEmailSentAt: null,
45
+ createdAt: new Date(Date.now() - 7 * 86_400_000),
46
+ updatedAt: new Date(Date.now() - 7 * 86_400_000),
47
+ deletedAt: null,
48
+ })
49
+ }
50
+
51
+ function matchesCondition(actual: unknown, condition: unknown): boolean {
52
+ if (condition !== null && typeof condition === 'object' && !(condition instanceof Date)) {
53
+ const operators = condition as Record<string, unknown>
54
+ if ('$in' in operators) return (operators.$in as unknown[]).includes(actual)
55
+ if ('$gt' in operators) return (actual as Date) > (operators.$gt as Date)
56
+ if ('$ne' in operators) return actual !== operators.$ne
57
+ return false
58
+ }
59
+ return actual === condition
60
+ }
61
+
62
+ function matchesFilter(row: Record<string, unknown>, filter: Record<string, unknown>): boolean {
63
+ return Object.entries(filter).every(([key, condition]) => {
64
+ if (key === '$or') return (condition as Record<string, unknown>[]).some((branch) => matchesFilter(row, branch))
65
+ return matchesCondition(row[key], condition)
66
+ })
67
+ }
68
+
69
+ /**
70
+ * Minimal filter-evaluating EntityManager stand-in. The point of this suite is that a
71
+ * backfilled row is still *found* by the service's real lookup, so the fake has to
72
+ * actually evaluate the `$or` the service builds rather than record the call.
73
+ */
74
+ function makeEm(rows: OnboardingRequest[]) {
75
+ const created: OnboardingRequest[] = []
76
+ return {
77
+ findOne: jest.fn(async (_entity: unknown, where: Record<string, unknown>) =>
78
+ rows.find((row) => matchesFilter(row as unknown as Record<string, unknown>, where)) ?? null),
79
+ create: jest.fn((_entity: unknown, data: Record<string, unknown>) => {
80
+ const row = Object.assign(new OnboardingRequest(), data)
81
+ created.push(row)
82
+ return row
83
+ }),
84
+ persist: jest.fn(() => ({ flush: jest.fn(async () => {}) })),
85
+ flush: jest.fn(async () => {}),
86
+ created,
87
+ } as unknown as EntityManager & { created: OnboardingRequest[]; findOne: jest.Mock; create: jest.Mock }
88
+ }
89
+
90
+ function makeEncryptionService(em: EntityManager) {
91
+ const kms = {
92
+ isHealthy: () => true,
93
+ async getTenantDek(tenantId: string): Promise<TenantDek | null> {
94
+ return { tenantId, key: SYSTEM_KEY, fetchedAt: Date.now() }
95
+ },
96
+ async createTenantDek(tenantId: string): Promise<TenantDek | null> {
97
+ return this.getTenantDek(tenantId)
98
+ },
99
+ }
100
+ return new TenantDataEncryptionService(em, { kms, defaultEncryptionMaps })
101
+ }
102
+
103
+ /**
104
+ * Applies exactly what `mercato entities backfill-system-encryption` applies to one row:
105
+ * the mapped fields go through `encryptEntityPayload` under the system key, and the
106
+ * resulting ciphertext plus lookup hash are written back to the record.
107
+ */
108
+ async function backfillRow(service: TenantDataEncryptionService, row: OnboardingRequest): Promise<void> {
109
+ const encrypted = await service.encryptEntityPayload('onboarding:onboarding_request', {
110
+ email: row.email,
111
+ email_hash: row.emailHash ?? null,
112
+ first_name: row.firstName,
113
+ last_name: row.lastName,
114
+ organization_name: row.organizationName,
115
+ password_hash: row.passwordHash ?? null,
116
+ }, null, null)
117
+ row.email = encrypted.email as string
118
+ row.emailHash = encrypted.email_hash as string
119
+ row.firstName = encrypted.first_name as string
120
+ row.lastName = encrypted.last_name as string
121
+ row.organizationName = encrypted.organization_name as string
122
+ row.passwordHash = encrypted.password_hash as string
123
+ }
124
+
125
+ describe('backfilled legacy onboarding request (#3876)', () => {
126
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
127
+
128
+ beforeEach(() => {
129
+ process.env.TENANT_DATA_ENCRYPTION = 'yes'
130
+ })
131
+
132
+ afterEach(() => {
133
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
134
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
135
+ })
136
+
137
+ it('encrypts every mapped field and populates the lookup hash', async () => {
138
+ const row = makeLegacyPlaintextRow()
139
+ const em = makeEm([row])
140
+
141
+ await backfillRow(makeEncryptionService(em), row)
142
+
143
+ for (const value of [row.email, row.firstName, row.lastName, row.organizationName, row.passwordHash]) {
144
+ expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/)
145
+ }
146
+ expect(lookupHashCandidates(LEGACY_EMAIL)).toContain(row.emailHash)
147
+ })
148
+
149
+ it('still resolves the row on resubmit instead of creating a duplicate', async () => {
150
+ const row = makeLegacyPlaintextRow()
151
+ const em = makeEm([row])
152
+ await backfillRow(makeEncryptionService(em), row)
153
+
154
+ const service = new OnboardingService(em)
155
+ const { request } = await service.createOrUpdateRequest({
156
+ email: LEGACY_EMAIL,
157
+ firstName: 'Ada',
158
+ lastName: 'Lovelace',
159
+ organizationName: 'Analytical Engines',
160
+ password: 'Secret1!',
161
+ confirmPassword: 'Secret1!',
162
+ termsAccepted: true,
163
+ marketingConsent: false,
164
+ } as never)
165
+
166
+ expect(request.id).toBe('legacy-row')
167
+ expect(em.created).toHaveLength(0)
168
+ expect(em.create).not.toHaveBeenCalled()
169
+ })
170
+
171
+ it('still resolves the row by verification token after the backfill', async () => {
172
+ const row = makeLegacyPlaintextRow()
173
+ const em = makeEm([row])
174
+ await backfillRow(makeEncryptionService(em), row)
175
+
176
+ const found = await new OnboardingService(em).findPendingByToken('legacy-token')
177
+
178
+ expect(found?.id).toBe('legacy-row')
179
+ })
180
+
181
+ it('leaves a row the backfill already processed unchanged on a second pass', async () => {
182
+ const row = makeLegacyPlaintextRow()
183
+ const em = makeEm([row])
184
+ const service = makeEncryptionService(em)
185
+
186
+ await backfillRow(service, row)
187
+ const afterFirstPass = { email: row.email, emailHash: row.emailHash, passwordHash: row.passwordHash }
188
+ await backfillRow(service, row)
189
+
190
+ expect(row.email).toBe(afterFirstPass.email)
191
+ expect(row.emailHash).toBe(afterFirstPass.emailHash)
192
+ expect(row.passwordHash).toBe(afterFirstPass.passwordHash)
193
+ })
194
+
195
+ it('still finds a row that was never backfilled, through the legacy plaintext branch', async () => {
196
+ const row = makeLegacyPlaintextRow()
197
+ const em = makeEm([row])
198
+
199
+ const found = await new OnboardingService(em).createOrUpdateRequest({
200
+ email: LEGACY_EMAIL,
201
+ firstName: 'Ada',
202
+ lastName: 'Lovelace',
203
+ organizationName: 'Analytical Engines',
204
+ password: 'Secret1!',
205
+ confirmPassword: 'Secret1!',
206
+ termsAccepted: true,
207
+ marketingConsent: false,
208
+ } as never)
209
+
210
+ expect(found.request.id).toBe('legacy-row')
211
+ expect(em.create).not.toHaveBeenCalled()
212
+ })
213
+ })
@@ -0,0 +1,19 @@
1
+ import { defaultEncryptionMaps } from '../modules/onboarding/encryption'
2
+
3
+ describe('onboarding encryption map', () => {
4
+ it('protects pre-tenant PII and the transient password hash with a system key', () => {
5
+ expect(defaultEncryptionMaps).toEqual([
6
+ {
7
+ entityId: 'onboarding:onboarding_request',
8
+ keyScope: 'system',
9
+ fields: [
10
+ { field: 'email', hashField: 'email_hash' },
11
+ { field: 'first_name' },
12
+ { field: 'last_name' },
13
+ { field: 'organization_name' },
14
+ { field: 'password_hash' },
15
+ ],
16
+ },
17
+ ])
18
+ })
19
+ })
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { OnboardingRequest } from '../modules/onboarding/data/entities'
4
4
  import { OnboardingService } from '../modules/onboarding/lib/service'
5
+ import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
5
6
 
6
7
  jest.mock('bcryptjs', () => ({
7
8
  hash: jest.fn().mockResolvedValue('hashed_password'),
@@ -127,6 +128,21 @@ describe('OnboardingService', () => {
127
128
  const result = await service.createOrUpdateRequest(makeStartInput())
128
129
  const createArgs = em.create.mock.calls[0][1]
129
130
  expect(createArgs.status).toBe('pending')
131
+ expect(createArgs.emailHash).toBe(hashForLookup('user@example.com'))
132
+ })
133
+
134
+ it('looks up encrypted and legacy plaintext emails without querying ciphertext as plaintext', async () => {
135
+ const em = createMockEm()
136
+ const service = new OnboardingService(em)
137
+
138
+ await service.createOrUpdateRequest(makeStartInput())
139
+
140
+ expect(em.findOne).toHaveBeenCalledWith(OnboardingRequest, {
141
+ $or: [
142
+ { emailHash: { $in: lookupHashCandidates('user@example.com') } },
143
+ { email: 'user@example.com', emailHash: null },
144
+ ],
145
+ }, undefined)
130
146
  })
131
147
 
132
148
  it('throws PENDING_REQUEST when within cooldown', async () => {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { expect, test } from '@playwright/test';
3
3
  import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
4
+ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';
4
5
 
5
6
  export const integrationMeta = {
6
7
  dependsOnModules: ['onboarding'],
@@ -26,6 +27,15 @@ type UserConsentRow = {
26
27
  granted_at: Date | null;
27
28
  };
28
29
 
30
+ type PendingRequestAtRest = {
31
+ email: string;
32
+ email_hash: string | null;
33
+ first_name: string;
34
+ last_name: string;
35
+ organization_name: string;
36
+ password_hash: string;
37
+ };
38
+
29
39
  const ONBOARDING_PASSWORD = 'IntegrationPass123!';
30
40
  const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
31
41
 
@@ -40,15 +50,32 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
40
50
  set token_hash = $2,
41
51
  expires_at = now() + interval '24 hours',
42
52
  updated_at = now()
43
- where email = $1
53
+ where email_hash = any($1::text[])
44
54
  returning id`,
45
- [email, hashToken(token)],
55
+ [lookupHashCandidates(email), hashToken(token)],
46
56
  );
47
57
  expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);
48
58
  return result.rows[0].id;
49
59
  });
50
60
  }
51
61
 
62
+ async function readPendingRequestAtRest(email: string): Promise<PendingRequestAtRest> {
63
+ return withClient(async (client) => {
64
+ const result = await client.query<PendingRequestAtRest>(
65
+ `select email, email_hash, first_name, last_name, organization_name, password_hash
66
+ from onboarding_requests
67
+ where email_hash = any($1::text[])`,
68
+ [lookupHashCandidates(email)],
69
+ );
70
+ expect(result.rowCount, 'onboarding request should be queryable by its lookup hash').toBe(1);
71
+ return result.rows[0];
72
+ });
73
+ }
74
+
75
+ function expectEncryptedPayload(value: string): void {
76
+ expect(value).toMatch(/^[^:]+:[^:]+:[^:]+:v1$/);
77
+ }
78
+
52
79
  async function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {
53
80
  return withClient(async (client) => {
54
81
  const result = await client.query<OnboardingRequestRow>(
@@ -142,6 +169,14 @@ test.describe('TC-ONB-001: self-service onboarding with marketing consent', () =
142
169
  await expect(page.getByRole('status')).toContainText('Check your inbox');
143
170
  await expect(page.getByRole('status')).toContainText(email);
144
171
 
172
+ const pendingAtRest = await readPendingRequestAtRest(email);
173
+ expect(lookupHashCandidates(email)).toContain(pendingAtRest.email_hash);
174
+ expectEncryptedPayload(pendingAtRest.email);
175
+ expectEncryptedPayload(pendingAtRest.first_name);
176
+ expectEncryptedPayload(pendingAtRest.last_name);
177
+ expectEncryptedPayload(pendingAtRest.organization_name);
178
+ expectEncryptedPayload(pendingAtRest.password_hash);
179
+
145
180
  const requestId = await replaceVerificationToken(email, token);
146
181
  const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;
147
182
 
@@ -4,6 +4,7 @@
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';
6
6
  import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
7
+ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes';
7
8
 
8
9
  export const integrationMeta = {
9
10
  dependsOnModules: ['onboarding'],
@@ -39,9 +40,9 @@ async function replaceVerificationToken(email: string, token: string): Promise<s
39
40
  set token_hash = $2,
40
41
  expires_at = now() + interval '24 hours',
41
42
  updated_at = now()
42
- where email = $1
43
+ where email_hash = any($1::text[])
43
44
  returning id`,
44
- [email, hashToken(token)],
45
+ [lookupHashCandidates(email), hashToken(token)],
45
46
  );
46
47
  expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
47
48
  return result.rows[0].id;
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
6
+ import { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
7
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
8
  import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
7
9
  import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
8
10
  import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
@@ -107,7 +109,13 @@ export async function POST(req: Request) {
107
109
  const container = await createRequestContainer()
108
110
  const em = (container.resolve('em') as EntityManager)
109
111
 
110
- const existingUser = await em.findOne(User, { email: parsed.data.email })
112
+ const existingUser = await findOneWithDecryption(em, User, {
113
+ deletedAt: null,
114
+ $or: [
115
+ { email: parsed.data.email },
116
+ { emailHash: { $in: lookupHashCandidates(parsed.data.email) } },
117
+ ],
118
+ })
111
119
  if (existingUser) {
112
120
  const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')
113
121
  return NextResponse.json({
@@ -4,6 +4,7 @@ type OnboardingStatus = 'pending' | 'processing' | 'completed' | 'expired'
4
4
 
5
5
  @Entity({ tableName: 'onboarding_requests' })
6
6
  @Unique({ properties: ['email'] })
7
+ @Unique({ properties: ['emailHash'] })
7
8
  @Unique({ properties: ['tokenHash'] })
8
9
  export class OnboardingRequest {
9
10
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
@@ -12,6 +13,9 @@ export class OnboardingRequest {
12
13
  @Property({ type: 'text' })
13
14
  email!: string
14
15
 
16
+ @Property({ name: 'email_hash', type: 'text', nullable: true })
17
+ emailHash?: string | null
18
+
15
19
  @Property({ name: 'token_hash', type: 'text' })
16
20
  tokenHash!: string
17
21
 
@@ -3,11 +3,13 @@ import type { ModuleEncryptionMap } from '@open-mercato/shared/modules/encryptio
3
3
  export const defaultEncryptionMaps: ModuleEncryptionMap[] = [
4
4
  {
5
5
  entityId: 'onboarding:onboarding_request',
6
+ keyScope: 'system',
6
7
  fields: [
7
- { field: 'email' },
8
+ { field: 'email', hashField: 'email_hash' },
8
9
  { field: 'first_name' },
9
10
  { field: 'last_name' },
10
11
  { field: 'organization_name' },
12
+ { field: 'password_hash' },
11
13
  ],
12
14
  },
13
15
  ]
@@ -1,6 +1,8 @@
1
1
  import { randomBytes, createHash } from 'node:crypto'
2
2
  import { hash } from 'bcryptjs'
3
3
  import { EntityManager } from '@mikro-orm/postgresql'
4
+ import { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'
5
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
6
  import { OnboardingRequest } from '../data/entities'
5
7
  import type { OnboardingStartInput } from '../data/validators'
6
8
 
@@ -18,8 +20,14 @@ export class OnboardingService {
18
20
  const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)
19
21
  const now = new Date()
20
22
  const passwordHash = await hash(input.password, 10)
23
+ const emailHash = hashForLookup(input.email)
21
24
 
22
- const existing = await this.em.findOne(OnboardingRequest, { email: input.email })
25
+ const existing = await findOneWithDecryption(this.em, OnboardingRequest, {
26
+ $or: [
27
+ { emailHash: { $in: lookupHashCandidates(input.email) } },
28
+ { email: input.email, emailHash: null },
29
+ ],
30
+ })
23
31
  if (existing) {
24
32
  const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt
25
33
  if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {
@@ -36,6 +44,7 @@ export class OnboardingService {
36
44
  existing.termsAccepted = true
37
45
  existing.marketingConsent = input.marketingConsent ?? false
38
46
  existing.passwordHash = passwordHash
47
+ existing.emailHash = emailHash
39
48
  existing.expiresAt = expiresAt
40
49
  existing.completedAt = null
41
50
  existing.processingStartedAt = null
@@ -52,6 +61,7 @@ export class OnboardingService {
52
61
 
53
62
  const request = this.em.create(OnboardingRequest, {
54
63
  email: input.email,
64
+ emailHash,
55
65
  tokenHash,
56
66
  status: 'pending',
57
67
  firstName: input.firstName,
@@ -74,7 +84,7 @@ export class OnboardingService {
74
84
  async findPendingByToken(token: string) {
75
85
  const tokenHash = hashToken(token)
76
86
  const now = new Date()
77
- return this.em.findOne(OnboardingRequest, {
87
+ return findOneWithDecryption(this.em, OnboardingRequest, {
78
88
  tokenHash,
79
89
  status: 'pending',
80
90
  expiresAt: { $gt: now } as any,
@@ -83,18 +93,20 @@ export class OnboardingService {
83
93
 
84
94
  async findByToken(token: string) {
85
95
  const tokenHash = hashToken(token)
86
- return this.em.findOne(OnboardingRequest, { tokenHash })
96
+ return findOneWithDecryption(this.em, OnboardingRequest, { tokenHash })
87
97
  }
88
98
 
89
99
  async findById(id: string) {
90
- return this.em.findOne(OnboardingRequest, { id })
100
+ return findOneWithDecryption(this.em, OnboardingRequest, { id })
91
101
  }
92
102
 
93
103
  async findLatestByTenantId(tenantId: string) {
94
- return this.em.findOne(
104
+ return findOneWithDecryption(
105
+ this.em,
95
106
  OnboardingRequest,
96
107
  { tenantId, deletedAt: null },
97
108
  { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },
109
+ { tenantId, organizationId: null },
98
110
  )
99
111
  }
100
112
 
@@ -25,6 +25,15 @@
25
25
  "nullable": false,
26
26
  "mappedType": "text"
27
27
  },
28
+ "email_hash": {
29
+ "name": "email_hash",
30
+ "type": "text",
31
+ "unsigned": false,
32
+ "autoincrement": false,
33
+ "primary": false,
34
+ "nullable": true,
35
+ "mappedType": "text"
36
+ },
28
37
  "token_hash": {
29
38
  "name": "token_hash",
30
39
  "type": "text",
@@ -260,6 +269,16 @@
260
269
  "primary": false,
261
270
  "unique": true
262
271
  },
272
+ {
273
+ "keyName": "onboarding_requests_email_hash_unique",
274
+ "columnNames": [
275
+ "email_hash"
276
+ ],
277
+ "composite": false,
278
+ "constraint": true,
279
+ "primary": false,
280
+ "unique": true
281
+ },
263
282
  {
264
283
  "keyName": "onboarding_requests_pkey",
265
284
  "columnNames": [
@@ -0,0 +1,15 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260710133207_onboarding extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`alter table "onboarding_requests" add "email_hash" text null;`);
7
+ this.addSql(`alter table "onboarding_requests" add constraint "onboarding_requests_email_hash_unique" unique ("email_hash");`);
8
+ }
9
+
10
+ override down(): void | Promise<void> {
11
+ this.addSql(`alter table "onboarding_requests" drop constraint if exists "onboarding_requests_email_hash_unique";`);
12
+ this.addSql(`alter table "onboarding_requests" drop column "email_hash";`);
13
+ }
14
+
15
+ }