@open-mercato/onboarding 0.6.6-develop.6465.1.019f0fb26f → 0.6.6-develop.6471.1.9ab5bdd79a
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/dist/modules/onboarding/api/demo-feedback/route.js +4 -2
- package/dist/modules/onboarding/api/demo-feedback/route.js.map +2 -2
- package/dist/modules/onboarding/api/get/onboarding/status.js +4 -2
- package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
- package/dist/modules/onboarding/api/get/onboarding/verify.js +11 -9
- package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +5 -3
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/lib/deferred-provisioning.js +16 -14
- package/dist/modules/onboarding/lib/deferred-provisioning.js.map +2 -2
- package/dist/modules/onboarding/lib/verify-base-url.js +5 -3
- package/dist/modules/onboarding/lib/verify-base-url.js.map +2 -2
- package/package.json +3 -3
- package/src/modules/onboarding/api/demo-feedback/route.ts +5 -2
- package/src/modules/onboarding/api/get/onboarding/status.ts +5 -2
- package/src/modules/onboarding/api/get/onboarding/verify.ts +12 -9
- package/src/modules/onboarding/api/post/onboarding.ts +6 -3
- package/src/modules/onboarding/lib/deferred-provisioning.ts +17 -14
- package/src/modules/onboarding/lib/verify-base-url.ts +6 -3
|
@@ -4,6 +4,8 @@ import { sendEmail } from "@open-mercato/shared/lib/email/send";
|
|
|
4
4
|
import FeedbackEmail from "@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail";
|
|
5
5
|
import { checkAuthRateLimit } from "@open-mercato/core/modules/auth/lib/rateLimitCheck";
|
|
6
6
|
import { readEndpointRateLimitConfig } from "@open-mercato/shared/lib/ratelimit/config";
|
|
7
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
8
|
+
const logger = createLogger("onboarding").child({ component: "demo-feedback" });
|
|
7
9
|
const demoFeedbackIpRateLimitConfig = readEndpointRateLimitConfig("DEMO_FEEDBACK_IP", {
|
|
8
10
|
points: 5,
|
|
9
11
|
duration: 300,
|
|
@@ -60,7 +62,7 @@ async function POST(req) {
|
|
|
60
62
|
react: FeedbackEmail({ copy: adminCopy })
|
|
61
63
|
});
|
|
62
64
|
} catch (err) {
|
|
63
|
-
|
|
65
|
+
logger.error("Admin email failed", { err });
|
|
64
66
|
return NextResponse.json({ ok: false, error: "Failed to send feedback. Please try again." }, { status: 502 });
|
|
65
67
|
}
|
|
66
68
|
if (sendCopy) {
|
|
@@ -80,7 +82,7 @@ async function POST(req) {
|
|
|
80
82
|
react: FeedbackEmail({ copy: userCopy })
|
|
81
83
|
});
|
|
82
84
|
} catch (err) {
|
|
83
|
-
|
|
85
|
+
logger.error("User copy email failed", { err });
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
return NextResponse.json({ ok: true });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/onboarding/api/demo-feedback/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport FeedbackEmail from '@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nconst demoFeedbackIpRateLimitConfig = readEndpointRateLimitConfig('DEMO_FEEDBACK_IP', {\n points: 5, duration: 300, blockDuration: 300, keyPrefix: 'demo-feedback-ip',\n})\n\nexport const metadata = {\n path: '/onboarding/demo-feedback',\n POST: {\n requireAuth: false,\n },\n}\n\nconst feedbackSchema = z.object({\n email: z.string().email(),\n message: z.string().max(5000).optional().default(''),\n termsAccepted: z.literal(true),\n marketingConsent: z.boolean().optional().default(false),\n sendCopy: z.boolean().optional().default(true),\n})\n\nexport async function POST(req: Request) {\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: demoFeedbackIpRateLimitConfig,\n })\n if (rateLimitError) return rateLimitError\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 parsed = feedbackSchema.safeParse(payload)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Please check the form and try again.' }, { status: 400 })\n }\n\n const { email, message, marketingConsent, sendCopy } = parsed.data\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n\n const marketingText = marketingConsent ? 'Marketing consent: Yes' : 'Marketing consent: No'\n\n const adminCopy = {\n preview: `Demo feedback from ${email}`,\n heading: 'New demo feedback',\n body: `${email} submitted a feedback/contact request from the demo environment.`,\n senderEmailLabel: 'From email:',\n senderEmail: email,\n messageLabel: 'Message:',\n message: message || '(no message provided)',\n marketingConsent: marketingText,\n footer: 'Open Mercato \\u00b7 Demo feedback',\n }\n\n try {\n await sendEmail({\n to: adminEmail,\n subject: `Demo feedback from ${email}`,\n react: FeedbackEmail({ copy: adminCopy }),\n })\n } catch (err) {\n
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,iBAAiB;AAC1B,OAAO,mBAAmB;AAC1B,SAAS,0BAA0B;AACnC,SAAS,mCAAmC;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport FeedbackEmail from '@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('onboarding').child({ component: 'demo-feedback' })\n\nconst demoFeedbackIpRateLimitConfig = readEndpointRateLimitConfig('DEMO_FEEDBACK_IP', {\n points: 5, duration: 300, blockDuration: 300, keyPrefix: 'demo-feedback-ip',\n})\n\nexport const metadata = {\n path: '/onboarding/demo-feedback',\n POST: {\n requireAuth: false,\n },\n}\n\nconst feedbackSchema = z.object({\n email: z.string().email(),\n message: z.string().max(5000).optional().default(''),\n termsAccepted: z.literal(true),\n marketingConsent: z.boolean().optional().default(false),\n sendCopy: z.boolean().optional().default(true),\n})\n\nexport async function POST(req: Request) {\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: demoFeedbackIpRateLimitConfig,\n })\n if (rateLimitError) return rateLimitError\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 parsed = feedbackSchema.safeParse(payload)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Please check the form and try again.' }, { status: 400 })\n }\n\n const { email, message, marketingConsent, sendCopy } = parsed.data\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n\n const marketingText = marketingConsent ? 'Marketing consent: Yes' : 'Marketing consent: No'\n\n const adminCopy = {\n preview: `Demo feedback from ${email}`,\n heading: 'New demo feedback',\n body: `${email} submitted a feedback/contact request from the demo environment.`,\n senderEmailLabel: 'From email:',\n senderEmail: email,\n messageLabel: 'Message:',\n message: message || '(no message provided)',\n marketingConsent: marketingText,\n footer: 'Open Mercato \\u00b7 Demo feedback',\n }\n\n try {\n await sendEmail({\n to: adminEmail,\n subject: `Demo feedback from ${email}`,\n react: FeedbackEmail({ copy: adminCopy }),\n })\n } catch (err) {\n logger.error('Admin email failed', { err })\n return NextResponse.json({ ok: false, error: 'Failed to send feedback. Please try again.' }, { status: 502 })\n }\n\n if (sendCopy) {\n const userCopy = {\n preview: 'Your feedback to Open Mercato',\n heading: 'Thank you for reaching out!',\n body: 'We received your message and will get back to you shortly. Below is a copy of what you submitted.',\n messageLabel: 'Your message:',\n message: message || '(no message provided)',\n marketingConsent: marketingText,\n footer: 'Open Mercato \\u00b7 demo.openmercato.com',\n }\n try {\n await sendEmail({\n to: email,\n subject: 'Your feedback to Open Mercato',\n react: FeedbackEmail({ copy: userCopy }),\n })\n } catch (err) {\n logger.error('User copy email failed', { err })\n }\n }\n\n return NextResponse.json({ ok: true })\n}\n\nexport default POST\n\nconst feedbackTag = 'Demo'\n\nconst feedbackPostDoc: OpenApiMethodDoc = {\n summary: 'Submit demo feedback',\n description: 'Sends a feedback/contact request from the demo environment to the admin and optionally to the user.',\n tags: [feedbackTag],\n requestBody: {\n contentType: 'application/json',\n schema: feedbackSchema,\n description: 'Feedback form payload.',\n },\n responses: [\n { status: 200, description: 'Feedback sent successfully.' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: feedbackTag,\n summary: 'Demo feedback submission',\n methods: {\n POST: feedbackPostDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { after, NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
5
|
import { assertAllowedAppOrigin, mapSecurityEmailUrlError } from "@open-mercato/shared/lib/url";
|
|
5
6
|
import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/lib/service";
|
|
6
7
|
import { sendWorkspaceReadyEmail } from "@open-mercato/onboarding/modules/onboarding/lib/ready-email";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
runDeferredProvisioning
|
|
10
11
|
} from "@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning";
|
|
11
12
|
import { isPreparationClaimActive } from "@open-mercato/onboarding/modules/onboarding/lib/preparation-claim";
|
|
13
|
+
const logger = createLogger("onboarding").child({ component: "status" });
|
|
12
14
|
const metadata = {
|
|
13
15
|
path: "/onboarding/onboarding/status",
|
|
14
16
|
GET: {
|
|
@@ -87,11 +89,11 @@ async function GET(req) {
|
|
|
87
89
|
requestId: request.id,
|
|
88
90
|
tenantId: readyTenantId
|
|
89
91
|
}).catch((error) => {
|
|
90
|
-
|
|
92
|
+
logger.error("Ready email retry failed", {
|
|
91
93
|
requestId: request.id,
|
|
92
94
|
tenantId: readyTenantId,
|
|
93
95
|
organizationId: request.organizationId,
|
|
94
|
-
error
|
|
96
|
+
err: error
|
|
95
97
|
});
|
|
96
98
|
});
|
|
97
99
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/onboarding/api/get/onboarding/status.ts"],
|
|
4
|
-
"sourcesContent": ["import { after, 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 { assertAllowedAppOrigin, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport {\n resolveProvisioningIds,\n runDeferredProvisioning,\n} from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'\nimport { isPreparationClaimActive } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nexport const metadata = {\n path: '/onboarding/onboarding/status',\n GET: {\n requireAuth: false,\n },\n}\n\nconst ONBOARDING_LOGIN_TENANT_COOKIE = 'om_login_tenant'\n\nconst onboardingStatusQuerySchema = z.object({\n tenantId: z.string().uuid(),\n})\n\nfunction readCookie(req: Request, name: string): string | null {\n const header = req.headers.get('cookie')\n if (!header) return null\n for (const part of header.split(';')) {\n const separatorIndex = part.indexOf('=')\n if (separatorIndex === -1) continue\n const key = part.slice(0, separatorIndex).trim()\n if (key !== name) continue\n const rawValue = part.slice(separatorIndex + 1).trim()\n try {\n return decodeURIComponent(rawValue)\n } catch {\n return rawValue\n }\n }\n return null\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const tenantId = url.searchParams.get('tenantId') || url.searchParams.get('tenant') || ''\n const parsed = onboardingStatusQuerySchema.safeParse({ tenantId })\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Invalid tenant id.' }, { status: 400 })\n }\n\n const loginTenantCookie = readCookie(req, ONBOARDING_LOGIN_TENANT_COOKIE)\n if (!loginTenantCookie || loginTenantCookie !== parsed.data.tenantId) {\n return NextResponse.json({ ok: false, error: 'Not authorized for this tenant.' }, { status: 403 })\n }\n\n try {\n assertAllowedAppOrigin(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'onboarding.status',\n configMessage: 'Onboarding status is not configured.',\n })\n if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })\n throw error\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n const request = await service.findLatestByTenantId(parsed.data.tenantId)\n if (!request) {\n return NextResponse.json({ ok: false, error: 'Onboarding request not found.' }, { status: 404 })\n }\n\n const provisioningIds = resolveProvisioningIds(request)\n if (provisioningIds && request.status === 'processing') {\n await service.markCompleted(request, provisioningIds)\n }\n // Schedule deferred provisioning only while no runner holds a fresh claim \u2014\n // otherwise every ~1s poll piles another full seed + reindex chain onto the\n // connection pool. The atomic claim inside runDeferredProvisioning remains\n // the authoritative gate; this check just keeps polls cheap.\n if (\n provisioningIds &&\n request.status === 'completed' &&\n !request.preparationCompletedAt &&\n !isPreparationClaimActive(request.preparationStartedAt)\n ) {\n after(async () => {\n await runDeferredProvisioning({\n requestId: request.id,\n tenantId: provisioningIds.tenantId,\n organizationId: provisioningIds.organizationId,\n })\n })\n }\n\n const emailSent = Boolean(request.readyEmailSentAt)\n const ready = request.status === 'completed' && Boolean(request.preparationCompletedAt)\n const loginUrl = ready && request.tenantId ? `/login?tenant=${encodeURIComponent(request.tenantId)}` : null\n\n if (ready && request.tenantId && !request.readyEmailSentAt) {\n const readyTenantId = request.tenantId\n after(async () => {\n await sendWorkspaceReadyEmail({\n requestId: request.id,\n tenantId: readyTenantId,\n }).catch((error) => {\n
|
|
5
|
-
"mappings": "AAAA,SAAS,OAAO,oBAAoB;AACpC,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,wBAAwB,gCAAgC;AACjE,SAAS,yBAAyB;AAClC,SAAS,+BAA+B;AACxC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,gCAAgC;
|
|
4
|
+
"sourcesContent": ["import { after, 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 { assertAllowedAppOrigin, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport {\n resolveProvisioningIds,\n runDeferredProvisioning,\n} from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'\nimport { isPreparationClaimActive } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nconst logger = createLogger('onboarding').child({ component: 'status' })\n\nexport const metadata = {\n path: '/onboarding/onboarding/status',\n GET: {\n requireAuth: false,\n },\n}\n\nconst ONBOARDING_LOGIN_TENANT_COOKIE = 'om_login_tenant'\n\nconst onboardingStatusQuerySchema = z.object({\n tenantId: z.string().uuid(),\n})\n\nfunction readCookie(req: Request, name: string): string | null {\n const header = req.headers.get('cookie')\n if (!header) return null\n for (const part of header.split(';')) {\n const separatorIndex = part.indexOf('=')\n if (separatorIndex === -1) continue\n const key = part.slice(0, separatorIndex).trim()\n if (key !== name) continue\n const rawValue = part.slice(separatorIndex + 1).trim()\n try {\n return decodeURIComponent(rawValue)\n } catch {\n return rawValue\n }\n }\n return null\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const tenantId = url.searchParams.get('tenantId') || url.searchParams.get('tenant') || ''\n const parsed = onboardingStatusQuerySchema.safeParse({ tenantId })\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Invalid tenant id.' }, { status: 400 })\n }\n\n const loginTenantCookie = readCookie(req, ONBOARDING_LOGIN_TENANT_COOKIE)\n if (!loginTenantCookie || loginTenantCookie !== parsed.data.tenantId) {\n return NextResponse.json({ ok: false, error: 'Not authorized for this tenant.' }, { status: 403 })\n }\n\n try {\n assertAllowedAppOrigin(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'onboarding.status',\n configMessage: 'Onboarding status is not configured.',\n })\n if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })\n throw error\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n const request = await service.findLatestByTenantId(parsed.data.tenantId)\n if (!request) {\n return NextResponse.json({ ok: false, error: 'Onboarding request not found.' }, { status: 404 })\n }\n\n const provisioningIds = resolveProvisioningIds(request)\n if (provisioningIds && request.status === 'processing') {\n await service.markCompleted(request, provisioningIds)\n }\n // Schedule deferred provisioning only while no runner holds a fresh claim \u2014\n // otherwise every ~1s poll piles another full seed + reindex chain onto the\n // connection pool. The atomic claim inside runDeferredProvisioning remains\n // the authoritative gate; this check just keeps polls cheap.\n if (\n provisioningIds &&\n request.status === 'completed' &&\n !request.preparationCompletedAt &&\n !isPreparationClaimActive(request.preparationStartedAt)\n ) {\n after(async () => {\n await runDeferredProvisioning({\n requestId: request.id,\n tenantId: provisioningIds.tenantId,\n organizationId: provisioningIds.organizationId,\n })\n })\n }\n\n const emailSent = Boolean(request.readyEmailSentAt)\n const ready = request.status === 'completed' && Boolean(request.preparationCompletedAt)\n const loginUrl = ready && request.tenantId ? `/login?tenant=${encodeURIComponent(request.tenantId)}` : null\n\n if (ready && request.tenantId && !request.readyEmailSentAt) {\n const readyTenantId = request.tenantId\n after(async () => {\n await sendWorkspaceReadyEmail({\n requestId: request.id,\n tenantId: readyTenantId,\n }).catch((error) => {\n logger.error('Ready email retry failed', {\n requestId: request.id,\n tenantId: readyTenantId,\n organizationId: request.organizationId,\n err: error,\n })\n })\n })\n }\n\n return NextResponse.json({\n ok: true,\n status: request.status,\n ready,\n emailSent,\n tenantId: request.tenantId ?? parsed.data.tenantId,\n loginUrl,\n })\n}\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingStatusSuccessSchema = z.object({\n ok: z.literal(true),\n status: z.enum(['pending', 'processing', 'completed', 'expired']),\n ready: z.boolean(),\n emailSent: z.boolean(),\n tenantId: z.string().uuid(),\n loginUrl: z.string().nullable(),\n})\n\nconst onboardingStatusErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nconst onboardingStatusDoc: OpenApiMethodDoc = {\n summary: 'Get onboarding preparation status',\n description: 'Resolves whether a tenant workspace finished deferred onboarding preparation and can be opened.',\n tags: [onboardingTag],\n query: onboardingStatusQuerySchema,\n responses: [\n { status: 200, description: 'Onboarding status resolved.', schema: onboardingStatusSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid tenant id or request origin.', schema: onboardingStatusErrorSchema },\n { status: 403, description: 'Caller is not authorized for this tenant.', schema: onboardingStatusErrorSchema },\n { status: 404, description: 'Onboarding request not found.', schema: onboardingStatusErrorSchema },\n { status: 500, description: 'Onboarding status is not configured.', schema: onboardingStatusErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Onboarding preparation status',\n methods: {\n GET: onboardingStatusDoc,\n },\n}\n\nexport default GET\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -21,6 +21,8 @@ import { resolveConsentClientIp } from "@open-mercato/onboarding/modules/onboard
|
|
|
21
21
|
import { runBestEffortProvisioningStep } from "@open-mercato/onboarding/modules/onboarding/lib/provisioning";
|
|
22
22
|
import { getModules } from "@open-mercato/shared/lib/modules/registry";
|
|
23
23
|
import { isUniqueViolation } from "@open-mercato/shared/lib/crud/errors";
|
|
24
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
25
|
+
const logger = createLogger("onboarding").child({ component: "verify" });
|
|
24
26
|
const metadata = {
|
|
25
27
|
path: "/onboarding/onboarding/verify",
|
|
26
28
|
GET: {
|
|
@@ -35,7 +37,7 @@ function createTimeoutPromise(label, timeoutMs) {
|
|
|
35
37
|
}
|
|
36
38
|
async function runModuleSetupHook(args) {
|
|
37
39
|
const startedAt = Date.now();
|
|
38
|
-
|
|
40
|
+
logger.info("Module hook started", {
|
|
39
41
|
moduleId: args.moduleId,
|
|
40
42
|
phase: args.phase,
|
|
41
43
|
timeoutMs: args.timeoutMs
|
|
@@ -45,25 +47,25 @@ async function runModuleSetupHook(args) {
|
|
|
45
47
|
args.run(),
|
|
46
48
|
createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs)
|
|
47
49
|
]);
|
|
48
|
-
|
|
50
|
+
logger.info("Module hook completed", {
|
|
49
51
|
moduleId: args.moduleId,
|
|
50
52
|
phase: args.phase,
|
|
51
53
|
durationMs: Math.max(0, Date.now() - startedAt)
|
|
52
54
|
});
|
|
53
55
|
} catch (error) {
|
|
54
56
|
if (isUniqueViolation(error)) {
|
|
55
|
-
|
|
57
|
+
logger.info("Module hook skipped (already seeded)", {
|
|
56
58
|
moduleId: args.moduleId,
|
|
57
59
|
phase: args.phase,
|
|
58
60
|
durationMs: Math.max(0, Date.now() - startedAt)
|
|
59
61
|
});
|
|
60
62
|
} else {
|
|
61
|
-
|
|
63
|
+
logger.error("Module hook failed", {
|
|
62
64
|
moduleId: args.moduleId,
|
|
63
65
|
phase: args.phase,
|
|
64
66
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
65
67
|
timeoutMs: args.timeoutMs,
|
|
66
|
-
error
|
|
68
|
+
err: error
|
|
67
69
|
});
|
|
68
70
|
}
|
|
69
71
|
throw error;
|
|
@@ -120,11 +122,11 @@ async function GET(req) {
|
|
|
120
122
|
requestId: request.id,
|
|
121
123
|
tenantId: request.tenantId
|
|
122
124
|
}).catch((error) => {
|
|
123
|
-
|
|
125
|
+
logger.error("Retry ready email failed", {
|
|
124
126
|
requestId: request.id,
|
|
125
127
|
tenantId: request.tenantId,
|
|
126
128
|
organizationId: request.organizationId,
|
|
127
|
-
error
|
|
129
|
+
err: error
|
|
128
130
|
});
|
|
129
131
|
});
|
|
130
132
|
});
|
|
@@ -161,7 +163,7 @@ async function GET(req) {
|
|
|
161
163
|
return redirectToPreparing(baseUrl, current?.tenantId ?? request.tenantId ?? null);
|
|
162
164
|
}
|
|
163
165
|
if (!request.passwordHash) {
|
|
164
|
-
|
|
166
|
+
logger.error("Missing password hash for request", { requestId: request.id });
|
|
165
167
|
await service.resetProcessing(request);
|
|
166
168
|
return redirectWithStatus(baseUrl, "error");
|
|
167
169
|
}
|
|
@@ -265,7 +267,7 @@ async function GET(req) {
|
|
|
265
267
|
await service.resetProcessing(request);
|
|
266
268
|
return redirectWithStatus(baseUrl, "already_exists");
|
|
267
269
|
}
|
|
268
|
-
|
|
270
|
+
logger.error("Verification failed", { err: error });
|
|
269
271
|
await service.resetProcessing(request);
|
|
270
272
|
return redirectWithStatus(baseUrl, "error");
|
|
271
273
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/onboarding/api/get/onboarding/verify.ts"],
|
|
4
|
-
"sourcesContent": ["import { after, 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 { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport {\n redirectToLogin,\n redirectToPreparing,\n redirectWithStatus,\n} from '@open-mercato/onboarding/modules/onboarding/lib/verify-redirects'\nimport { resolveVerifyRedirectBaseUrl } from '@open-mercato/onboarding/modules/onboarding/lib/verify-base-url'\nimport {\n resolveProvisioningIds,\n runDeferredProvisioning,\n} from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'\nimport { setupInitialTenant } from '@open-mercato/core/modules/auth/lib/setup-app'\nimport { UserConsent } from '@open-mercato/core/modules/auth/data/entities'\nimport { computeConsentIntegrityHash } from '@open-mercato/core/modules/auth/lib/consentIntegrity'\nimport { resolveConsentClientIp } from '@open-mercato/onboarding/modules/onboarding/lib/consentClientIp'\nimport { runBestEffortProvisioningStep } from '@open-mercato/onboarding/modules/onboarding/lib/provisioning'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nexport const metadata = {\n path: '/onboarding/onboarding/verify',\n GET: {\n requireAuth: false,\n },\n}\n\nconst SEED_DEFAULTS_TIMEOUT_MS = 15_000\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedDefaults' | 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n console.info('[onboarding.verify] module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n console.info('[onboarding.verify] module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n // A concurrent verify (or a re-verify after the request was already\n // provisioned) re-runs seedDefaults against rows that exist. seed hooks\n // are not fully idempotent, so the collision is expected and harmless \u2014\n // the workspace already exists. Log at info so real failures stand out.\n console.info('[onboarding.verify] module hook skipped (already seeded)', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } else {\n console.error('[onboarding.verify] module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n error,\n })\n }\n throw error\n }\n}\n\nasync function completeProvisionedRequest(args: {\n service: OnboardingService\n request: OnboardingRequest\n}) {\n const ids = resolveProvisioningIds(args.request)\n if (!ids) return null\n await args.service.markCompleted(args.request, ids)\n after(async () => {\n await runDeferredProvisioning({\n requestId: args.request.id,\n tenantId: ids.tenantId,\n organizationId: ids.organizationId,\n })\n })\n return ids\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrlResult = resolveVerifyRedirectBaseUrl(req)\n if (!baseUrlResult.ok) {\n if (baseUrlResult.redirectOrigin) {\n return redirectWithStatus(baseUrlResult.redirectOrigin, baseUrlResult.status)\n }\n return NextResponse.json(\n { ok: false, error: baseUrlResult.message },\n { status: baseUrlResult.httpStatus },\n )\n }\n const baseUrl = baseUrlResult.baseUrl\n const token = url.searchParams.get('token') ?? ''\n const parsed = onboardingVerifySchema.safeParse({ token })\n if (!parsed.success) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n const service = new OnboardingService(em)\n const request = await service.findByToken(parsed.data.token)\n if (!request) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.expiresAt <= new Date() && request.status !== 'completed') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.status === 'completed' && request.tenantId) {\n if (!request.preparationCompletedAt) {\n return redirectToPreparing(baseUrl, request.tenantId)\n }\n if (!request.readyEmailSentAt) {\n after(async () => {\n await sendWorkspaceReadyEmail({\n requestId: request.id,\n tenantId: request.tenantId!,\n }).catch((error) => {\n console.error('[onboarding.verify] retry ready email failed', {\n requestId: request.id,\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n error,\n })\n })\n })\n }\n return redirectToLogin(baseUrl, request.tenantId)\n }\n const lockWindowMs = 15 * 60 * 1000\n const processingStartedAt = request.processingStartedAt?.getTime() ?? 0\n const processingFresh = request.status === 'processing' && processingStartedAt > Date.now() - lockWindowMs\n if (processingFresh) {\n const recovered = await completeProvisionedRequest({ service, request })\n if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)\n return redirectToPreparing(baseUrl, request.tenantId ?? null)\n }\n if (request.status === 'processing' && !processingFresh) {\n const recovered = await completeProvisionedRequest({ service, request })\n if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)\n await service.resetProcessing(request)\n }\n if (request.status !== 'pending') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n const claimed = await service.startProcessing(request, new Date())\n if (!claimed) {\n // A concurrent verify request already claimed this token (pending \u2192 processing).\n // Re-read on a fresh fork \u2014 the request EM's identity map still holds the stale\n // pre-claim copy \u2014 and route off the winner's committed state instead of re-running\n // provisioning, which would throw USER_EXISTS and could strand the request (#2742).\n const currentService = new OnboardingService(em.fork())\n const current = await currentService.findById(request.id)\n if (current?.status === 'completed' && current.tenantId) {\n return current.preparationCompletedAt\n ? redirectToLogin(baseUrl, current.tenantId)\n : redirectToPreparing(baseUrl, current.tenantId)\n }\n if (current?.status === 'processing') {\n const recovered = await completeProvisionedRequest({ service: currentService, request: current })\n if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)\n }\n return redirectToPreparing(baseUrl, current?.tenantId ?? request.tenantId ?? null)\n }\n if (!request.passwordHash) {\n console.error('[onboarding.verify] missing password hash for request', request.id)\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n\n let tenantId: string | null = null\n let organizationId: string | null = null\n let userId: string | null = null\n\n try {\n const setupResult = await setupInitialTenant(em, {\n orgName: request.organizationName,\n includeDerivedUsers: false,\n failIfUserExists: true,\n primaryUserRoles: ['admin'],\n includeSuperadminRole: false,\n primaryUser: {\n email: request.email,\n firstName: request.firstName,\n lastName: request.lastName,\n displayName: `${request.firstName} ${request.lastName}`.trim(),\n hashedPassword: request.passwordHash,\n confirm: true,\n },\n modules: getModules(),\n })\n\n const resolvedTenantId = String(setupResult.tenantId)\n const resolvedOrganizationId = String(setupResult.organizationId)\n tenantId = resolvedTenantId\n organizationId = resolvedOrganizationId\n\n const mainUserSnapshot = setupResult.users.find((entry) => entry.user.email === request.email)\n if (!mainUserSnapshot) throw new Error('USER_NOT_CREATED')\n const user = mainUserSnapshot.user\n const resolvedUserId = String(user.id)\n userId = resolvedUserId\n await service.updateProvisioningIds(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n\n if (request.marketingConsent) {\n const now = new Date()\n const clientIp = resolveConsentClientIp(req)\n const integrityHash = computeConsentIntegrityHash({\n userId: resolvedUserId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n ipAddress: clientIp,\n source: 'onboarding',\n })\n // Persist the marketing consent on an isolated EM fork wrapped in a\n // transaction so it commits all-or-nothing AND a failure here can neither\n // abort provisioning nor poison the request EM's unit of work before\n // markCompleted runs. Recording the consent is best-effort: the workspace\n // is already provisioned, and a lost consent record fails safe (treated as\n // not granted) and is logged for follow-up rather than stranding the user.\n await runBestEffortProvisioningStep('marketing-consent', () =>\n em.fork().transactional(async (txEm) => {\n txEm.create(UserConsent, {\n userId: resolvedUserId,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n source: 'onboarding',\n ipAddress: clientIp,\n integrityHash,\n createdAt: now,\n })\n }),\n )\n }\n\n // Call module seedDefaults hooks. Each hook is best-effort and runs on its\n // own isolated EM fork: a single module's throw or 15s timeout must not\n // strand the freshly provisioned workspace \u2014 the tenant/org/user already\n // exist and the request must still reach markCompleted so the user can sign\n // in. A per-module fork also keeps a failed module's unflushed unit of work\n // from leaking into (or aborting) the next module's flush. Failures are\n // logged for follow-up (deferred seedExamples is already non-fatal in the\n // same way).\n const modules = getModules()\n for (const mod of modules) {\n if (!mod.setup?.seedDefaults) continue\n const seedEm = em.fork()\n await runBestEffortProvisioningStep(`seedDefaults:${mod.id}`, () =>\n runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedDefaults',\n timeoutMs: SEED_DEFAULTS_TIMEOUT_MS,\n run: () => mod.setup!.seedDefaults!({\n em: seedEm,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n container,\n }),\n }),\n )\n }\n await service.markCompleted(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n // TODO: Move deferred provisioning into a durable job keyed by request id so process restarts can resume\n // seedExamples/index rebuild/email dispatch instead of leaving completed requests stuck on preparing.\n after(async () => {\n await runDeferredProvisioning({\n requestId: request.id,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n })\n })\n return redirectToPreparing(baseUrl, resolvedTenantId)\n } catch (error) {\n if (error instanceof Error && error.message === 'USER_EXISTS') {\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'already_exists')\n }\n console.error('[onboarding.verify] failed', error)\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n}\n\nexport default GET\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingVerifyQuerySchema = z.object({\n token: onboardingVerifySchema.shape.token,\n})\n\nconst onboardingVerifyDoc: OpenApiMethodDoc = {\n summary: 'Verify onboarding token',\n description: 'Validates the onboarding token, provisions the tenant, seeds demo data, and redirects the user to the login screen.',\n tags: [onboardingTag],\n query: onboardingVerifyQuerySchema,\n responses: [\n { status: 302, description: 'Redirect to onboarding UI or login' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Onboarding verification redirect',\n methods: {\n GET: onboardingVerifyDoc,\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,OAAO,oBAAoB;AACpC,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AAEvC,SAAS,yBAAyB;AAClC,SAAS,+BAA+B;AACxC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oCAAoC;AAC7C;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,0BAA0B;AACnC,SAAS,mBAAmB;AAC5B,SAAS,mCAAmC;AAC5C,SAAS,8BAA8B;AACvC,SAAS,qCAAqC;AAC9C,SAAS,kBAAkB;AAC3B,SAAS,yBAAyB;
|
|
4
|
+
"sourcesContent": ["import { after, 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 { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport {\n redirectToLogin,\n redirectToPreparing,\n redirectWithStatus,\n} from '@open-mercato/onboarding/modules/onboarding/lib/verify-redirects'\nimport { resolveVerifyRedirectBaseUrl } from '@open-mercato/onboarding/modules/onboarding/lib/verify-base-url'\nimport {\n resolveProvisioningIds,\n runDeferredProvisioning,\n} from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'\nimport { setupInitialTenant } from '@open-mercato/core/modules/auth/lib/setup-app'\nimport { UserConsent } from '@open-mercato/core/modules/auth/data/entities'\nimport { computeConsentIntegrityHash } from '@open-mercato/core/modules/auth/lib/consentIntegrity'\nimport { resolveConsentClientIp } from '@open-mercato/onboarding/modules/onboarding/lib/consentClientIp'\nimport { runBestEffortProvisioningStep } from '@open-mercato/onboarding/modules/onboarding/lib/provisioning'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('onboarding').child({ component: 'verify' })\n\nexport const metadata = {\n path: '/onboarding/onboarding/verify',\n GET: {\n requireAuth: false,\n },\n}\n\nconst SEED_DEFAULTS_TIMEOUT_MS = 15_000\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedDefaults' | 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n logger.info('Module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n logger.info('Module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n // A concurrent verify (or a re-verify after the request was already\n // provisioned) re-runs seedDefaults against rows that exist. seed hooks\n // are not fully idempotent, so the collision is expected and harmless \u2014\n // the workspace already exists. Log at info so real failures stand out.\n logger.info('Module hook skipped (already seeded)', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } else {\n logger.error('Module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n err: error,\n })\n }\n throw error\n }\n}\n\nasync function completeProvisionedRequest(args: {\n service: OnboardingService\n request: OnboardingRequest\n}) {\n const ids = resolveProvisioningIds(args.request)\n if (!ids) return null\n await args.service.markCompleted(args.request, ids)\n after(async () => {\n await runDeferredProvisioning({\n requestId: args.request.id,\n tenantId: ids.tenantId,\n organizationId: ids.organizationId,\n })\n })\n return ids\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrlResult = resolveVerifyRedirectBaseUrl(req)\n if (!baseUrlResult.ok) {\n if (baseUrlResult.redirectOrigin) {\n return redirectWithStatus(baseUrlResult.redirectOrigin, baseUrlResult.status)\n }\n return NextResponse.json(\n { ok: false, error: baseUrlResult.message },\n { status: baseUrlResult.httpStatus },\n )\n }\n const baseUrl = baseUrlResult.baseUrl\n const token = url.searchParams.get('token') ?? ''\n const parsed = onboardingVerifySchema.safeParse({ token })\n if (!parsed.success) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n const service = new OnboardingService(em)\n const request = await service.findByToken(parsed.data.token)\n if (!request) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.expiresAt <= new Date() && request.status !== 'completed') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.status === 'completed' && request.tenantId) {\n if (!request.preparationCompletedAt) {\n return redirectToPreparing(baseUrl, request.tenantId)\n }\n if (!request.readyEmailSentAt) {\n after(async () => {\n await sendWorkspaceReadyEmail({\n requestId: request.id,\n tenantId: request.tenantId!,\n }).catch((error) => {\n logger.error('Retry ready email failed', {\n requestId: request.id,\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n err: error,\n })\n })\n })\n }\n return redirectToLogin(baseUrl, request.tenantId)\n }\n const lockWindowMs = 15 * 60 * 1000\n const processingStartedAt = request.processingStartedAt?.getTime() ?? 0\n const processingFresh = request.status === 'processing' && processingStartedAt > Date.now() - lockWindowMs\n if (processingFresh) {\n const recovered = await completeProvisionedRequest({ service, request })\n if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)\n return redirectToPreparing(baseUrl, request.tenantId ?? null)\n }\n if (request.status === 'processing' && !processingFresh) {\n const recovered = await completeProvisionedRequest({ service, request })\n if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)\n await service.resetProcessing(request)\n }\n if (request.status !== 'pending') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n const claimed = await service.startProcessing(request, new Date())\n if (!claimed) {\n // A concurrent verify request already claimed this token (pending \u2192 processing).\n // Re-read on a fresh fork \u2014 the request EM's identity map still holds the stale\n // pre-claim copy \u2014 and route off the winner's committed state instead of re-running\n // provisioning, which would throw USER_EXISTS and could strand the request (#2742).\n const currentService = new OnboardingService(em.fork())\n const current = await currentService.findById(request.id)\n if (current?.status === 'completed' && current.tenantId) {\n return current.preparationCompletedAt\n ? redirectToLogin(baseUrl, current.tenantId)\n : redirectToPreparing(baseUrl, current.tenantId)\n }\n if (current?.status === 'processing') {\n const recovered = await completeProvisionedRequest({ service: currentService, request: current })\n if (recovered) return redirectToPreparing(baseUrl, recovered.tenantId)\n }\n return redirectToPreparing(baseUrl, current?.tenantId ?? request.tenantId ?? null)\n }\n if (!request.passwordHash) {\n logger.error('Missing password hash for request', { requestId: request.id })\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n\n let tenantId: string | null = null\n let organizationId: string | null = null\n let userId: string | null = null\n\n try {\n const setupResult = await setupInitialTenant(em, {\n orgName: request.organizationName,\n includeDerivedUsers: false,\n failIfUserExists: true,\n primaryUserRoles: ['admin'],\n includeSuperadminRole: false,\n primaryUser: {\n email: request.email,\n firstName: request.firstName,\n lastName: request.lastName,\n displayName: `${request.firstName} ${request.lastName}`.trim(),\n hashedPassword: request.passwordHash,\n confirm: true,\n },\n modules: getModules(),\n })\n\n const resolvedTenantId = String(setupResult.tenantId)\n const resolvedOrganizationId = String(setupResult.organizationId)\n tenantId = resolvedTenantId\n organizationId = resolvedOrganizationId\n\n const mainUserSnapshot = setupResult.users.find((entry) => entry.user.email === request.email)\n if (!mainUserSnapshot) throw new Error('USER_NOT_CREATED')\n const user = mainUserSnapshot.user\n const resolvedUserId = String(user.id)\n userId = resolvedUserId\n await service.updateProvisioningIds(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n\n if (request.marketingConsent) {\n const now = new Date()\n const clientIp = resolveConsentClientIp(req)\n const integrityHash = computeConsentIntegrityHash({\n userId: resolvedUserId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n ipAddress: clientIp,\n source: 'onboarding',\n })\n // Persist the marketing consent on an isolated EM fork wrapped in a\n // transaction so it commits all-or-nothing AND a failure here can neither\n // abort provisioning nor poison the request EM's unit of work before\n // markCompleted runs. Recording the consent is best-effort: the workspace\n // is already provisioned, and a lost consent record fails safe (treated as\n // not granted) and is logged for follow-up rather than stranding the user.\n await runBestEffortProvisioningStep('marketing-consent', () =>\n em.fork().transactional(async (txEm) => {\n txEm.create(UserConsent, {\n userId: resolvedUserId,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n source: 'onboarding',\n ipAddress: clientIp,\n integrityHash,\n createdAt: now,\n })\n }),\n )\n }\n\n // Call module seedDefaults hooks. Each hook is best-effort and runs on its\n // own isolated EM fork: a single module's throw or 15s timeout must not\n // strand the freshly provisioned workspace \u2014 the tenant/org/user already\n // exist and the request must still reach markCompleted so the user can sign\n // in. A per-module fork also keeps a failed module's unflushed unit of work\n // from leaking into (or aborting) the next module's flush. Failures are\n // logged for follow-up (deferred seedExamples is already non-fatal in the\n // same way).\n const modules = getModules()\n for (const mod of modules) {\n if (!mod.setup?.seedDefaults) continue\n const seedEm = em.fork()\n await runBestEffortProvisioningStep(`seedDefaults:${mod.id}`, () =>\n runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedDefaults',\n timeoutMs: SEED_DEFAULTS_TIMEOUT_MS,\n run: () => mod.setup!.seedDefaults!({\n em: seedEm,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n container,\n }),\n }),\n )\n }\n await service.markCompleted(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n // TODO: Move deferred provisioning into a durable job keyed by request id so process restarts can resume\n // seedExamples/index rebuild/email dispatch instead of leaving completed requests stuck on preparing.\n after(async () => {\n await runDeferredProvisioning({\n requestId: request.id,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n })\n })\n return redirectToPreparing(baseUrl, resolvedTenantId)\n } catch (error) {\n if (error instanceof Error && error.message === 'USER_EXISTS') {\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'already_exists')\n }\n logger.error('Verification failed', { err: error })\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n}\n\nexport default GET\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingVerifyQuerySchema = z.object({\n token: onboardingVerifySchema.shape.token,\n})\n\nconst onboardingVerifyDoc: OpenApiMethodDoc = {\n summary: 'Verify onboarding token',\n description: 'Validates the onboarding token, provisions the tenant, seeds demo data, and redirects the user to the login screen.',\n tags: [onboardingTag],\n query: onboardingVerifyQuerySchema,\n responses: [\n { status: 302, description: 'Redirect to onboarding UI or login' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Onboarding verification redirect',\n methods: {\n GET: onboardingVerifyDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
5
|
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from "@open-mercato/shared/lib/url";
|
|
5
6
|
import { loadDictionary } from "@open-mercato/shared/lib/i18n/server";
|
|
6
7
|
import { defaultLocale, locales } from "@open-mercato/shared/lib/i18n/config";
|
|
@@ -15,6 +16,7 @@ import { formatPasswordRequirements, getPasswordPolicy } from "@open-mercato/sha
|
|
|
15
16
|
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
16
17
|
import { readEndpointRateLimitConfig } from "@open-mercato/shared/lib/ratelimit/config";
|
|
17
18
|
import { rateLimitErrorSchema } from "@open-mercato/shared/lib/ratelimit/helpers";
|
|
19
|
+
const logger = createLogger("onboarding").child({ component: "start" });
|
|
18
20
|
const metadata = {
|
|
19
21
|
path: "/onboarding/onboarding",
|
|
20
22
|
POST: {
|
|
@@ -157,7 +159,7 @@ async function POST(req) {
|
|
|
157
159
|
} catch (err) {
|
|
158
160
|
request.lastEmailSentAt = null;
|
|
159
161
|
await em.flush();
|
|
160
|
-
|
|
162
|
+
logger.error("Verification email failed", { err });
|
|
161
163
|
return NextResponse.json({
|
|
162
164
|
ok: false,
|
|
163
165
|
error: translate(
|
|
@@ -187,11 +189,11 @@ async function POST(req) {
|
|
|
187
189
|
react: AdminNotificationEmail({ copy: adminCopy })
|
|
188
190
|
});
|
|
189
191
|
} catch (err) {
|
|
190
|
-
|
|
192
|
+
logger.error("Admin email failed", { err });
|
|
191
193
|
}
|
|
192
194
|
return NextResponse.json({ ok: true, email: request.email });
|
|
193
195
|
} catch (error) {
|
|
194
|
-
|
|
196
|
+
logger.error("Onboarding start failed", { err: error });
|
|
195
197
|
return NextResponse.json({
|
|
196
198
|
ok: false,
|
|
197
199
|
error: translate("onboarding.form.genericError", "Something went wrong. Please try again later.")
|
|
@@ -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 { 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\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 console.error('[onboarding.start] 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 console.error('[onboarding.start] admin email failed', err)\n }\n\n return NextResponse.json({ ok: true, email: request.email })\n } catch (error) {\n console.error('[onboarding.start] failed', 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,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;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { loadDictionary } from '@open-mercato/shared/lib/i18n/server'\nimport { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { onboardingStartSchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport VerificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail'\nimport AdminNotificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\n\nconst logger = createLogger('onboarding').child({ component: 'start' })\n\nexport const metadata = {\n path: '/onboarding/onboarding',\n POST: {\n requireAuth: false,\n rateLimit: readEndpointRateLimitConfig('ONBOARDING', {\n points: 10,\n duration: 60,\n blockDuration: 60,\n keyPrefix: 'onboarding',\n }),\n },\n}\n\nexport async function POST(req: Request) {\n if (parseBooleanToken(process.env.SELF_SERVICE_ONBOARDING_ENABLED ?? '') !== true) {\n return NextResponse.json({ ok: false, error: 'Self-service onboarding is disabled.' }, { status: 404 })\n }\n let payload: unknown\n try {\n payload = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid payload' }, { status: 400 })\n }\n\n const rawLocale =\n payload && typeof payload === 'object' && 'locale' in payload && typeof (payload as any).locale === 'string'\n ? (payload as any).locale as string\n : null\n const locale: Locale = rawLocale && locales.includes(rawLocale as Locale)\n ? (rawLocale as Locale)\n : defaultLocale\n const dict = await loadDictionary(locale)\n const translate = createFallbackTranslator(dict)\n const passwordRequirements = formatPasswordRequirements(\n getPasswordPolicy(),\n translate,\n 'onboarding.password.requirements',\n )\n\n const parsed = onboardingStartSchema.safeParse(payload)\n if (!parsed.success) {\n const fieldErrors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (!path) continue\n switch (path) {\n case 'email':\n fieldErrors.email = translate('onboarding.errors.emailInvalid', 'Enter a valid work email.')\n break\n case 'firstName':\n fieldErrors.firstName = translate('onboarding.errors.firstNameRequired', 'First name is required.')\n break\n case 'lastName':\n fieldErrors.lastName = translate('onboarding.errors.lastNameRequired', 'Last name is required.')\n break\n case 'organizationName':\n fieldErrors.organizationName = translate('onboarding.errors.organizationNameRequired', 'Organization name is required.')\n break\n case 'password':\n fieldErrors.password = translate(\n 'onboarding.errors.passwordRequired',\n 'Password must meet the requirements: {requirements}.',\n { requirements: passwordRequirements },\n )\n break\n case 'confirmPassword':\n fieldErrors.confirmPassword = translate('onboarding.errors.passwordMismatch', 'Passwords must match.')\n break\n case 'termsAccepted':\n fieldErrors.termsAccepted = translate('onboarding.form.termsRequired', 'Please accept the terms to continue.')\n break\n default:\n break\n }\n }\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Please check the form and try again.'),\n fieldErrors,\n }, { status: 400 })\n }\n\n try {\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n\n const existingUser = await em.findOne(User, { email: parsed.data.email })\n if (existingUser) {\n const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n\n const service = new OnboardingService(em)\n let request, token\n try {\n const result = await service.createOrUpdateRequest(parsed.data)\n request = result.request\n token = result.token\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('PENDING_REQUEST:')) {\n const minutes = Number(err.message.split(':')[1] || '10')\n const message = translate('onboarding.errors.pendingRequest', 'We already have a pending verification. Please try again in about {minutes} minutes or contact the administrator.', { minutes })\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n throw err\n }\n\n let baseUrl: string\n try {\n baseUrl = getSecurityEmailBaseUrl(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'onboarding.start',\n configMessage: 'Self-service onboarding is not configured.',\n })\n if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })\n throw error\n }\n const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`\n\n const firstName = request.firstName || parsed.data.firstName\n const hasMarketingConsent = request.marketingConsent === true\n const marketingConsentText = hasMarketingConsent\n ? translate('onboarding.email.marketingConsentYes', 'Marketing consent: Yes')\n : translate('onboarding.email.marketingConsentNo', 'Marketing consent: No')\n const subject = translate('onboarding.email.subject', 'Confirm your email to finish onboarding')\n const emailCopy = {\n preview: translate('onboarding.email.preview', 'Confirm your email to activate your Open Mercato workspace'),\n heading: translate('onboarding.email.heading', 'Welcome to Open Mercato'),\n greeting: translate('onboarding.email.greeting', 'Hi {firstName},', { firstName }),\n body: translate(\n 'onboarding.email.body',\n 'We just need to confirm your email address to finish setting up the organization {organizationName}.',\n { organizationName: request.organizationName },\n ),\n cta: translate('onboarding.email.cta', 'Confirm email & activate workspace'),\n expiry: translate(\n 'onboarding.email.expiry',\n \"The link will expire in 24 hours. If you didn't request this, you can safely ignore this message.\",\n ),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.footer', 'Open Mercato \u00B7 Onboarding service'),\n }\n const emailReact = VerificationEmail({ verifyUrl, copy: emailCopy })\n try {\n await sendEmail({ to: request.email, subject, react: emailReact })\n } catch (err) {\n request.lastEmailSentAt = null\n await em.flush()\n logger.error('Verification email failed', { err })\n return NextResponse.json({\n ok: false,\n error: translate(\n 'onboarding.errors.emailSendFailed',\n 'We could not send the verification email. Please try again or contact support.',\n ),\n }, { status: 502 })\n }\n\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n const adminSubject = translate('onboarding.email.adminSubject', 'New self-service onboarding request')\n const adminCopy = {\n preview: translate('onboarding.email.adminPreview', 'New onboarding request submitted'),\n heading: translate('onboarding.email.adminHeading', 'New onboarding request'),\n body: translate('onboarding.email.adminBody', '{firstName} {lastName} ({email}) submitted an onboarding request for {organizationName}.', {\n firstName: request.firstName,\n lastName: request.lastName,\n email: request.email,\n organizationName: request.organizationName,\n }),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.adminFooter', 'You can review the tenant after verification is complete.'),\n }\n try {\n await sendEmail({\n to: adminEmail,\n subject: adminSubject,\n react: AdminNotificationEmail({ copy: adminCopy }),\n })\n } catch (err) {\n logger.error('Admin email failed', { err })\n }\n\n return NextResponse.json({ ok: true, email: request.email })\n } catch (error) {\n logger.error('Onboarding start failed', { err: error })\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Something went wrong. Please try again later.'),\n }, { status: 500 })\n }\n}\n\nexport default POST\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingSuccessSchema = z.object({\n ok: z.literal(true),\n email: z.string().email(),\n})\n\nconst onboardingErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n fieldErrors: z.record(z.string(), z.string()).optional(),\n})\n\nconst onboardingPostDoc: OpenApiMethodDoc = {\n summary: 'Submit onboarding request',\n description: 'Accepts a self-service onboarding form submission and triggers email verification.',\n tags: [onboardingTag],\n requestBody: {\n contentType: 'application/json',\n schema: onboardingStartSchema,\n description: 'Onboarding form payload with contact and organization information.',\n },\n responses: [\n { status: 200, description: 'Onboarding request accepted.', schema: onboardingSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: onboardingErrorSchema },\n { status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },\n { status: 409, description: 'Existing account or pending request', schema: onboardingErrorSchema },\n { status: 429, description: 'Too many onboarding submissions from this IP', schema: rateLimitErrorSchema },\n { status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Self-service onboarding submission',\n methods: {\n POST: onboardingPostDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -6,6 +6,8 @@ import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/l
|
|
|
6
6
|
import { sendWorkspaceReadyEmail } from "@open-mercato/onboarding/modules/onboarding/lib/ready-email";
|
|
7
7
|
import { isUniqueViolation } from "@open-mercato/shared/lib/crud/errors";
|
|
8
8
|
import { PREPARATION_CLAIM_STALE_MS } from "@open-mercato/onboarding/modules/onboarding/lib/preparation-claim";
|
|
9
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
10
|
+
const logger = createLogger("onboarding").child({ component: "verify" });
|
|
9
11
|
const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5e3;
|
|
10
12
|
const SEED_EXAMPLES_TIMEOUT_MS = 15e3;
|
|
11
13
|
const PREPARATION_HEARTBEAT_MS = 5e3;
|
|
@@ -24,7 +26,7 @@ function createTimeoutPromise(label, timeoutMs) {
|
|
|
24
26
|
}
|
|
25
27
|
async function runModuleSetupHook(args) {
|
|
26
28
|
const startedAt = Date.now();
|
|
27
|
-
|
|
29
|
+
logger.info("Module hook started", {
|
|
28
30
|
moduleId: args.moduleId,
|
|
29
31
|
phase: args.phase,
|
|
30
32
|
timeoutMs: args.timeoutMs
|
|
@@ -34,25 +36,25 @@ async function runModuleSetupHook(args) {
|
|
|
34
36
|
args.run(),
|
|
35
37
|
createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs)
|
|
36
38
|
]);
|
|
37
|
-
|
|
39
|
+
logger.info("Module hook completed", {
|
|
38
40
|
moduleId: args.moduleId,
|
|
39
41
|
phase: args.phase,
|
|
40
42
|
durationMs: Math.max(0, Date.now() - startedAt)
|
|
41
43
|
});
|
|
42
44
|
} catch (error) {
|
|
43
45
|
if (isUniqueViolation(error)) {
|
|
44
|
-
|
|
46
|
+
logger.info("Module hook skipped (already seeded)", {
|
|
45
47
|
moduleId: args.moduleId,
|
|
46
48
|
phase: args.phase,
|
|
47
49
|
durationMs: Math.max(0, Date.now() - startedAt)
|
|
48
50
|
});
|
|
49
51
|
} else {
|
|
50
|
-
|
|
52
|
+
logger.error("Module hook failed", {
|
|
51
53
|
moduleId: args.moduleId,
|
|
52
54
|
phase: args.phase,
|
|
53
55
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
54
56
|
timeoutMs: args.timeoutMs,
|
|
55
|
-
error
|
|
57
|
+
err: error
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
throw error;
|
|
@@ -102,10 +104,10 @@ async function enqueueQueryIndexRebuild(args) {
|
|
|
102
104
|
{ persistent: true, deliverInline: false }
|
|
103
105
|
);
|
|
104
106
|
} catch (error) {
|
|
105
|
-
|
|
107
|
+
logger.error("Failed to enqueue query index rebuild", {
|
|
106
108
|
entityType,
|
|
107
109
|
tenantId: args.tenantId,
|
|
108
|
-
error
|
|
110
|
+
err: error
|
|
109
111
|
});
|
|
110
112
|
}
|
|
111
113
|
}
|
|
@@ -121,7 +123,7 @@ async function runDeferredProvisioning(args) {
|
|
|
121
123
|
new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS)
|
|
122
124
|
);
|
|
123
125
|
if (!claimed) {
|
|
124
|
-
|
|
126
|
+
logger.info("Deferred provisioning skipped (already claimed or completed)", {
|
|
125
127
|
requestId: args.requestId,
|
|
126
128
|
tenantId: args.tenantId
|
|
127
129
|
});
|
|
@@ -154,17 +156,17 @@ async function runDeferredProvisioning(args) {
|
|
|
154
156
|
});
|
|
155
157
|
} catch (error) {
|
|
156
158
|
if (isUniqueViolation(error)) {
|
|
157
|
-
|
|
159
|
+
logger.info("Deferred seedExamples skipped (already applied)", {
|
|
158
160
|
moduleId: mod.id,
|
|
159
161
|
tenantId: args.tenantId,
|
|
160
162
|
organizationId: args.organizationId
|
|
161
163
|
});
|
|
162
164
|
} else {
|
|
163
|
-
|
|
165
|
+
logger.error("Deferred seedExamples failed", {
|
|
164
166
|
moduleId: mod.id,
|
|
165
167
|
tenantId: args.tenantId,
|
|
166
168
|
organizationId: args.organizationId,
|
|
167
|
-
error
|
|
169
|
+
err: error
|
|
168
170
|
});
|
|
169
171
|
}
|
|
170
172
|
}
|
|
@@ -184,11 +186,11 @@ async function runDeferredProvisioning(args) {
|
|
|
184
186
|
requestId: args.requestId,
|
|
185
187
|
tenantId: args.tenantId
|
|
186
188
|
}).catch((error) => {
|
|
187
|
-
|
|
189
|
+
logger.error("Ready email failed", {
|
|
188
190
|
requestId: args.requestId,
|
|
189
191
|
tenantId: args.tenantId,
|
|
190
192
|
organizationId: args.organizationId,
|
|
191
|
-
error
|
|
193
|
+
err: error
|
|
192
194
|
});
|
|
193
195
|
});
|
|
194
196
|
await enqueueVectorReindex({
|
|
@@ -196,7 +198,7 @@ async function runDeferredProvisioning(args) {
|
|
|
196
198
|
tenantId: args.tenantId,
|
|
197
199
|
organizationId: args.organizationId
|
|
198
200
|
}).catch((error) => {
|
|
199
|
-
|
|
201
|
+
logger.warn("Vector reindex enqueue did not complete promptly", {
|
|
200
202
|
tenantId: args.tenantId,
|
|
201
203
|
organizationId: args.organizationId,
|
|
202
204
|
reason: error instanceof Error ? error.message : String(error)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/onboarding/lib/deferred-provisioning.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SearchIndexer } from '@open-mercato/search'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\nimport { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'\n\nconst VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000\nconst SEED_EXAMPLES_TIMEOUT_MS = 15_000\n// Steady lease-renewal cadence while the chain works. Must stay well below\n// PREPARATION_CLAIM_STALE_MS so a live runner is never mistaken for a crashed\n// one, regardless of how long any single module's seedExamples takes.\nconst PREPARATION_HEARTBEAT_MS = 5_000\n\nexport function resolveProvisioningIds(request: OnboardingRequest) {\n if (!request.tenantId || !request.organizationId || !request.userId) return null\n return {\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n userId: request.userId,\n }\n}\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n console.info('[onboarding.verify] module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n console.info('[onboarding.verify] module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n // Deferred provisioning is re-triggered on every preparing-page status\n // poll until preparationCompletedAt is set. seedExamples is not fully\n // idempotent (e.g. catalog product handles are unique-scoped), so a\n // re-run that lands before completion collides on an already-seeded row.\n // The workspace is already provisioned and the collision is expected and\n // harmless \u2014 log at info so genuine failures still stand out.\n console.info('[onboarding.verify] module hook skipped (already seeded)', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } else {\n console.error('[onboarding.verify] module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n error,\n })\n }\n throw error\n }\n}\n\nasync function markWorkspaceReady(args: {\n requestId: string\n service: OnboardingService\n}) {\n const request = await args.service.findById(args.requestId)\n // The status guard protects a request that was re-submitted (and reset to\n // pending) while this chain was still running \u2014 completing it would let the\n // new flow skip its own deferred provisioning.\n if (!request || request.preparationCompletedAt || request.status !== 'completed') return\n await args.service.markPreparationCompleted(request, new Date())\n}\n\nasync function enqueueVectorReindex(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n organizationId: string\n}) {\n let searchIndexer: SearchIndexer | null = null\n try {\n searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchIndexer = null\n }\n if (!searchIndexer) return\n\n await Promise.race([\n searchIndexer.reindexAllToVector({\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n purgeFirst: true,\n useQueue: true,\n }),\n createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),\n ])\n}\n\ntype PersistentEventBus = {\n emitEvent: (\n event: string,\n payload: unknown,\n options?: { persistent?: boolean; deliverInline?: boolean },\n ) => Promise<void>\n}\n\nasync function enqueueQueryIndexRebuild(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n}) {\n // Hand the heavy query-index rebuild to the durable queue instead of running\n // a multi-minute force purge+reindex of every system entity inline \u2014 that\n // inline rebuild was what stalled the preparing page and exhausted the PG\n // pool. Each entity becomes a persistent `query_index.reindex` job, so it\n // survives a worker/process restart and is retried independently of\n // onboarding. reindexEntity({ force: true }) purges the scope and refreshes\n // coverage internally, so no explicit purge/coverage sweep is needed here.\n //\n // Scope is the whole tenant (no organizationId): the previous inline rebuild\n // reindexed tenant-wide, which also covers organization_id IS NULL rows and\n // entities whose org is derived from the row (e.g. directory:organization).\n // Narrowing to a single org would silently drop those.\n let eventBus: PersistentEventBus | null = null\n try {\n eventBus = args.container.resolve<PersistentEventBus>('eventBus')\n } catch {\n eventBus = null\n }\n if (!eventBus) return\n\n const entityIds = flattenSystemEntityIds(getEntityIds())\n for (const entityType of entityIds) {\n try {\n // deliverInline: false is load-bearing here. A bare `{ persistent: true }`\n // emit dual-dispatches: the events worker drains the queued job AND the\n // subscriber runs inline in THIS request \u2014 so the multi-minute force\n // reindex of every system entity would still block onboarding (and reuse\n // the request's already-committed em, spamming \"Transaction is already\n // committed\" from the vector-purge status-log prune). Enqueue-only hands\n // the rebuild to the worker and returns immediately.\n await eventBus.emitEvent(\n 'query_index.reindex',\n {\n entityType,\n tenantId: args.tenantId,\n force: true,\n },\n { persistent: true, deliverInline: false },\n )\n } catch (error) {\n console.error('[onboarding.verify] failed to enqueue query index rebuild', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n }\n}\n\nexport async function runDeferredProvisioning(args: {\n requestId: string\n tenantId: string\n organizationId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n\n // Single-flight guard: the preparing page polls the status endpoint every\n // second and each poll (plus the verify handler) schedules this chain. The\n // atomic claim collapses those triggers into one run per request \u2014 without\n // it, dozens of concurrent seed + full-reindex chains exhaust the PG\n // connection pool (2026-06-11 demo outage). A stale claim (crashed runner)\n // becomes reclaimable after PREPARATION_CLAIM_STALE_MS.\n const claimedAt = new Date()\n const claimed = await service.claimPreparation(\n args.requestId,\n claimedAt,\n new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),\n )\n if (!claimed) {\n console.info('[onboarding.verify] deferred provisioning skipped (already claimed or completed)', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n })\n return\n }\n\n // Steady heartbeat on a SEPARATE EntityManager (own connection) so renewal can\n // never interleave with the chain's own DB work on `em`. This keeps the lease\n // fresh on a fixed cadence independent of how long any single module's\n // seedExamples takes, which is what lets PREPARATION_CLAIM_STALE_MS stay short:\n // a runner that actually crashes stops renewing and is reclaimable in seconds,\n // while a genuinely-working one is never falsely reclaimed. renewPreparation is\n // a no-op once the request is completed, so a late tick can't resurrect a\n // finished lease.\n const heartbeatEm = typeof (em as { fork?: () => EntityManager }).fork === 'function'\n ? em.fork()\n : em\n const heartbeatService = new OnboardingService(heartbeatEm)\n const heartbeat = setInterval(() => {\n void heartbeatService.renewPreparation(args.requestId, new Date()).catch(() => {})\n }, PREPARATION_HEARTBEAT_MS)\n if (typeof heartbeat.unref === 'function') heartbeat.unref()\n\n try {\n const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n // Renew immediately before each module too, so even a long-running module\n // starts from a fresh lease in addition to the steady heartbeat above.\n await service.renewPreparation(args.requestId, new Date()).catch(() => {})\n try {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedExamples',\n timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,\n run: () => mod.setup!.seedExamples!({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n container,\n }),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n console.info('[onboarding.verify] deferred seedExamples skipped (already applied)', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n } else {\n console.error('[onboarding.verify] deferred seedExamples failed', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n }\n }\n }\n\n // The query-index rebuild is ENQUEUED before the completion flag, not run\n // inline: preparationCompletedAt is the terminal gate for both the\n // status-route scheduling and claimPreparation, so a runner that dies before\n // the jobs are queued must leave the flag unset \u2014 the stale claim then makes\n // the chain reclaimable and re-enqueues (a repeated force reindex is\n // harmless). Enqueuing is fast, so the workspace is marked ready in seconds\n // while the actual reindex runs in the background workers.\n await enqueueQueryIndexRebuild({\n container,\n tenantId: args.tenantId,\n })\n\n await markWorkspaceReady({\n requestId: args.requestId,\n service,\n })\n } finally {\n // Stop renewing as soon as the chain finishes (or throws): a crashed runner\n // must let the lease go stale so a status poll can reclaim it.\n clearInterval(heartbeat)\n }\n\n // Non-fatal (#2954 contract): an email failure must not abort the chain.\n // The status endpoint retries the ready email on later polls while\n // readyEmailSentAt is unset.\n await sendWorkspaceReadyEmail({\n requestId: args.requestId,\n tenantId: args.tenantId,\n }).catch((error) => {\n console.error('[onboarding.verify] ready email failed', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n })\n\n await enqueueVectorReindex({\n container,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n }).catch((error) => {\n console.warn('[onboarding.verify] vector reindex enqueue did not complete promptly', {\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n reason: error instanceof Error ? error.message : String(error),\n })\n })\n}\n"],
|
|
5
|
-
"mappings": "AAEA,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAE3B,SAAS,yBAAyB;AAClC,SAAS,+BAA+B;AACxC,SAAS,yBAAyB;AAClC,SAAS,kCAAkC;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SearchIndexer } from '@open-mercato/search'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\nimport { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('onboarding').child({ component: 'verify' })\n\nconst VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000\nconst SEED_EXAMPLES_TIMEOUT_MS = 15_000\n// Steady lease-renewal cadence while the chain works. Must stay well below\n// PREPARATION_CLAIM_STALE_MS so a live runner is never mistaken for a crashed\n// one, regardless of how long any single module's seedExamples takes.\nconst PREPARATION_HEARTBEAT_MS = 5_000\n\nexport function resolveProvisioningIds(request: OnboardingRequest) {\n if (!request.tenantId || !request.organizationId || !request.userId) return null\n return {\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n userId: request.userId,\n }\n}\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n logger.info('Module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n logger.info('Module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n // Deferred provisioning is re-triggered on every preparing-page status\n // poll until preparationCompletedAt is set. seedExamples is not fully\n // idempotent (e.g. catalog product handles are unique-scoped), so a\n // re-run that lands before completion collides on an already-seeded row.\n // The workspace is already provisioned and the collision is expected and\n // harmless \u2014 log at info so genuine failures still stand out.\n logger.info('Module hook skipped (already seeded)', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } else {\n logger.error('Module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n err: error,\n })\n }\n throw error\n }\n}\n\nasync function markWorkspaceReady(args: {\n requestId: string\n service: OnboardingService\n}) {\n const request = await args.service.findById(args.requestId)\n // The status guard protects a request that was re-submitted (and reset to\n // pending) while this chain was still running \u2014 completing it would let the\n // new flow skip its own deferred provisioning.\n if (!request || request.preparationCompletedAt || request.status !== 'completed') return\n await args.service.markPreparationCompleted(request, new Date())\n}\n\nasync function enqueueVectorReindex(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n organizationId: string\n}) {\n let searchIndexer: SearchIndexer | null = null\n try {\n searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchIndexer = null\n }\n if (!searchIndexer) return\n\n await Promise.race([\n searchIndexer.reindexAllToVector({\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n purgeFirst: true,\n useQueue: true,\n }),\n createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),\n ])\n}\n\ntype PersistentEventBus = {\n emitEvent: (\n event: string,\n payload: unknown,\n options?: { persistent?: boolean; deliverInline?: boolean },\n ) => Promise<void>\n}\n\nasync function enqueueQueryIndexRebuild(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n}) {\n // Hand the heavy query-index rebuild to the durable queue instead of running\n // a multi-minute force purge+reindex of every system entity inline \u2014 that\n // inline rebuild was what stalled the preparing page and exhausted the PG\n // pool. Each entity becomes a persistent `query_index.reindex` job, so it\n // survives a worker/process restart and is retried independently of\n // onboarding. reindexEntity({ force: true }) purges the scope and refreshes\n // coverage internally, so no explicit purge/coverage sweep is needed here.\n //\n // Scope is the whole tenant (no organizationId): the previous inline rebuild\n // reindexed tenant-wide, which also covers organization_id IS NULL rows and\n // entities whose org is derived from the row (e.g. directory:organization).\n // Narrowing to a single org would silently drop those.\n let eventBus: PersistentEventBus | null = null\n try {\n eventBus = args.container.resolve<PersistentEventBus>('eventBus')\n } catch {\n eventBus = null\n }\n if (!eventBus) return\n\n const entityIds = flattenSystemEntityIds(getEntityIds())\n for (const entityType of entityIds) {\n try {\n // deliverInline: false is load-bearing here. A bare `{ persistent: true }`\n // emit dual-dispatches: the events worker drains the queued job AND the\n // subscriber runs inline in THIS request \u2014 so the multi-minute force\n // reindex of every system entity would still block onboarding (and reuse\n // the request's already-committed em, spamming \"Transaction is already\n // committed\" from the vector-purge status-log prune). Enqueue-only hands\n // the rebuild to the worker and returns immediately.\n await eventBus.emitEvent(\n 'query_index.reindex',\n {\n entityType,\n tenantId: args.tenantId,\n force: true,\n },\n { persistent: true, deliverInline: false },\n )\n } catch (error) {\n logger.error('Failed to enqueue query index rebuild', {\n entityType,\n tenantId: args.tenantId,\n err: error,\n })\n }\n }\n}\n\nexport async function runDeferredProvisioning(args: {\n requestId: string\n tenantId: string\n organizationId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n\n // Single-flight guard: the preparing page polls the status endpoint every\n // second and each poll (plus the verify handler) schedules this chain. The\n // atomic claim collapses those triggers into one run per request \u2014 without\n // it, dozens of concurrent seed + full-reindex chains exhaust the PG\n // connection pool (2026-06-11 demo outage). A stale claim (crashed runner)\n // becomes reclaimable after PREPARATION_CLAIM_STALE_MS.\n const claimedAt = new Date()\n const claimed = await service.claimPreparation(\n args.requestId,\n claimedAt,\n new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),\n )\n if (!claimed) {\n logger.info('Deferred provisioning skipped (already claimed or completed)', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n })\n return\n }\n\n // Steady heartbeat on a SEPARATE EntityManager (own connection) so renewal can\n // never interleave with the chain's own DB work on `em`. This keeps the lease\n // fresh on a fixed cadence independent of how long any single module's\n // seedExamples takes, which is what lets PREPARATION_CLAIM_STALE_MS stay short:\n // a runner that actually crashes stops renewing and is reclaimable in seconds,\n // while a genuinely-working one is never falsely reclaimed. renewPreparation is\n // a no-op once the request is completed, so a late tick can't resurrect a\n // finished lease.\n const heartbeatEm = typeof (em as { fork?: () => EntityManager }).fork === 'function'\n ? em.fork()\n : em\n const heartbeatService = new OnboardingService(heartbeatEm)\n const heartbeat = setInterval(() => {\n void heartbeatService.renewPreparation(args.requestId, new Date()).catch(() => {})\n }, PREPARATION_HEARTBEAT_MS)\n if (typeof heartbeat.unref === 'function') heartbeat.unref()\n\n try {\n const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n // Renew immediately before each module too, so even a long-running module\n // starts from a fresh lease in addition to the steady heartbeat above.\n await service.renewPreparation(args.requestId, new Date()).catch(() => {})\n try {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedExamples',\n timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,\n run: () => mod.setup!.seedExamples!({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n container,\n }),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n logger.info('Deferred seedExamples skipped (already applied)', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n } else {\n logger.error('Deferred seedExamples failed', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n err: error,\n })\n }\n }\n }\n\n // The query-index rebuild is ENQUEUED before the completion flag, not run\n // inline: preparationCompletedAt is the terminal gate for both the\n // status-route scheduling and claimPreparation, so a runner that dies before\n // the jobs are queued must leave the flag unset \u2014 the stale claim then makes\n // the chain reclaimable and re-enqueues (a repeated force reindex is\n // harmless). Enqueuing is fast, so the workspace is marked ready in seconds\n // while the actual reindex runs in the background workers.\n await enqueueQueryIndexRebuild({\n container,\n tenantId: args.tenantId,\n })\n\n await markWorkspaceReady({\n requestId: args.requestId,\n service,\n })\n } finally {\n // Stop renewing as soon as the chain finishes (or throws): a crashed runner\n // must let the lease go stale so a status poll can reclaim it.\n clearInterval(heartbeat)\n }\n\n // Non-fatal (#2954 contract): an email failure must not abort the chain.\n // The status endpoint retries the ready email on later polls while\n // readyEmailSentAt is unset.\n await sendWorkspaceReadyEmail({\n requestId: args.requestId,\n tenantId: args.tenantId,\n }).catch((error) => {\n logger.error('Ready email failed', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n err: error,\n })\n })\n\n await enqueueVectorReindex({\n container,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n }).catch((error) => {\n logger.warn('Vector reindex enqueue did not complete promptly', {\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n reason: error instanceof Error ? error.message : String(error),\n })\n })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
getSecurityEmailBaseUrl,
|
|
5
5
|
resolveRequestOrigin
|
|
6
6
|
} from "@open-mercato/shared/lib/url";
|
|
7
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
8
|
+
const logger = createLogger("onboarding").child({ component: "verify" });
|
|
7
9
|
function normalizeOrigin(raw) {
|
|
8
10
|
try {
|
|
9
11
|
return new URL(raw).origin;
|
|
@@ -36,7 +38,7 @@ function resolveVerifyRedirectBaseUrl(req, env = process.env) {
|
|
|
36
38
|
const configuredOrigin = normalizeOrigin(baseUrl);
|
|
37
39
|
const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url);
|
|
38
40
|
if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {
|
|
39
|
-
|
|
41
|
+
logger.error("APP_URL does not match verification request origin", {
|
|
40
42
|
requestUrl: req.url,
|
|
41
43
|
configuredOrigin,
|
|
42
44
|
requestOrigin
|
|
@@ -52,7 +54,7 @@ function resolveVerifyRedirectBaseUrl(req, env = process.env) {
|
|
|
52
54
|
return { ok: true, baseUrl };
|
|
53
55
|
} catch (error) {
|
|
54
56
|
if (error instanceof AppOriginRejectedError) {
|
|
55
|
-
|
|
57
|
+
logger.error("Rejected request origin for redirect base", {
|
|
56
58
|
requestUrl: req.url,
|
|
57
59
|
reason: error.message
|
|
58
60
|
});
|
|
@@ -65,7 +67,7 @@ function resolveVerifyRedirectBaseUrl(req, env = process.env) {
|
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
if (error instanceof AppOriginConfigurationError) {
|
|
68
|
-
|
|
70
|
+
logger.error("APP_URL is required for onboarding verification redirects", {
|
|
69
71
|
requestUrl: req.url,
|
|
70
72
|
reason: error.message
|
|
71
73
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/onboarding/lib/verify-base-url.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n AppOriginConfigurationError,\n AppOriginRejectedError,\n getSecurityEmailBaseUrl,\n resolveRequestOrigin,\n} from '@open-mercato/shared/lib/url'\n\ntype EnvLike = Record<string, string | undefined>\n\nexport type VerifyRedirectBaseUrlResult =\n | { ok: true; baseUrl: string }\n | {\n ok: false\n status: 'origin_not_allowed' | 'redirect_misconfigured' | 'url_not_configured'\n message: string\n redirectOrigin: string | null\n httpStatus: number\n }\n\nfunction normalizeOrigin(raw: string): string | null {\n try {\n return new URL(raw).origin\n } catch {\n return null\n }\n}\n\nfunction originPort(url: URL): string {\n if (url.port) return url.port\n if (url.protocol === 'https:') return '443'\n if (url.protocol === 'http:') return '80'\n return ''\n}\n\nfunction isLoopbackHostname(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'\n}\n\nfunction originsMatchForRedirect(configuredOrigin: string, requestOrigin: string): boolean {\n if (configuredOrigin === requestOrigin) return true\n try {\n const configured = new URL(configuredOrigin)\n const request = new URL(requestOrigin)\n return (\n configured.protocol === request.protocol &&\n isLoopbackHostname(configured.hostname) &&\n isLoopbackHostname(request.hostname) &&\n originPort(configured) === originPort(request)\n )\n } catch {\n return false\n }\n}\n\nexport function resolveVerifyRedirectBaseUrl(\n req: Request,\n env: EnvLike = process.env,\n): VerifyRedirectBaseUrlResult {\n try {\n const baseUrl = getSecurityEmailBaseUrl(req, env)\n const configuredOrigin = normalizeOrigin(baseUrl)\n const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url)\n if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {\n
|
|
5
|
-
"mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;
|
|
4
|
+
"sourcesContent": ["import {\n AppOriginConfigurationError,\n AppOriginRejectedError,\n getSecurityEmailBaseUrl,\n resolveRequestOrigin,\n} from '@open-mercato/shared/lib/url'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('onboarding').child({ component: 'verify' })\n\ntype EnvLike = Record<string, string | undefined>\n\nexport type VerifyRedirectBaseUrlResult =\n | { ok: true; baseUrl: string }\n | {\n ok: false\n status: 'origin_not_allowed' | 'redirect_misconfigured' | 'url_not_configured'\n message: string\n redirectOrigin: string | null\n httpStatus: number\n }\n\nfunction normalizeOrigin(raw: string): string | null {\n try {\n return new URL(raw).origin\n } catch {\n return null\n }\n}\n\nfunction originPort(url: URL): string {\n if (url.port) return url.port\n if (url.protocol === 'https:') return '443'\n if (url.protocol === 'http:') return '80'\n return ''\n}\n\nfunction isLoopbackHostname(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'\n}\n\nfunction originsMatchForRedirect(configuredOrigin: string, requestOrigin: string): boolean {\n if (configuredOrigin === requestOrigin) return true\n try {\n const configured = new URL(configuredOrigin)\n const request = new URL(requestOrigin)\n return (\n configured.protocol === request.protocol &&\n isLoopbackHostname(configured.hostname) &&\n isLoopbackHostname(request.hostname) &&\n originPort(configured) === originPort(request)\n )\n } catch {\n return false\n }\n}\n\nexport function resolveVerifyRedirectBaseUrl(\n req: Request,\n env: EnvLike = process.env,\n): VerifyRedirectBaseUrlResult {\n try {\n const baseUrl = getSecurityEmailBaseUrl(req, env)\n const configuredOrigin = normalizeOrigin(baseUrl)\n const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url)\n if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {\n logger.error('APP_URL does not match verification request origin', {\n requestUrl: req.url,\n configuredOrigin,\n requestOrigin,\n })\n return {\n ok: false,\n status: 'redirect_misconfigured',\n message: 'Onboarding verification redirect is misconfigured.',\n redirectOrigin: requestOrigin,\n httpStatus: 400,\n }\n }\n return { ok: true, baseUrl }\n } catch (error) {\n if (error instanceof AppOriginRejectedError) {\n logger.error('Rejected request origin for redirect base', {\n requestUrl: req.url,\n reason: error.message,\n })\n return {\n ok: false,\n status: 'origin_not_allowed',\n message: 'Onboarding verification request origin is not allowed.',\n redirectOrigin: null,\n httpStatus: 400,\n }\n }\n if (error instanceof AppOriginConfigurationError) {\n logger.error('APP_URL is required for onboarding verification redirects', {\n requestUrl: req.url,\n reason: error.message,\n })\n return {\n ok: false,\n status: 'url_not_configured',\n message: 'Onboarding verification redirect is not configured.',\n redirectOrigin: null,\n httpStatus: 500,\n }\n }\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,SAAS,CAAC;AAcvE,SAAS,gBAAgB,KAA4B;AACnD,MAAI;AACF,WAAO,IAAI,IAAI,GAAG,EAAE;AAAA,EACtB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,WAAW,KAAkB;AACpC,MAAI,IAAI,KAAM,QAAO,IAAI;AACzB,MAAI,IAAI,aAAa,SAAU,QAAO;AACtC,MAAI,IAAI,aAAa,QAAS,QAAO;AACrC,SAAO;AACT;AAEA,SAAS,mBAAmB,UAA2B;AACrD,SAAO,aAAa,eAAe,aAAa,eAAe,aAAa;AAC9E;AAEA,SAAS,wBAAwB,kBAA0B,eAAgC;AACzF,MAAI,qBAAqB,cAAe,QAAO;AAC/C,MAAI;AACF,UAAM,aAAa,IAAI,IAAI,gBAAgB;AAC3C,UAAM,UAAU,IAAI,IAAI,aAAa;AACrC,WACE,WAAW,aAAa,QAAQ,YAChC,mBAAmB,WAAW,QAAQ,KACtC,mBAAmB,QAAQ,QAAQ,KACnC,WAAW,UAAU,MAAM,WAAW,OAAO;AAAA,EAEjD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,6BACd,KACA,MAAe,QAAQ,KACM;AAC7B,MAAI;AACF,UAAM,UAAU,wBAAwB,KAAK,GAAG;AAChD,UAAM,mBAAmB,gBAAgB,OAAO;AAChD,UAAM,gBAAgB,gBAAgB,qBAAqB,GAAG,CAAC,KAAK,gBAAgB,IAAI,GAAG;AAC3F,QAAI,oBAAoB,iBAAiB,CAAC,wBAAwB,kBAAkB,aAAa,GAAG;AAClG,aAAO,MAAM,sDAAsD;AAAA,QACjE,YAAY,IAAI;AAAA,QAChB;AAAA,QACA;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,YAAY;AAAA,MACd;AAAA,IACF;AACA,WAAO,EAAE,IAAI,MAAM,QAAQ;AAAA,EAC7B,SAAS,OAAO;AACd,QAAI,iBAAiB,wBAAwB;AAC3C,aAAO,MAAM,6CAA6C;AAAA,QACxD,YAAY,IAAI;AAAA,QAChB,QAAQ,MAAM;AAAA,MAChB,CAAC;AACD,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,YAAY;AAAA,MACd;AAAA,IACF;AACA,QAAI,iBAAiB,6BAA6B;AAChD,aAAO,MAAM,6DAA6D;AAAA,QACxE,YAAY,IAAI;AAAA,QAChB,QAAQ,MAAM;AAAA,MAChB,CAAC;AACD,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,gBAAgB;AAAA,QAChB,YAAY;AAAA,MACd;AAAA,IACF;AACA,UAAM;AAAA,EACR;AACF;",
|
|
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.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
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.6-develop.
|
|
73
|
+
"@open-mercato/shared": "0.6.6-develop.6471.1.9ab5bdd79a"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"jest": "^30.4.2",
|
|
79
79
|
"ts-jest": "^29.4.11",
|
|
@@ -5,6 +5,9 @@ import FeedbackEmail from '@open-mercato/onboarding/modules/onboarding/emails/Fe
|
|
|
5
5
|
import { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'
|
|
6
6
|
import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
|
|
7
7
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
8
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
9
|
+
|
|
10
|
+
const logger = createLogger('onboarding').child({ component: 'demo-feedback' })
|
|
8
11
|
|
|
9
12
|
const demoFeedbackIpRateLimitConfig = readEndpointRateLimitConfig('DEMO_FEEDBACK_IP', {
|
|
10
13
|
points: 5, duration: 300, blockDuration: 300, keyPrefix: 'demo-feedback-ip',
|
|
@@ -68,7 +71,7 @@ export async function POST(req: Request) {
|
|
|
68
71
|
react: FeedbackEmail({ copy: adminCopy }),
|
|
69
72
|
})
|
|
70
73
|
} catch (err) {
|
|
71
|
-
|
|
74
|
+
logger.error('Admin email failed', { err })
|
|
72
75
|
return NextResponse.json({ ok: false, error: 'Failed to send feedback. Please try again.' }, { status: 502 })
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -89,7 +92,7 @@ export async function POST(req: Request) {
|
|
|
89
92
|
react: FeedbackEmail({ copy: userCopy }),
|
|
90
93
|
})
|
|
91
94
|
} catch (err) {
|
|
92
|
-
|
|
95
|
+
logger.error('User copy email failed', { err })
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -2,6 +2,7 @@ import { after, 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 { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
6
|
import { assertAllowedAppOrigin, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
6
7
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
7
8
|
import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
|
|
@@ -12,6 +13,8 @@ import {
|
|
|
12
13
|
import { isPreparationClaimActive } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
|
|
13
14
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
14
15
|
|
|
16
|
+
const logger = createLogger('onboarding').child({ component: 'status' })
|
|
17
|
+
|
|
15
18
|
export const metadata = {
|
|
16
19
|
path: '/onboarding/onboarding/status',
|
|
17
20
|
GET: {
|
|
@@ -109,11 +112,11 @@ export async function GET(req: Request) {
|
|
|
109
112
|
requestId: request.id,
|
|
110
113
|
tenantId: readyTenantId,
|
|
111
114
|
}).catch((error) => {
|
|
112
|
-
|
|
115
|
+
logger.error('Ready email retry failed', {
|
|
113
116
|
requestId: request.id,
|
|
114
117
|
tenantId: readyTenantId,
|
|
115
118
|
organizationId: request.organizationId,
|
|
116
|
-
error,
|
|
119
|
+
err: error,
|
|
117
120
|
})
|
|
118
121
|
})
|
|
119
122
|
})
|
|
@@ -24,6 +24,9 @@ import { runBestEffortProvisioningStep } from '@open-mercato/onboarding/modules/
|
|
|
24
24
|
import { getModules } from '@open-mercato/shared/lib/modules/registry'
|
|
25
25
|
import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
|
|
26
26
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
27
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
28
|
+
|
|
29
|
+
const logger = createLogger('onboarding').child({ component: 'verify' })
|
|
27
30
|
|
|
28
31
|
export const metadata = {
|
|
29
32
|
path: '/onboarding/onboarding/verify',
|
|
@@ -47,7 +50,7 @@ async function runModuleSetupHook(args: {
|
|
|
47
50
|
run: () => Promise<void>
|
|
48
51
|
}) {
|
|
49
52
|
const startedAt = Date.now()
|
|
50
|
-
|
|
53
|
+
logger.info('Module hook started', {
|
|
51
54
|
moduleId: args.moduleId,
|
|
52
55
|
phase: args.phase,
|
|
53
56
|
timeoutMs: args.timeoutMs,
|
|
@@ -57,7 +60,7 @@ async function runModuleSetupHook(args: {
|
|
|
57
60
|
args.run(),
|
|
58
61
|
createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),
|
|
59
62
|
])
|
|
60
|
-
|
|
63
|
+
logger.info('Module hook completed', {
|
|
61
64
|
moduleId: args.moduleId,
|
|
62
65
|
phase: args.phase,
|
|
63
66
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
@@ -68,18 +71,18 @@ async function runModuleSetupHook(args: {
|
|
|
68
71
|
// provisioned) re-runs seedDefaults against rows that exist. seed hooks
|
|
69
72
|
// are not fully idempotent, so the collision is expected and harmless —
|
|
70
73
|
// the workspace already exists. Log at info so real failures stand out.
|
|
71
|
-
|
|
74
|
+
logger.info('Module hook skipped (already seeded)', {
|
|
72
75
|
moduleId: args.moduleId,
|
|
73
76
|
phase: args.phase,
|
|
74
77
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
75
78
|
})
|
|
76
79
|
} else {
|
|
77
|
-
|
|
80
|
+
logger.error('Module hook failed', {
|
|
78
81
|
moduleId: args.moduleId,
|
|
79
82
|
phase: args.phase,
|
|
80
83
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
81
84
|
timeoutMs: args.timeoutMs,
|
|
82
|
-
error,
|
|
85
|
+
err: error,
|
|
83
86
|
})
|
|
84
87
|
}
|
|
85
88
|
throw error
|
|
@@ -142,11 +145,11 @@ export async function GET(req: Request) {
|
|
|
142
145
|
requestId: request.id,
|
|
143
146
|
tenantId: request.tenantId!,
|
|
144
147
|
}).catch((error) => {
|
|
145
|
-
|
|
148
|
+
logger.error('Retry ready email failed', {
|
|
146
149
|
requestId: request.id,
|
|
147
150
|
tenantId: request.tenantId,
|
|
148
151
|
organizationId: request.organizationId,
|
|
149
|
-
error,
|
|
152
|
+
err: error,
|
|
150
153
|
})
|
|
151
154
|
})
|
|
152
155
|
})
|
|
@@ -189,7 +192,7 @@ export async function GET(req: Request) {
|
|
|
189
192
|
return redirectToPreparing(baseUrl, current?.tenantId ?? request.tenantId ?? null)
|
|
190
193
|
}
|
|
191
194
|
if (!request.passwordHash) {
|
|
192
|
-
|
|
195
|
+
logger.error('Missing password hash for request', { requestId: request.id })
|
|
193
196
|
await service.resetProcessing(request)
|
|
194
197
|
return redirectWithStatus(baseUrl, 'error')
|
|
195
198
|
}
|
|
@@ -313,7 +316,7 @@ export async function GET(req: Request) {
|
|
|
313
316
|
await service.resetProcessing(request)
|
|
314
317
|
return redirectWithStatus(baseUrl, 'already_exists')
|
|
315
318
|
}
|
|
316
|
-
|
|
319
|
+
logger.error('Verification failed', { err: error })
|
|
317
320
|
await service.resetProcessing(request)
|
|
318
321
|
return redirectWithStatus(baseUrl, 'error')
|
|
319
322
|
}
|
|
@@ -2,6 +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 { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
6
|
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
6
7
|
import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
|
|
7
8
|
import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
@@ -18,6 +19,8 @@ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
|
18
19
|
import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
|
|
19
20
|
import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
20
21
|
|
|
22
|
+
const logger = createLogger('onboarding').child({ component: 'start' })
|
|
23
|
+
|
|
21
24
|
export const metadata = {
|
|
22
25
|
path: '/onboarding/onboarding',
|
|
23
26
|
POST: {
|
|
@@ -175,7 +178,7 @@ export async function POST(req: Request) {
|
|
|
175
178
|
} catch (err) {
|
|
176
179
|
request.lastEmailSentAt = null
|
|
177
180
|
await em.flush()
|
|
178
|
-
|
|
181
|
+
logger.error('Verification email failed', { err })
|
|
179
182
|
return NextResponse.json({
|
|
180
183
|
ok: false,
|
|
181
184
|
error: translate(
|
|
@@ -206,12 +209,12 @@ export async function POST(req: Request) {
|
|
|
206
209
|
react: AdminNotificationEmail({ copy: adminCopy }),
|
|
207
210
|
})
|
|
208
211
|
} catch (err) {
|
|
209
|
-
|
|
212
|
+
logger.error('Admin email failed', { err })
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
return NextResponse.json({ ok: true, email: request.email })
|
|
213
216
|
} catch (error) {
|
|
214
|
-
|
|
217
|
+
logger.error('Onboarding start failed', { err: error })
|
|
215
218
|
return NextResponse.json({
|
|
216
219
|
ok: false,
|
|
217
220
|
error: translate('onboarding.form.genericError', 'Something went wrong. Please try again later.'),
|
|
@@ -9,6 +9,9 @@ import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/l
|
|
|
9
9
|
import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
|
|
10
10
|
import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
|
|
11
11
|
import { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
|
|
12
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
13
|
+
|
|
14
|
+
const logger = createLogger('onboarding').child({ component: 'verify' })
|
|
12
15
|
|
|
13
16
|
const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000
|
|
14
17
|
const SEED_EXAMPLES_TIMEOUT_MS = 15_000
|
|
@@ -39,7 +42,7 @@ async function runModuleSetupHook(args: {
|
|
|
39
42
|
run: () => Promise<void>
|
|
40
43
|
}) {
|
|
41
44
|
const startedAt = Date.now()
|
|
42
|
-
|
|
45
|
+
logger.info('Module hook started', {
|
|
43
46
|
moduleId: args.moduleId,
|
|
44
47
|
phase: args.phase,
|
|
45
48
|
timeoutMs: args.timeoutMs,
|
|
@@ -49,7 +52,7 @@ async function runModuleSetupHook(args: {
|
|
|
49
52
|
args.run(),
|
|
50
53
|
createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),
|
|
51
54
|
])
|
|
52
|
-
|
|
55
|
+
logger.info('Module hook completed', {
|
|
53
56
|
moduleId: args.moduleId,
|
|
54
57
|
phase: args.phase,
|
|
55
58
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
@@ -62,18 +65,18 @@ async function runModuleSetupHook(args: {
|
|
|
62
65
|
// re-run that lands before completion collides on an already-seeded row.
|
|
63
66
|
// The workspace is already provisioned and the collision is expected and
|
|
64
67
|
// harmless — log at info so genuine failures still stand out.
|
|
65
|
-
|
|
68
|
+
logger.info('Module hook skipped (already seeded)', {
|
|
66
69
|
moduleId: args.moduleId,
|
|
67
70
|
phase: args.phase,
|
|
68
71
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
69
72
|
})
|
|
70
73
|
} else {
|
|
71
|
-
|
|
74
|
+
logger.error('Module hook failed', {
|
|
72
75
|
moduleId: args.moduleId,
|
|
73
76
|
phase: args.phase,
|
|
74
77
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
75
78
|
timeoutMs: args.timeoutMs,
|
|
76
|
-
error,
|
|
79
|
+
err: error,
|
|
77
80
|
})
|
|
78
81
|
}
|
|
79
82
|
throw error
|
|
@@ -168,10 +171,10 @@ async function enqueueQueryIndexRebuild(args: {
|
|
|
168
171
|
{ persistent: true, deliverInline: false },
|
|
169
172
|
)
|
|
170
173
|
} catch (error) {
|
|
171
|
-
|
|
174
|
+
logger.error('Failed to enqueue query index rebuild', {
|
|
172
175
|
entityType,
|
|
173
176
|
tenantId: args.tenantId,
|
|
174
|
-
error,
|
|
177
|
+
err: error,
|
|
175
178
|
})
|
|
176
179
|
}
|
|
177
180
|
}
|
|
@@ -199,7 +202,7 @@ export async function runDeferredProvisioning(args: {
|
|
|
199
202
|
new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),
|
|
200
203
|
)
|
|
201
204
|
if (!claimed) {
|
|
202
|
-
|
|
205
|
+
logger.info('Deferred provisioning skipped (already claimed or completed)', {
|
|
203
206
|
requestId: args.requestId,
|
|
204
207
|
tenantId: args.tenantId,
|
|
205
208
|
})
|
|
@@ -245,17 +248,17 @@ export async function runDeferredProvisioning(args: {
|
|
|
245
248
|
})
|
|
246
249
|
} catch (error) {
|
|
247
250
|
if (isUniqueViolation(error)) {
|
|
248
|
-
|
|
251
|
+
logger.info('Deferred seedExamples skipped (already applied)', {
|
|
249
252
|
moduleId: mod.id,
|
|
250
253
|
tenantId: args.tenantId,
|
|
251
254
|
organizationId: args.organizationId,
|
|
252
255
|
})
|
|
253
256
|
} else {
|
|
254
|
-
|
|
257
|
+
logger.error('Deferred seedExamples failed', {
|
|
255
258
|
moduleId: mod.id,
|
|
256
259
|
tenantId: args.tenantId,
|
|
257
260
|
organizationId: args.organizationId,
|
|
258
|
-
error,
|
|
261
|
+
err: error,
|
|
259
262
|
})
|
|
260
263
|
}
|
|
261
264
|
}
|
|
@@ -290,11 +293,11 @@ export async function runDeferredProvisioning(args: {
|
|
|
290
293
|
requestId: args.requestId,
|
|
291
294
|
tenantId: args.tenantId,
|
|
292
295
|
}).catch((error) => {
|
|
293
|
-
|
|
296
|
+
logger.error('Ready email failed', {
|
|
294
297
|
requestId: args.requestId,
|
|
295
298
|
tenantId: args.tenantId,
|
|
296
299
|
organizationId: args.organizationId,
|
|
297
|
-
error,
|
|
300
|
+
err: error,
|
|
298
301
|
})
|
|
299
302
|
})
|
|
300
303
|
|
|
@@ -303,7 +306,7 @@ export async function runDeferredProvisioning(args: {
|
|
|
303
306
|
tenantId: args.tenantId,
|
|
304
307
|
organizationId: args.organizationId,
|
|
305
308
|
}).catch((error) => {
|
|
306
|
-
|
|
309
|
+
logger.warn('Vector reindex enqueue did not complete promptly', {
|
|
307
310
|
tenantId: args.tenantId,
|
|
308
311
|
organizationId: args.organizationId,
|
|
309
312
|
reason: error instanceof Error ? error.message : String(error),
|
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
getSecurityEmailBaseUrl,
|
|
5
5
|
resolveRequestOrigin,
|
|
6
6
|
} from '@open-mercato/shared/lib/url'
|
|
7
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
8
|
+
|
|
9
|
+
const logger = createLogger('onboarding').child({ component: 'verify' })
|
|
7
10
|
|
|
8
11
|
type EnvLike = Record<string, string | undefined>
|
|
9
12
|
|
|
@@ -61,7 +64,7 @@ export function resolveVerifyRedirectBaseUrl(
|
|
|
61
64
|
const configuredOrigin = normalizeOrigin(baseUrl)
|
|
62
65
|
const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url)
|
|
63
66
|
if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {
|
|
64
|
-
|
|
67
|
+
logger.error('APP_URL does not match verification request origin', {
|
|
65
68
|
requestUrl: req.url,
|
|
66
69
|
configuredOrigin,
|
|
67
70
|
requestOrigin,
|
|
@@ -77,7 +80,7 @@ export function resolveVerifyRedirectBaseUrl(
|
|
|
77
80
|
return { ok: true, baseUrl }
|
|
78
81
|
} catch (error) {
|
|
79
82
|
if (error instanceof AppOriginRejectedError) {
|
|
80
|
-
|
|
83
|
+
logger.error('Rejected request origin for redirect base', {
|
|
81
84
|
requestUrl: req.url,
|
|
82
85
|
reason: error.message,
|
|
83
86
|
})
|
|
@@ -90,7 +93,7 @@ export function resolveVerifyRedirectBaseUrl(
|
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
if (error instanceof AppOriginConfigurationError) {
|
|
93
|
-
|
|
96
|
+
logger.error('APP_URL is required for onboarding verification redirects', {
|
|
94
97
|
requestUrl: req.url,
|
|
95
98
|
reason: error.message,
|
|
96
99
|
})
|