@open-mercato/onboarding 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +8 -5
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +25 -2
  4. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +2 -2
  5. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +3 -2
  6. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +2 -2
  7. package/dist/modules/onboarding/api/post/onboarding.js +9 -1
  8. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  9. package/dist/modules/onboarding/data/entities.js +4 -0
  10. package/dist/modules/onboarding/data/entities.js.map +2 -2
  11. package/dist/modules/onboarding/encryption.js +4 -2
  12. package/dist/modules/onboarding/encryption.js.map +2 -2
  13. package/dist/modules/onboarding/lib/service.js +18 -6
  14. package/dist/modules/onboarding/lib/service.js.map +2 -2
  15. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js +15 -0
  16. package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js.map +7 -0
  17. package/generated/entities/onboarding_request/index.ts +1 -0
  18. package/generated/entity-fields-registry.ts +1 -0
  19. package/package.json +6 -5
  20. package/src/__tests__/backfill-legacy-request.test.ts +213 -0
  21. package/src/__tests__/encryption.test.ts +19 -0
  22. package/src/__tests__/service.test.ts +16 -0
  23. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +37 -2
  24. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +3 -2
  25. package/src/modules/onboarding/api/post/onboarding.ts +9 -1
  26. package/src/modules/onboarding/data/entities.ts +4 -0
  27. package/src/modules/onboarding/encryption.ts +3 -1
  28. package/src/modules/onboarding/i18n/ko.json +100 -0
  29. package/src/modules/onboarding/lib/service.ts +17 -5
  30. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +19 -0
  31. 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
package/AGENTS.md CHANGED
@@ -33,16 +33,17 @@ yarn workspace @open-mercato/onboarding build
33
33
  - Steps are ordered — a step MUST NOT be accessible until its predecessor is complete
34
34
  - Step validation logic lives in `lib/` — keep API handlers thin
35
35
  - Frontend components in `frontend/` render each step's UI
36
+ - Existing `api/get/**` and `api/post/**` files are legacy compatibility routes. Treat `api/<step>/get|post/route.ts` as legacy too; never copy either HTTP-method-directory layout into new work.
36
37
 
37
38
  ## Adding a New Onboarding Step
38
39
 
39
40
  1. Create step logic in `lib/<step-name>.ts` with validation and save functions
40
- 2. Add GET endpoint in `api/<step-name>/get/route.ts` to fetch current state
41
- 3. Add POST endpoint in `api/<step-name>/post/route.ts` to save and advance
42
- 4. Create frontend component in `frontend/<step-name>/page.tsx`
41
+ 2. Create `api/<step-name>/route.ts` and export sibling `GET` and `POST` handlers from that file
42
+ 3. Declare per-method route metadata beside those handlers; use the exact public/authenticated access contract required by the step
43
+ 4. Create the frontend page under `frontend/<step-name>/page.tsx`
43
44
  5. Add translations to `i18n/<locale>.json`
44
45
  6. Register the step in the wizard step ordering configuration
45
- 7. Run `yarn generate`
46
+ 7. Run `yarn generate` and verify both methods resolve at the intended route
46
47
 
47
48
  ## Module Setup Integration
48
49
 
@@ -60,7 +61,9 @@ See `packages/core/AGENTS.md` → Module Setup for the full `setup.ts` contract.
60
61
 
61
62
  ```
62
63
  packages/onboarding/src/modules/onboarding/
63
- ├── api/ # Wizard step endpoints (GET/POST per step)
64
+ ├── api/ # New work: <step>/route.ts with sibling GET/POST handlers
65
+ │ ├── get/ # Legacy compatibility routes; do not copy
66
+ │ └── post/ # Legacy compatibility routes; do not copy
64
67
  ├── data/ # ORM entities for onboarding state
65
68
  ├── emails/ # Email templates (welcome, invitations)
66
69
  ├── frontend/ # Wizard UI components
@@ -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
  }