@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +25 -2
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +2 -2
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +3 -2
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +9 -1
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/data/entities.js +4 -0
- package/dist/modules/onboarding/data/entities.js.map +2 -2
- package/dist/modules/onboarding/encryption.js +4 -2
- package/dist/modules/onboarding/encryption.js.map +2 -2
- package/dist/modules/onboarding/lib/service.js +18 -6
- package/dist/modules/onboarding/lib/service.js.map +2 -2
- package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js +15 -0
- package/dist/modules/onboarding/migrations/Migration20260710133207_onboarding.js.map +7 -0
- package/generated/entities/onboarding_request/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +3 -3
- package/src/__tests__/backfill-legacy-request.test.ts +213 -0
- package/src/__tests__/encryption.test.ts +19 -0
- package/src/__tests__/service.test.ts +16 -0
- package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +37 -2
- package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +3 -2
- package/src/modules/onboarding/api/post/onboarding.ts +9 -1
- package/src/modules/onboarding/data/entities.ts +4 -0
- package/src/modules/onboarding/encryption.ts +3 -1
- package/src/modules/onboarding/lib/service.ts +17 -5
- package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +19 -0
- package/src/modules/onboarding/migrations/Migration20260710133207_onboarding.ts +15 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:onboarding] found
|
|
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
|
|
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
|
|
5
|
-
"mappings": "AAAA,SAAS,YAAY,kBAAkB;AACvC,SAAS,QAAQ,YAAY;AAC7B,SAAS,kBAAkB;
|
|
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
|
}
|
package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map
CHANGED
|
@@ -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
|
|
5
|
-
"mappings": "AAGA,SAAS,YAAY,kBAAkB;AACvC,SAAS,QAAQ,YAAkF;AACnG,SAAS,kBAAkB;
|
|
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
|
|
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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB,gCAAgC;AAClE,SAAS,sBAAsB;AAC/B,SAAS,eAAe,eAA4B;AACpD,SAAS,gCAAgC;AACzC,SAAS,iBAAiB;AAC1B,SAAS,6BAA6B;AACtC,SAAS,yBAAyB;AAClC,OAAO,uBAAuB;AAC9B,OAAO,4BAA4B;AACnC,SAAS,YAAY;AAErB,SAAS,4BAA4B,yBAAyB;AAC9D,SAAS,yBAAyB;AAClC,SAAS,mCAAmC;AAC5C,SAAS,4BAA4B;AAErC,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAE/D,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA,IACJ,aAAa;AAAA,IACb,WAAW,4BAA4B,cAAc;AAAA,MACnD,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,eAAe;AAAA,MACf,WAAW;AAAA,IACb,CAAC;AAAA,EACH;AACF;AAEA,eAAsB,KAAK,KAAc;AACvC,MAAI,kBAAkB,QAAQ,IAAI,mCAAmC,EAAE,MAAM,MAAM;AACjF,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,uCAAuC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxG;AACA,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,IAAI,KAAK;AAAA,EAC3B,QAAQ;AACN,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnF;AAEA,QAAM,YACJ,WAAW,OAAO,YAAY,YAAY,YAAY,WAAW,OAAQ,QAAgB,WAAW,WAC/F,QAAgB,SACjB;AACN,QAAM,SAAiB,aAAa,QAAQ,SAAS,SAAmB,IACnE,YACD;AACJ,QAAM,OAAO,MAAM,eAAe,MAAM;AACxC,QAAM,YAAY,yBAAyB,IAAI;AAC/C,QAAM,uBAAuB;AAAA,IAC3B,kBAAkB;AAAA,IAClB;AAAA,IACA;AAAA,EACF;AAEA,QAAM,SAAS,sBAAsB,UAAU,OAAO;AACtD,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,cAAsC,CAAC;AAC7C,eAAW,SAAS,OAAO,MAAM,QAAQ;AACvC,YAAM,OAAO,MAAM,KAAK,CAAC;AACzB,UAAI,CAAC,KAAM;AACX,cAAQ,MAAM;AAAA,QACZ,KAAK;AACH,sBAAY,QAAQ,UAAU,kCAAkC,2BAA2B;AAC3F;AAAA,QACF,KAAK;AACH,sBAAY,YAAY,UAAU,uCAAuC,yBAAyB;AAClG;AAAA,QACF,KAAK;AACH,sBAAY,WAAW,UAAU,sCAAsC,wBAAwB;AAC/F;AAAA,QACF,KAAK;AACH,sBAAY,mBAAmB,UAAU,8CAA8C,gCAAgC;AACvH;AAAA,QACF,KAAK;AACH,sBAAY,WAAW;AAAA,YACrB;AAAA,YACA;AAAA,YACA,EAAE,cAAc,qBAAqB;AAAA,UACvC;AACA;AAAA,QACF,KAAK;AACH,sBAAY,kBAAkB,UAAU,sCAAsC,uBAAuB;AACrG;AAAA,QACF,KAAK;AACH,sBAAY,gBAAgB,UAAU,iCAAiC,sCAAsC;AAC7G;AAAA,QACF;AACE;AAAA,MACJ;AAAA,IACF;AACA,WAAO,aAAa,KAAK;AAAA,MACvB,IAAI;AAAA,MACJ,OAAO,UAAU,gCAAgC,sCAAsC;AAAA,MACvF;AAAA,IACF,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpB;AAEA,MAAI;AACF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAM,UAAU,QAAQ,IAAI;AAElC,UAAM,eAAe,MAAM,
|
|
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": ";;;;;;;;;;AAAA,SAAS,QAAQ,YAAY,UAAU,cAAc;
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
86
|
+
return findOneWithDecryption(this.em, OnboardingRequest, { tokenHash });
|
|
77
87
|
}
|
|
78
88
|
async findById(id) {
|
|
79
|
-
return this.em
|
|
89
|
+
return findOneWithDecryption(this.em, OnboardingRequest, { id });
|
|
80
90
|
}
|
|
81
91
|
async findLatestByTenantId(tenantId) {
|
|
82
|
-
return
|
|
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
|
|
5
|
-
"mappings": "AAAA,SAAS,aAAa,kBAAkB;AACxC,SAAS,YAAY;AAErB,SAAS,yBAAyB;AAO3B,MAAM,kBAAkB;AAAA,EAC7B,YAA6B,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAEjD,MAAM,sBAAsB,OAA6B,UAAgC,CAAC,GAAG;AAC3F,UAAM,iBAAiB,QAAQ,kBAAkB;AACjD,UAAM,QAAQ,YAAY,EAAE,EAAE,SAAS,KAAK;AAC5C,UAAM,YAAY,UAAU,KAAK;AACjC,UAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,iBAAiB,KAAK,KAAK,GAAI;AACvE,UAAM,MAAM,oBAAI,KAAK;AACrB,UAAM,eAAe,MAAM,KAAK,MAAM,UAAU,EAAE;
|
|
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": "AAAA,SAAS,aAAa,kBAAkB;AACxC,SAAS,YAAY;AAErB,SAAS,eAAe,4BAA4B;AACpD,SAAS,6BAA6B;AACtC,SAAS,yBAAyB;AAO3B,MAAM,kBAAkB;AAAA,EAC7B,YAA6B,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAEjD,MAAM,sBAAsB,OAA6B,UAAgC,CAAC,GAAG;AAC3F,UAAM,iBAAiB,QAAQ,kBAAkB;AACjD,UAAM,QAAQ,YAAY,EAAE,EAAE,SAAS,KAAK;AAC5C,UAAM,YAAY,UAAU,KAAK;AACjC,UAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,iBAAiB,KAAK,KAAK,GAAI;AACvE,UAAM,MAAM,oBAAI,KAAK;AACrB,UAAM,eAAe,MAAM,KAAK,MAAM,UAAU,EAAE;AAClD,UAAM,YAAY,cAAc,MAAM,KAAK;AAE3C,UAAM,WAAW,MAAM,sBAAsB,KAAK,IAAI,mBAAmB;AAAA,MACvE,KAAK;AAAA,QACH,EAAE,WAAW,EAAE,KAAK,qBAAqB,MAAM,KAAK,EAAE,EAAE;AAAA,QACxD,EAAE,OAAO,MAAM,OAAO,WAAW,KAAK;AAAA,MACxC;AAAA,IACF,CAAC;AACD,QAAI,UAAU;AACZ,YAAM,aAAa,SAAS,mBAAmB,SAAS,aAAa,SAAS;AAC9E,UAAI,CAAC,WAAW,YAAY,EAAE,SAAS,SAAS,MAAM,KAAK,cAAc,WAAW,QAAQ,IAAI,KAAK,IAAI,IAAI,KAAK,KAAK,KAAM;AAC3H,cAAM,cAAc,KAAK,KAAK,OAAQ,KAAK,IAAI,IAAI,WAAW,QAAQ;AACtE,cAAM,cAAc,KAAK,IAAI,GAAG,KAAK,KAAK,eAAe,KAAK,IAAK,CAAC;AACpE,cAAM,IAAI,MAAM,mBAAmB,WAAW,EAAE;AAAA,MAClD;AACA,eAAS,YAAY;AACrB,eAAS,SAAS;AAClB,eAAS,YAAY,MAAM;AAC3B,eAAS,WAAW,MAAM;AAC1B,eAAS,mBAAmB,MAAM;AAClC,eAAS,SAAS,MAAM,UAAU,SAAS,UAAU;AACrD,eAAS,gBAAgB;AACzB,eAAS,mBAAmB,MAAM,oBAAoB;AACtD,eAAS,eAAe;AACxB,eAAS,YAAY;AACrB,eAAS,YAAY;AACrB,eAAS,cAAc;AACvB,eAAS,sBAAsB;AAC/B,eAAS,WAAW;AACpB,eAAS,iBAAiB;AAC1B,eAAS,SAAS;AAClB,eAAS,kBAAkB;AAC3B,eAAS,uBAAuB;AAChC,eAAS,yBAAyB;AAClC,eAAS,mBAAmB;AAC5B,YAAM,KAAK,GAAG,MAAM;AACpB,aAAO,EAAE,SAAS,UAAU,MAAM;AAAA,IACpC;AAEA,UAAM,UAAU,KAAK,GAAG,OAAO,mBAAmB;AAAA,MAChD,OAAO,MAAM;AAAA,MACb;AAAA,MACA;AAAA,MACA,QAAQ;AAAA,MACR,WAAW,MAAM;AAAA,MACjB,UAAU,MAAM;AAAA,MAChB,kBAAkB,MAAM;AAAA,MACxB,QAAQ,MAAM,UAAU;AAAA,MACxB,eAAe;AAAA,MACf,kBAAkB,MAAM,oBAAoB;AAAA,MAC5C;AAAA,MACA;AAAA,MACA,qBAAqB;AAAA,MACrB,iBAAiB;AAAA,MACjB,WAAW;AAAA,MACX,WAAW;AAAA,IACb,CAAC;AACD,UAAM,KAAK,GAAG,QAAQ,OAAO,EAAE,MAAM;AACrC,WAAO,EAAE,SAAS,MAAM;AAAA,EAC1B;AAAA,EAEA,MAAM,mBAAmB,OAAe;AACtC,UAAM,YAAY,UAAU,KAAK;AACjC,UAAM,MAAM,oBAAI,KAAK;AACrB,WAAO,sBAAsB,KAAK,IAAI,mBAAmB;AAAA,MACvD;AAAA,MACA,QAAQ;AAAA,MACR,WAAW,EAAE,KAAK,IAAI;AAAA,IACxB,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,YAAY,OAAe;AAC/B,UAAM,YAAY,UAAU,KAAK;AACjC,WAAO,sBAAsB,KAAK,IAAI,mBAAmB,EAAE,UAAU,CAAC;AAAA,EACxE;AAAA,EAEA,MAAM,SAAS,IAAY;AACzB,WAAO,sBAAsB,KAAK,IAAI,mBAAmB,EAAE,GAAG,CAAC;AAAA,EACjE;AAAA,EAEA,MAAM,qBAAqB,UAAkB;AAC3C,WAAO;AAAA,MACL,KAAK;AAAA,MACL;AAAA,MACA,EAAE,UAAU,WAAW,KAAK;AAAA,MAC5B,EAAE,SAAS,EAAE,WAAW,QAAQ,WAAW,OAAO,EAAE;AAAA,MACpD,EAAE,UAAU,gBAAgB,KAAK;AAAA,IACnC;AAAA,EACF;AAAA,EAEA,MAAM,gBAAgB,SAA4B,WAAmC;AACnF,UAAM,cAAc,MAAM,KAAK,GAAG;AAAA,MAChC;AAAA,MACA,EAAE,IAAI,QAAQ,IAAI,QAAQ,UAAU;AAAA,MACpC,EAAE,QAAQ,cAAc,qBAAqB,WAAW,WAAW,oBAAI,KAAK,EAAE;AAAA,IAChF;AACA,QAAI,gBAAgB,EAAG,QAAO;AAC9B,YAAQ,SAAS;AACjB,YAAQ,sBAAsB;AAC9B,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,gBAAgB,SAA8C;AAClE,UAAM,eAAe,MAAM,KAAK,GAAG;AAAA,MACjC;AAAA,MACA,EAAE,IAAI,QAAQ,IAAI,QAAQ,aAAa;AAAA,MACvC,EAAE,QAAQ,WAAW,qBAAqB,MAAM,WAAW,oBAAI,KAAK,EAAE;AAAA,IACxE;AACA,QAAI,iBAAiB,EAAG,QAAO;AAC/B,YAAQ,SAAS;AACjB,YAAQ,sBAAsB;AAC9B,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,sBAAsB,SAA4B,MAAoE;AAC1H,YAAQ,WAAW,KAAK;AACxB,YAAQ,iBAAiB,KAAK;AAC9B,YAAQ,SAAS,KAAK;AACtB,UAAM,KAAK,GAAG,MAAM;AAAA,EACtB;AAAA,EAEA,MAAM,cAAc,SAA4B,MAAoE;AAClH,YAAQ,SAAS;AACjB,YAAQ,cAAc,oBAAI,KAAK;AAC/B,YAAQ,WAAW,KAAK;AACxB,YAAQ,iBAAiB,KAAK;AAC9B,YAAQ,SAAS,KAAK;AACtB,YAAQ,sBAAsB;AAC9B,YAAQ,eAAe;AACvB,UAAM,KAAK,GAAG,MAAM;AAAA,EACtB;AAAA,EAEA,MAAM,mBAAmB,SAA4B,QAAc;AACjE,YAAQ,mBAAmB;AAC3B,UAAM,KAAK,GAAG,MAAM;AAAA,EACtB;AAAA,EAEA,MAAM,iBAAiB,WAAmB,WAAiB,aAAqC;AAC9F,UAAM,cAAc,MAAM,KAAK,GAAG;AAAA,MAChC;AAAA,MACA;AAAA,QACE,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,wBAAwB;AAAA,QACxB,KAAK;AAAA,UACH,EAAE,sBAAsB,KAAK;AAAA,UAC7B,EAAE,sBAAsB,EAAE,KAAK,YAAY,EAAE;AAAA,QAC/C;AAAA,MACF;AAAA,MACA,EAAE,sBAAsB,WAAW,WAAW,oBAAI,KAAK,EAAE;AAAA,IAC3D;AACA,WAAO,cAAc;AAAA,EACvB;AAAA,EAEA,MAAM,iBAAiB,WAAmB,WAAmC;AAC3E,UAAM,cAAc,MAAM,KAAK,GAAG;AAAA,MAChC;AAAA,MACA;AAAA,QACE,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,wBAAwB;AAAA,QACxB,sBAAsB,EAAE,KAAK,KAAK;AAAA,MACpC;AAAA,MACA,EAAE,sBAAsB,WAAW,WAAW,oBAAI,KAAK,EAAE;AAAA,IAC3D;AACA,WAAO,cAAc;AAAA,EACvB;AAAA,EAEA,MAAM,yBAAyB,SAA4B,aAAmB;AAC5E,YAAQ,yBAAyB;AACjC,YAAQ,uBAAuB;AAC/B,UAAM,KAAK,GAAG,MAAM;AAAA,EACtB;AACF;AAEA,SAAS,UAAU,OAAe;AAChC,SAAO,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACxD;",
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/onboarding",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
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.
|
|
73
|
+
"@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
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
|
|
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
|
|
package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
96
|
+
return findOneWithDecryption(this.em, OnboardingRequest, { tokenHash })
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
async findById(id: string) {
|
|
90
|
-
return this.em
|
|
100
|
+
return findOneWithDecryption(this.em, OnboardingRequest, { id })
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
async findLatestByTenantId(tenantId: string) {
|
|
94
|
-
return
|
|
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
|
+
}
|