@open-mercato/onboarding 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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/api/get/onboarding/status.js +38 -5
- package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
- package/dist/modules/onboarding/api/get/onboarding/verify.js +30 -8
- package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +12 -2
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/lib/consentClientIp.js +11 -0
- package/dist/modules/onboarding/lib/consentClientIp.js.map +7 -0
- package/dist/modules/onboarding/lib/ready-email.js +3 -1
- package/dist/modules/onboarding/lib/ready-email.js.map +2 -2
- package/dist/modules/onboarding/lib/service.js +14 -2
- package/dist/modules/onboarding/lib/service.js.map +2 -2
- package/package.json +4 -5
- package/src/__tests__/consentClientIp.test.ts +47 -0
- package/src/__tests__/ready-email.test.ts +114 -0
- package/src/__tests__/service.test.ts +82 -0
- package/src/__tests__/status-endpoint-auth.test.ts +111 -0
- package/src/modules/onboarding/api/get/onboarding/status.ts +40 -4
- package/src/modules/onboarding/api/get/onboarding/verify.ts +37 -9
- package/src/modules/onboarding/api/post/onboarding.ts +12 -2
- package/src/modules/onboarding/lib/consentClientIp.ts +15 -0
- package/src/modules/onboarding/lib/ready-email.ts +3 -2
- package/src/modules/onboarding/lib/service.ts +16 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/onboarding/lib/service.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport { hash } from 'bcryptjs'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { OnboardingRequest } from '../data/entities'\nimport type { OnboardingStartInput } from '../data/validators'\n\ntype CreateRequestOptions = {\n expiresInHours?: number\n}\n\nexport class OnboardingService {\n constructor(private readonly em: EntityManager) {}\n\n async createOrUpdateRequest(input: OnboardingStartInput, options: CreateRequestOptions = {}) {\n const expiresInHours = options.expiresInHours ?? 24\n const token = randomBytes(32).toString('hex')\n const tokenHash = hashToken(token)\n const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)\n const now = new Date()\n const passwordHash = await hash(input.password, 10)\n\n const existing = await this.em.findOne(OnboardingRequest, { email: input.email })\n if (existing) {\n const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt\n if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {\n const remainingMs = 10 * 60 * 1000 - (Date.now() - lastSentAt.getTime())\n const waitMinutes = Math.max(1, Math.ceil(remainingMs / (60 * 1000)))\n throw new Error(`PENDING_REQUEST:${waitMinutes}`)\n }\n existing.tokenHash = tokenHash\n existing.status = 'pending'\n existing.firstName = input.firstName\n existing.lastName = input.lastName\n existing.organizationName = input.organizationName\n existing.locale = input.locale ?? existing.locale ?? 'en'\n existing.termsAccepted = true\n existing.marketingConsent = input.marketingConsent ?? false\n existing.passwordHash = passwordHash\n existing.expiresAt = expiresAt\n existing.completedAt = null\n existing.processingStartedAt = null\n existing.tenantId = null\n existing.organizationId = null\n existing.userId = null\n existing.lastEmailSentAt = now\n existing.preparationCompletedAt = null\n existing.readyEmailSentAt = null\n await this.em.flush()\n return { request: existing, token }\n }\n\n const request = this.em.create(OnboardingRequest, {\n email: input.email,\n tokenHash,\n status: 'pending',\n firstName: input.firstName,\n lastName: input.lastName,\n organizationName: input.organizationName,\n locale: input.locale ?? 'en',\n termsAccepted: true,\n marketingConsent: input.marketingConsent ?? false,\n passwordHash,\n expiresAt,\n processingStartedAt: null,\n lastEmailSentAt: now,\n createdAt: now,\n updatedAt: now,\n })\n await this.em.persist(request).flush()\n return { request, token }\n }\n\n async findPendingByToken(token: string) {\n const tokenHash = hashToken(token)\n const now = new Date()\n return this.em.findOne(OnboardingRequest, {\n tokenHash,\n status: 'pending',\n expiresAt: { $gt: now } as any,\n })\n }\n\n async findByToken(token: string) {\n const tokenHash = hashToken(token)\n return this.em.findOne(OnboardingRequest, { tokenHash })\n }\n\n async findById(id: string) {\n return this.em.findOne(OnboardingRequest, { id })\n }\n\n async findLatestByTenantId(tenantId: string) {\n return this.em.findOne(\n OnboardingRequest,\n { tenantId, deletedAt: null },\n { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },\n )\n }\n\n async startProcessing(request: OnboardingRequest, startedAt: Date) {\n request.status = 'processing'\n request.processingStartedAt = startedAt\n
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport { hash } from 'bcryptjs'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { OnboardingRequest } from '../data/entities'\nimport type { OnboardingStartInput } from '../data/validators'\n\ntype CreateRequestOptions = {\n expiresInHours?: number\n}\n\nexport class OnboardingService {\n constructor(private readonly em: EntityManager) {}\n\n async createOrUpdateRequest(input: OnboardingStartInput, options: CreateRequestOptions = {}) {\n const expiresInHours = options.expiresInHours ?? 24\n const token = randomBytes(32).toString('hex')\n const tokenHash = hashToken(token)\n const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)\n const now = new Date()\n const passwordHash = await hash(input.password, 10)\n\n const existing = await this.em.findOne(OnboardingRequest, { email: input.email })\n if (existing) {\n const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt\n if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {\n const remainingMs = 10 * 60 * 1000 - (Date.now() - lastSentAt.getTime())\n const waitMinutes = Math.max(1, Math.ceil(remainingMs / (60 * 1000)))\n throw new Error(`PENDING_REQUEST:${waitMinutes}`)\n }\n existing.tokenHash = tokenHash\n existing.status = 'pending'\n existing.firstName = input.firstName\n existing.lastName = input.lastName\n existing.organizationName = input.organizationName\n existing.locale = input.locale ?? existing.locale ?? 'en'\n existing.termsAccepted = true\n existing.marketingConsent = input.marketingConsent ?? false\n existing.passwordHash = passwordHash\n existing.expiresAt = expiresAt\n existing.completedAt = null\n existing.processingStartedAt = null\n existing.tenantId = null\n existing.organizationId = null\n existing.userId = null\n existing.lastEmailSentAt = now\n existing.preparationCompletedAt = null\n existing.readyEmailSentAt = null\n await this.em.flush()\n return { request: existing, token }\n }\n\n const request = this.em.create(OnboardingRequest, {\n email: input.email,\n tokenHash,\n status: 'pending',\n firstName: input.firstName,\n lastName: input.lastName,\n organizationName: input.organizationName,\n locale: input.locale ?? 'en',\n termsAccepted: true,\n marketingConsent: input.marketingConsent ?? false,\n passwordHash,\n expiresAt,\n processingStartedAt: null,\n lastEmailSentAt: now,\n createdAt: now,\n updatedAt: now,\n })\n await this.em.persist(request).flush()\n return { request, token }\n }\n\n async findPendingByToken(token: string) {\n const tokenHash = hashToken(token)\n const now = new Date()\n return this.em.findOne(OnboardingRequest, {\n tokenHash,\n status: 'pending',\n expiresAt: { $gt: now } as any,\n })\n }\n\n async findByToken(token: string) {\n const tokenHash = hashToken(token)\n return this.em.findOne(OnboardingRequest, { tokenHash })\n }\n\n async findById(id: string) {\n return this.em.findOne(OnboardingRequest, { id })\n }\n\n async findLatestByTenantId(tenantId: string) {\n return this.em.findOne(\n OnboardingRequest,\n { tenantId, deletedAt: null },\n { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },\n )\n }\n\n async startProcessing(request: OnboardingRequest, startedAt: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'pending' },\n { status: 'processing', processingStartedAt: startedAt, updatedAt: new Date() },\n )\n if (claimedRows === 0) return false\n request.status = 'processing'\n request.processingStartedAt = startedAt\n return true\n }\n\n async resetProcessing(request: OnboardingRequest): Promise<boolean> {\n const revertedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'processing' },\n { status: 'pending', processingStartedAt: null, updatedAt: new Date() },\n )\n if (revertedRows === 0) return false\n request.status = 'pending'\n request.processingStartedAt = null\n return true\n }\n\n async updateProvisioningIds(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n await this.em.flush()\n }\n\n async markCompleted(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.status = 'completed'\n request.completedAt = new Date()\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n request.processingStartedAt = null\n request.passwordHash = null\n await this.em.flush()\n }\n\n async markReadyEmailSent(request: OnboardingRequest, sentAt: Date) {\n request.readyEmailSentAt = sentAt\n await this.em.flush()\n }\n\n async markPreparationCompleted(request: OnboardingRequest, completedAt: Date) {\n request.preparationCompletedAt = completedAt\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,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;AAElD,UAAM,WAAW,MAAM,KAAK,GAAG,QAAQ,mBAAmB,EAAE,OAAO,MAAM,MAAM,CAAC;AAChF,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,cAAc;AACvB,eAAS,sBAAsB;AAC/B,eAAS,WAAW;AACpB,eAAS,iBAAiB;AAC1B,eAAS,SAAS;AAClB,eAAS,kBAAkB;AAC3B,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,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,KAAK,GAAG,QAAQ,mBAAmB;AAAA,MACxC;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,KAAK,GAAG,QAAQ,mBAAmB,EAAE,UAAU,CAAC;AAAA,EACzD;AAAA,EAEA,MAAM,SAAS,IAAY;AACzB,WAAO,KAAK,GAAG,QAAQ,mBAAmB,EAAE,GAAG,CAAC;AAAA,EAClD;AAAA,EAEA,MAAM,qBAAqB,UAAkB;AAC3C,WAAO,KAAK,GAAG;AAAA,MACb;AAAA,MACA,EAAE,UAAU,WAAW,KAAK;AAAA,MAC5B,EAAE,SAAS,EAAE,WAAW,QAAQ,WAAW,OAAO,EAAE;AAAA,IACtD;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,yBAAyB,SAA4B,aAAmB;AAC5E,YAAQ,yBAAyB;AACjC,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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/onboarding",
|
|
3
|
-
"version": "0.6.4
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@open-mercato/shared": "0.6.4
|
|
72
|
+
"@open-mercato/shared": "0.6.4"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@open-mercato/shared": "0.6.4
|
|
75
|
+
"@open-mercato/shared": "0.6.4",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
77
77
|
"jest": "^30.4.2",
|
|
78
78
|
"ts-jest": "^29.4.11"
|
|
@@ -84,6 +84,5 @@
|
|
|
84
84
|
"type": "git",
|
|
85
85
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
86
86
|
"directory": "packages/onboarding"
|
|
87
|
-
}
|
|
88
|
-
"stableVersion": "0.6.3"
|
|
87
|
+
}
|
|
89
88
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolveConsentClientIp } from '@open-mercato/onboarding/modules/onboarding/lib/consentClientIp'
|
|
2
|
+
|
|
3
|
+
const getCachedRateLimiterService = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('@open-mercato/core/bootstrap', () => ({
|
|
6
|
+
getCachedRateLimiterService: () => getCachedRateLimiterService(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
function makeRequest(headers: Record<string, string>): Request {
|
|
10
|
+
return new Request('http://localhost/onboarding/onboarding/verify', { headers })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('resolveConsentClientIp', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
getCachedRateLimiterService.mockReset()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('ignores a spoofed X-Forwarded-For when no proxy is trusted (trustProxyDepth=0)', () => {
|
|
19
|
+
getCachedRateLimiterService.mockReturnValue({ trustProxyDepth: 0 })
|
|
20
|
+
const req = makeRequest({ 'x-forwarded-for': '6.6.6.6' })
|
|
21
|
+
expect(resolveConsentClientIp(req)).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('honors the configured trust depth instead of a hardcoded 1 (trustProxyDepth=2)', () => {
|
|
25
|
+
getCachedRateLimiterService.mockReturnValue({ trustProxyDepth: 2 })
|
|
26
|
+
const req = makeRequest({ 'x-forwarded-for': 'client, proxy2, proxy1' })
|
|
27
|
+
expect(resolveConsentClientIp(req)).toBe('proxy2')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('records the trusted client IP for a single-proxy deployment (trustProxyDepth=1)', () => {
|
|
31
|
+
getCachedRateLimiterService.mockReturnValue({ trustProxyDepth: 1 })
|
|
32
|
+
const req = makeRequest({ 'x-forwarded-for': 'client, edge' })
|
|
33
|
+
expect(resolveConsentClientIp(req)).toBe('edge')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('falls back to X-Real-IP when X-Forwarded-For is untrusted', () => {
|
|
37
|
+
getCachedRateLimiterService.mockReturnValue({ trustProxyDepth: 0 })
|
|
38
|
+
const req = makeRequest({ 'x-forwarded-for': '6.6.6.6', 'x-real-ip': '10.0.0.5' })
|
|
39
|
+
expect(resolveConsentClientIp(req)).toBe('10.0.0.5')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns null when the rate limiter service is unavailable', () => {
|
|
43
|
+
getCachedRateLimiterService.mockReturnValue(null)
|
|
44
|
+
const req = makeRequest({ 'x-forwarded-for': '6.6.6.6' })
|
|
45
|
+
expect(resolveConsentClientIp(req)).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { OnboardingRequest } from '../modules/onboarding/data/entities'
|
|
2
|
+
|
|
3
|
+
const sendEmailMock = jest.fn().mockResolvedValue(undefined)
|
|
4
|
+
const findByIdMock = jest.fn()
|
|
5
|
+
const markReadyEmailSentMock = jest.fn().mockResolvedValue(undefined)
|
|
6
|
+
const workspaceReadyEmailMock = jest.fn().mockReturnValue(null)
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
9
|
+
createRequestContainer: jest.fn().mockResolvedValue({
|
|
10
|
+
resolve: () => ({}),
|
|
11
|
+
}),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/email/send', () => ({
|
|
15
|
+
sendEmail: (...args: unknown[]) => sendEmailMock(...args),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
19
|
+
loadDictionary: jest.fn().mockResolvedValue({}),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
jest.mock('@open-mercato/shared/lib/i18n/translate', () => ({
|
|
23
|
+
createFallbackTranslator: () => (_key: string, fallback: string) => fallback,
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
|
|
27
|
+
OnboardingService: jest.fn().mockImplementation(() => ({
|
|
28
|
+
findById: (...args: unknown[]) => findByIdMock(...args),
|
|
29
|
+
markReadyEmailSent: (...args: unknown[]) => markReadyEmailSentMock(...args),
|
|
30
|
+
})),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/emails/WorkspaceReadyEmail', () => ({
|
|
34
|
+
__esModule: true,
|
|
35
|
+
default: (props: { loginUrl: string }) => workspaceReadyEmailMock(props),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
import { sendWorkspaceReadyEmail } from '../modules/onboarding/lib/ready-email'
|
|
39
|
+
|
|
40
|
+
function makeReadyRequest(overrides: Record<string, unknown> = {}) {
|
|
41
|
+
return Object.assign(new OnboardingRequest(), {
|
|
42
|
+
id: 'req-1',
|
|
43
|
+
email: 'owner@example.com',
|
|
44
|
+
status: 'completed',
|
|
45
|
+
firstName: 'Jane',
|
|
46
|
+
lastName: 'Doe',
|
|
47
|
+
organizationName: 'Acme Corp',
|
|
48
|
+
locale: 'en',
|
|
49
|
+
tenantId: 'tenant-uuid',
|
|
50
|
+
readyEmailSentAt: null,
|
|
51
|
+
...overrides,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('sendWorkspaceReadyEmail', () => {
|
|
56
|
+
const originalEnv = { ...process.env }
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
jest.clearAllMocks()
|
|
60
|
+
process.env = { ...originalEnv }
|
|
61
|
+
delete process.env.NEXT_PUBLIC_APP_URL
|
|
62
|
+
delete process.env.APP_URL
|
|
63
|
+
delete process.env.APP_ALLOWED_ORIGINS
|
|
64
|
+
process.env.NODE_ENV = 'test'
|
|
65
|
+
findByIdMock.mockResolvedValue(makeReadyRequest())
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
process.env = originalEnv
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('builds the login link from the configured APP_URL, not a request header', async () => {
|
|
73
|
+
process.env.APP_URL = 'https://app.openmercato.com'
|
|
74
|
+
|
|
75
|
+
const sent = await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })
|
|
76
|
+
|
|
77
|
+
expect(sent).toBe(true)
|
|
78
|
+
expect(workspaceReadyEmailMock).toHaveBeenCalledTimes(1)
|
|
79
|
+
const props = workspaceReadyEmailMock.mock.calls[0][0]
|
|
80
|
+
expect(props.loginUrl).toBe('https://app.openmercato.com/login?tenant=tenant-uuid')
|
|
81
|
+
expect(props.loginUrl).not.toContain('evil.com')
|
|
82
|
+
expect(sendEmailMock).toHaveBeenCalledTimes(1)
|
|
83
|
+
expect(markReadyEmailSentMock).toHaveBeenCalledTimes(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('falls back to the dev origin outside production when APP_URL is unset', async () => {
|
|
87
|
+
const sent = await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })
|
|
88
|
+
|
|
89
|
+
expect(sent).toBe(true)
|
|
90
|
+
const props = workspaceReadyEmailMock.mock.calls[0][0]
|
|
91
|
+
expect(props.loginUrl).toBe('http://localhost:3000/login?tenant=tenant-uuid')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('throws in production when APP_URL is not configured instead of trusting a host', async () => {
|
|
95
|
+
process.env.NODE_ENV = 'production'
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' }),
|
|
99
|
+
).rejects.toThrow(/APP_URL/)
|
|
100
|
+
|
|
101
|
+
expect(sendEmailMock).not.toHaveBeenCalled()
|
|
102
|
+
expect(markReadyEmailSentMock).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('skips when the request is missing or already notified', async () => {
|
|
106
|
+
findByIdMock.mockResolvedValueOnce(null)
|
|
107
|
+
expect(await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })).toBe(false)
|
|
108
|
+
|
|
109
|
+
findByIdMock.mockResolvedValueOnce(makeReadyRequest({ readyEmailSentAt: new Date() }))
|
|
110
|
+
expect(await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })).toBe(false)
|
|
111
|
+
|
|
112
|
+
expect(sendEmailMock).not.toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -75,6 +75,38 @@ function createMockEm(overrides: Record<string, unknown> = {}): MockEm {
|
|
|
75
75
|
} as unknown as MockEm
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
type DbRow = { id: string; status: string; processingStartedAt: Date | null }
|
|
79
|
+
|
|
80
|
+
// EntityManager mock backed by a single persisted row. `nativeUpdate` honours the
|
|
81
|
+
// status guard in its WHERE clause (so it returns 0 when the row has already moved
|
|
82
|
+
// on), while `flush` writes the tracked managed entity's scalars back to the row —
|
|
83
|
+
// reproducing MikroORM's last-write-wins behaviour for the unconditional code path.
|
|
84
|
+
function createRowBackedEm(dbRow: DbRow, trackedEntity?: OnboardingRequest): EntityManager {
|
|
85
|
+
const flush = jest.fn(async () => {
|
|
86
|
+
if (!trackedEntity) return
|
|
87
|
+
dbRow.status = trackedEntity.status
|
|
88
|
+
dbRow.processingStartedAt = trackedEntity.processingStartedAt ?? null
|
|
89
|
+
})
|
|
90
|
+
const nativeUpdate = jest.fn(
|
|
91
|
+
async (_entity: unknown, where: Record<string, unknown>, data: Record<string, unknown>) => {
|
|
92
|
+
const matches = Object.entries(where).every(
|
|
93
|
+
([key, value]) => (dbRow as unknown as Record<string, unknown>)[key] === value,
|
|
94
|
+
)
|
|
95
|
+
if (!matches) return 0
|
|
96
|
+
if ('status' in data) dbRow.status = data.status as string
|
|
97
|
+
if ('processingStartedAt' in data) dbRow.processingStartedAt = (data.processingStartedAt as Date | null) ?? null
|
|
98
|
+
return 1
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
return {
|
|
102
|
+
findOne: jest.fn().mockResolvedValue(null),
|
|
103
|
+
create: jest.fn(),
|
|
104
|
+
persist: jest.fn(() => ({ flush })),
|
|
105
|
+
flush,
|
|
106
|
+
nativeUpdate,
|
|
107
|
+
} as unknown as EntityManager
|
|
108
|
+
}
|
|
109
|
+
|
|
78
110
|
describe('OnboardingService', () => {
|
|
79
111
|
describe('createOrUpdateRequest', () => {
|
|
80
112
|
it('creates a new pending request when no existing email found', async () => {
|
|
@@ -126,4 +158,54 @@ describe('OnboardingService', () => {
|
|
|
126
158
|
expect(em.flush).toHaveBeenCalled()
|
|
127
159
|
})
|
|
128
160
|
})
|
|
161
|
+
|
|
162
|
+
// Regression coverage for #2742: two concurrent verify requests carrying the same
|
|
163
|
+
// token must not corrupt the persisted state. The fix makes the pending→processing
|
|
164
|
+
// transition an atomic claim and the processing→pending reset conditional, so a
|
|
165
|
+
// request that lost the race can never clobber a sibling that already completed.
|
|
166
|
+
describe('startProcessing (atomic claim, #2742)', () => {
|
|
167
|
+
it('lets only one concurrent caller claim a pending request', async () => {
|
|
168
|
+
const dbRow: DbRow = { id: 'req-1', status: 'pending', processingStartedAt: null }
|
|
169
|
+
const em = createRowBackedEm(dbRow)
|
|
170
|
+
const service = new OnboardingService(em)
|
|
171
|
+
const requestA = makeRequest({ id: 'req-1', status: 'pending', processingStartedAt: null })
|
|
172
|
+
const requestB = makeRequest({ id: 'req-1', status: 'pending', processingStartedAt: null })
|
|
173
|
+
|
|
174
|
+
const claimedA = await service.startProcessing(requestA, new Date())
|
|
175
|
+
const claimedB = await service.startProcessing(requestB, new Date())
|
|
176
|
+
|
|
177
|
+
expect(claimedA).toBe(true)
|
|
178
|
+
expect(claimedB).toBe(false)
|
|
179
|
+
expect(dbRow.status).toBe('processing')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('resetProcessing (conditional revert, #2742)', () => {
|
|
184
|
+
it('does not revert a request a concurrent worker already completed', async () => {
|
|
185
|
+
// A sibling worker already advanced the persisted row to 'completed'.
|
|
186
|
+
const dbRow: DbRow = { id: 'req-1', status: 'completed', processingStartedAt: null }
|
|
187
|
+
// This worker still holds an in-memory copy it believes is 'processing'.
|
|
188
|
+
const staleRequest = makeRequest({ id: 'req-1', status: 'processing', processingStartedAt: new Date() })
|
|
189
|
+
const em = createRowBackedEm(dbRow, staleRequest)
|
|
190
|
+
const service = new OnboardingService(em)
|
|
191
|
+
|
|
192
|
+
const reverted = await service.resetProcessing(staleRequest)
|
|
193
|
+
|
|
194
|
+
expect(reverted).toBe(false)
|
|
195
|
+
expect(dbRow.status).toBe('completed')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('reverts a request that is still processing', async () => {
|
|
199
|
+
const dbRow: DbRow = { id: 'req-1', status: 'processing', processingStartedAt: new Date() }
|
|
200
|
+
const request = makeRequest({ id: 'req-1', status: 'processing', processingStartedAt: new Date() })
|
|
201
|
+
const em = createRowBackedEm(dbRow, request)
|
|
202
|
+
const service = new OnboardingService(em)
|
|
203
|
+
|
|
204
|
+
const reverted = await service.resetProcessing(request)
|
|
205
|
+
|
|
206
|
+
expect(reverted).toBe(true)
|
|
207
|
+
expect(dbRow.status).toBe('pending')
|
|
208
|
+
expect(dbRow.processingStartedAt).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
129
211
|
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { OnboardingRequest } from '../modules/onboarding/data/entities'
|
|
2
|
+
|
|
3
|
+
const findLatestByTenantId = jest.fn()
|
|
4
|
+
const sendWorkspaceReadyEmail = jest.fn()
|
|
5
|
+
|
|
6
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
7
|
+
createRequestContainer: jest.fn(async () => ({
|
|
8
|
+
resolve: (name: string) => {
|
|
9
|
+
if (name === 'em') return {}
|
|
10
|
+
throw new Error(`unexpected resolve(${name})`)
|
|
11
|
+
},
|
|
12
|
+
})),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
jest.mock('@open-mercato/shared/lib/url', () => ({
|
|
16
|
+
getSecurityEmailBaseUrl: () => 'https://app.example.com',
|
|
17
|
+
mapSecurityEmailUrlError: () => null,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
|
|
21
|
+
OnboardingService: jest.fn().mockImplementation(() => ({
|
|
22
|
+
findLatestByTenantId,
|
|
23
|
+
})),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/ready-email', () => ({
|
|
27
|
+
sendWorkspaceReadyEmail,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
import { GET } from '../modules/onboarding/api/get/onboarding/status'
|
|
31
|
+
|
|
32
|
+
const TENANT_ID = '11111111-1111-4111-8111-111111111111'
|
|
33
|
+
const OTHER_TENANT_ID = '22222222-2222-4222-8222-222222222222'
|
|
34
|
+
|
|
35
|
+
function makeRequest(overrides: Record<string, unknown> = {}) {
|
|
36
|
+
return Object.assign(new OnboardingRequest(), {
|
|
37
|
+
id: 'req-1',
|
|
38
|
+
status: 'completed',
|
|
39
|
+
tenantId: TENANT_ID,
|
|
40
|
+
organizationId: 'org-1',
|
|
41
|
+
preparationCompletedAt: new Date(),
|
|
42
|
+
readyEmailSentAt: new Date(),
|
|
43
|
+
...overrides,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildRequest(args: { tenantId: string; cookie?: string }) {
|
|
48
|
+
const headers = new Headers()
|
|
49
|
+
if (args.cookie !== undefined) headers.set('cookie', args.cookie)
|
|
50
|
+
return new Request(`https://app.example.com/api/onboarding/onboarding/status?tenantId=${args.tenantId}`, {
|
|
51
|
+
headers,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('onboarding status endpoint authorization', () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
findLatestByTenantId.mockReset()
|
|
58
|
+
sendWorkspaceReadyEmail.mockReset()
|
|
59
|
+
findLatestByTenantId.mockResolvedValue(makeRequest())
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns 403 and does not look up tenant state when no om_login_tenant cookie is present', async () => {
|
|
63
|
+
const res = await GET(buildRequest({ tenantId: TENANT_ID }))
|
|
64
|
+
expect(res.status).toBe(403)
|
|
65
|
+
const body = await res.json()
|
|
66
|
+
expect(body).toEqual({ ok: false, error: 'Not authorized for this tenant.' })
|
|
67
|
+
expect(findLatestByTenantId).not.toHaveBeenCalled()
|
|
68
|
+
expect(sendWorkspaceReadyEmail).not.toHaveBeenCalled()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns 403 when the om_login_tenant cookie is for a different tenant', async () => {
|
|
72
|
+
const res = await GET(
|
|
73
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${OTHER_TENANT_ID}` }),
|
|
74
|
+
)
|
|
75
|
+
expect(res.status).toBe(403)
|
|
76
|
+
expect(findLatestByTenantId).not.toHaveBeenCalled()
|
|
77
|
+
expect(sendWorkspaceReadyEmail).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('does not trigger the ready email side effect for an unauthorized caller', async () => {
|
|
81
|
+
findLatestByTenantId.mockResolvedValue(
|
|
82
|
+
makeRequest({ readyEmailSentAt: null }),
|
|
83
|
+
)
|
|
84
|
+
const res = await GET(
|
|
85
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${OTHER_TENANT_ID}` }),
|
|
86
|
+
)
|
|
87
|
+
expect(res.status).toBe(403)
|
|
88
|
+
expect(sendWorkspaceReadyEmail).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('returns the status when the om_login_tenant cookie matches the requested tenant', async () => {
|
|
92
|
+
const res = await GET(
|
|
93
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `other=foo; om_login_tenant=${TENANT_ID}` }),
|
|
94
|
+
)
|
|
95
|
+
expect(res.status).toBe(200)
|
|
96
|
+
const body = await res.json()
|
|
97
|
+
expect(body.ok).toBe(true)
|
|
98
|
+
expect(body.tenantId).toBe(TENANT_ID)
|
|
99
|
+
expect(body.ready).toBe(true)
|
|
100
|
+
expect(body.loginUrl).toBe(`https://app.example.com/login?tenant=${TENANT_ID}`)
|
|
101
|
+
expect(findLatestByTenantId).toHaveBeenCalledWith(TENANT_ID)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns 404 for an authorized caller when no onboarding record exists', async () => {
|
|
105
|
+
findLatestByTenantId.mockResolvedValue(null)
|
|
106
|
+
const res = await GET(
|
|
107
|
+
buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
|
|
108
|
+
)
|
|
109
|
+
expect(res.status).toBe(404)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
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
|
-
import {
|
|
5
|
+
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
6
6
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
7
7
|
import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
|
|
8
8
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -14,10 +14,30 @@ export const metadata = {
|
|
|
14
14
|
},
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const ONBOARDING_LOGIN_TENANT_COOKIE = 'om_login_tenant'
|
|
18
|
+
|
|
17
19
|
const onboardingStatusQuerySchema = z.object({
|
|
18
20
|
tenantId: z.string().uuid(),
|
|
19
21
|
})
|
|
20
22
|
|
|
23
|
+
function readCookie(req: Request, name: string): string | null {
|
|
24
|
+
const header = req.headers.get('cookie')
|
|
25
|
+
if (!header) return null
|
|
26
|
+
for (const part of header.split(';')) {
|
|
27
|
+
const separatorIndex = part.indexOf('=')
|
|
28
|
+
if (separatorIndex === -1) continue
|
|
29
|
+
const key = part.slice(0, separatorIndex).trim()
|
|
30
|
+
if (key !== name) continue
|
|
31
|
+
const rawValue = part.slice(separatorIndex + 1).trim()
|
|
32
|
+
try {
|
|
33
|
+
return decodeURIComponent(rawValue)
|
|
34
|
+
} catch {
|
|
35
|
+
return rawValue
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export async function GET(req: Request) {
|
|
22
42
|
const url = new URL(req.url)
|
|
23
43
|
const tenantId = url.searchParams.get('tenantId') || url.searchParams.get('tenant') || ''
|
|
@@ -26,7 +46,22 @@ export async function GET(req: Request) {
|
|
|
26
46
|
return NextResponse.json({ ok: false, error: 'Invalid tenant id.' }, { status: 400 })
|
|
27
47
|
}
|
|
28
48
|
|
|
29
|
-
const
|
|
49
|
+
const loginTenantCookie = readCookie(req, ONBOARDING_LOGIN_TENANT_COOKIE)
|
|
50
|
+
if (!loginTenantCookie || loginTenantCookie !== parsed.data.tenantId) {
|
|
51
|
+
return NextResponse.json({ ok: false, error: 'Not authorized for this tenant.' }, { status: 403 })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let baseUrl: string
|
|
55
|
+
try {
|
|
56
|
+
baseUrl = getSecurityEmailBaseUrl(req)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
59
|
+
scope: 'onboarding.status',
|
|
60
|
+
configMessage: 'Onboarding status is not configured.',
|
|
61
|
+
})
|
|
62
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
30
65
|
const container = await createRequestContainer()
|
|
31
66
|
const em = container.resolve('em') as EntityManager
|
|
32
67
|
const service = new OnboardingService(em)
|
|
@@ -43,7 +78,6 @@ export async function GET(req: Request) {
|
|
|
43
78
|
try {
|
|
44
79
|
emailSent = await sendWorkspaceReadyEmail({
|
|
45
80
|
requestId: request.id,
|
|
46
|
-
baseUrl,
|
|
47
81
|
tenantId: request.tenantId,
|
|
48
82
|
})
|
|
49
83
|
} catch (error) {
|
|
@@ -91,8 +125,10 @@ const onboardingStatusDoc: OpenApiMethodDoc = {
|
|
|
91
125
|
{ status: 200, description: 'Onboarding status resolved.', schema: onboardingStatusSuccessSchema },
|
|
92
126
|
],
|
|
93
127
|
errors: [
|
|
94
|
-
{ status: 400, description: 'Invalid tenant id.', schema: onboardingStatusErrorSchema },
|
|
128
|
+
{ status: 400, description: 'Invalid tenant id or request origin.', schema: onboardingStatusErrorSchema },
|
|
129
|
+
{ status: 403, description: 'Caller is not authorized for this tenant.', schema: onboardingStatusErrorSchema },
|
|
95
130
|
{ status: 404, description: 'Onboarding request not found.', schema: onboardingStatusErrorSchema },
|
|
131
|
+
{ status: 500, description: 'Onboarding status is not configured.', schema: onboardingStatusErrorSchema },
|
|
96
132
|
],
|
|
97
133
|
}
|
|
98
134
|
|
|
@@ -3,14 +3,18 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import type { SearchIndexer } from '@open-mercato/search'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AppOriginConfigurationError,
|
|
8
|
+
AppOriginRejectedError,
|
|
9
|
+
getSecurityEmailBaseUrl,
|
|
10
|
+
} from '@open-mercato/shared/lib/url'
|
|
7
11
|
import { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'
|
|
8
12
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
9
13
|
import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
|
|
10
14
|
import { setupInitialTenant } from '@open-mercato/core/modules/auth/lib/setup-app'
|
|
11
15
|
import { UserConsent } from '@open-mercato/core/modules/auth/data/entities'
|
|
12
16
|
import { computeConsentIntegrityHash } from '@open-mercato/core/modules/auth/lib/consentIntegrity'
|
|
13
|
-
import {
|
|
17
|
+
import { resolveConsentClientIp } from '@open-mercato/onboarding/modules/onboarding/lib/consentClientIp'
|
|
14
18
|
import { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'
|
|
15
19
|
import { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'
|
|
16
20
|
import { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'
|
|
@@ -26,6 +30,21 @@ export const metadata = {
|
|
|
26
30
|
},
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
function resolveTrustedBaseUrl(req: Request): string {
|
|
34
|
+
try {
|
|
35
|
+
return getSecurityEmailBaseUrl(req)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof AppOriginRejectedError || error instanceof AppOriginConfigurationError) {
|
|
38
|
+
console.error('[onboarding.verify] rejected request origin for redirect base', {
|
|
39
|
+
requestUrl: req.url,
|
|
40
|
+
reason: error.message,
|
|
41
|
+
})
|
|
42
|
+
return new URL(req.url).origin
|
|
43
|
+
}
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
function clearAuthCookies(response: NextResponse) {
|
|
30
49
|
response.cookies.set('auth_token', '', { path: '/', maxAge: 0 })
|
|
31
50
|
response.cookies.set('session_token', '', { path: '/', maxAge: 0 })
|
|
@@ -218,7 +237,6 @@ async function rebuildTenantQueryIndexes(args: {
|
|
|
218
237
|
|
|
219
238
|
async function runDeferredProvisioning(args: {
|
|
220
239
|
requestId: string
|
|
221
|
-
baseUrl: string
|
|
222
240
|
tenantId: string
|
|
223
241
|
organizationId: string
|
|
224
242
|
}) {
|
|
@@ -256,7 +274,6 @@ async function runDeferredProvisioning(args: {
|
|
|
256
274
|
|
|
257
275
|
await sendWorkspaceReadyEmail({
|
|
258
276
|
requestId: args.requestId,
|
|
259
|
-
baseUrl: args.baseUrl,
|
|
260
277
|
tenantId: args.tenantId,
|
|
261
278
|
}).catch((error) => {
|
|
262
279
|
console.error('[onboarding.verify] ready email failed', {
|
|
@@ -289,7 +306,7 @@ async function runDeferredProvisioning(args: {
|
|
|
289
306
|
|
|
290
307
|
export async function GET(req: Request) {
|
|
291
308
|
const url = new URL(req.url)
|
|
292
|
-
const baseUrl =
|
|
309
|
+
const baseUrl = resolveTrustedBaseUrl(req)
|
|
293
310
|
const token = url.searchParams.get('token') ?? ''
|
|
294
311
|
const parsed = onboardingVerifySchema.safeParse({ token })
|
|
295
312
|
if (!parsed.success) {
|
|
@@ -314,7 +331,6 @@ export async function GET(req: Request) {
|
|
|
314
331
|
after(async () => {
|
|
315
332
|
await sendWorkspaceReadyEmail({
|
|
316
333
|
requestId: request.id,
|
|
317
|
-
baseUrl,
|
|
318
334
|
tenantId: request.tenantId!,
|
|
319
335
|
}).catch((error) => {
|
|
320
336
|
console.error('[onboarding.verify] retry ready email failed', {
|
|
@@ -340,7 +356,20 @@ export async function GET(req: Request) {
|
|
|
340
356
|
if (request.status !== 'pending') {
|
|
341
357
|
return redirectWithStatus(baseUrl, 'invalid')
|
|
342
358
|
}
|
|
343
|
-
await service.startProcessing(request, new Date())
|
|
359
|
+
const claimed = await service.startProcessing(request, new Date())
|
|
360
|
+
if (!claimed) {
|
|
361
|
+
// A concurrent verify request already claimed this token (pending → processing).
|
|
362
|
+
// Re-read on a fresh fork — the request EM's identity map still holds the stale
|
|
363
|
+
// pre-claim copy — and route off the winner's committed state instead of re-running
|
|
364
|
+
// provisioning, which would throw USER_EXISTS and could strand the request (#2742).
|
|
365
|
+
const current = await new OnboardingService(em.fork()).findById(request.id)
|
|
366
|
+
if (current?.status === 'completed' && current.tenantId) {
|
|
367
|
+
return current.preparationCompletedAt
|
|
368
|
+
? redirectToLogin(baseUrl, current.tenantId)
|
|
369
|
+
: redirectToPreparing(baseUrl, current.tenantId)
|
|
370
|
+
}
|
|
371
|
+
return redirectToPreparing(baseUrl, current?.tenantId ?? request.tenantId ?? null)
|
|
372
|
+
}
|
|
344
373
|
if (!request.passwordHash) {
|
|
345
374
|
console.error('[onboarding.verify] missing password hash for request', request.id)
|
|
346
375
|
await service.resetProcessing(request)
|
|
@@ -387,7 +416,7 @@ export async function GET(req: Request) {
|
|
|
387
416
|
|
|
388
417
|
if (request.marketingConsent) {
|
|
389
418
|
const now = new Date()
|
|
390
|
-
const clientIp =
|
|
419
|
+
const clientIp = resolveConsentClientIp(req)
|
|
391
420
|
const integrityHash = computeConsentIntegrityHash({
|
|
392
421
|
userId: resolvedUserId,
|
|
393
422
|
consentType: 'marketing_email',
|
|
@@ -444,7 +473,6 @@ export async function GET(req: Request) {
|
|
|
444
473
|
after(async () => {
|
|
445
474
|
await runDeferredProvisioning({
|
|
446
475
|
requestId: request.id,
|
|
447
|
-
baseUrl,
|
|
448
476
|
tenantId: resolvedTenantId,
|
|
449
477
|
organizationId: resolvedOrganizationId,
|
|
450
478
|
})
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
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
|
-
import {
|
|
5
|
+
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
6
6
|
import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
|
|
7
7
|
import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
8
8
|
import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'
|
|
@@ -125,7 +125,17 @@ export async function POST(req: Request) {
|
|
|
125
125
|
throw err
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
let baseUrl: string
|
|
129
|
+
try {
|
|
130
|
+
baseUrl = getSecurityEmailBaseUrl(req)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
133
|
+
scope: 'onboarding.start',
|
|
134
|
+
configMessage: 'Self-service onboarding is not configured.',
|
|
135
|
+
})
|
|
136
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
129
139
|
const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`
|
|
130
140
|
|
|
131
141
|
const firstName = request.firstName || parsed.data.firstName
|