@open-mercato/onboarding 0.6.5-develop.5337.1.534b781eac → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +141 -0
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +7 -0
  4. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +158 -0
  5. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +7 -0
  6. package/dist/modules/onboarding/api/get/onboarding/status.js +35 -17
  7. package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
  8. package/dist/modules/onboarding/api/get/onboarding/verify.js +77 -195
  9. package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
  10. package/dist/modules/onboarding/api/post/onboarding.js +10 -1
  11. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  12. package/dist/modules/onboarding/data/entities.js +3 -0
  13. package/dist/modules/onboarding/data/entities.js.map +2 -2
  14. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js +31 -1
  15. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js.map +2 -2
  16. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js +36 -7
  17. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js.map +2 -2
  18. package/dist/modules/onboarding/lib/deferred-provisioning.js +235 -0
  19. package/dist/modules/onboarding/lib/deferred-provisioning.js.map +7 -0
  20. package/dist/modules/onboarding/lib/preparation-claim.js +10 -0
  21. package/dist/modules/onboarding/lib/preparation-claim.js.map +7 -0
  22. package/dist/modules/onboarding/lib/provisioning.js +18 -0
  23. package/dist/modules/onboarding/lib/provisioning.js.map +7 -0
  24. package/dist/modules/onboarding/lib/service.js +31 -0
  25. package/dist/modules/onboarding/lib/service.js.map +2 -2
  26. package/dist/modules/onboarding/lib/verify-base-url.js +86 -0
  27. package/dist/modules/onboarding/lib/verify-base-url.js.map +7 -0
  28. package/dist/modules/onboarding/migrations/Migration20260611120000.js +13 -0
  29. package/dist/modules/onboarding/migrations/Migration20260611120000.js.map +7 -0
  30. package/generated/entities/onboarding_request/index.ts +1 -0
  31. package/generated/entity-fields-registry.ts +1 -0
  32. package/package.json +4 -5
  33. package/src/__tests__/deferred-provisioning.test.ts +210 -0
  34. package/src/__tests__/onboarding-submit-rate-limit.test.ts +22 -0
  35. package/src/__tests__/provisioning.test.ts +89 -0
  36. package/src/__tests__/status-endpoint-auth.test.ts +130 -3
  37. package/src/__tests__/verify-base-url.test.ts +65 -0
  38. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +176 -0
  39. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +198 -0
  40. package/src/modules/onboarding/api/get/onboarding/status.ts +46 -17
  41. package/src/modules/onboarding/api/get/onboarding/verify.ts +96 -227
  42. package/src/modules/onboarding/api/post/onboarding.ts +9 -0
  43. package/src/modules/onboarding/data/entities.ts +3 -0
  44. package/src/modules/onboarding/frontend/onboarding/OnboardingPageClient.tsx +32 -2
  45. package/src/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.tsx +38 -6
  46. package/src/modules/onboarding/i18n/de.json +10 -1
  47. package/src/modules/onboarding/i18n/en.json +10 -1
  48. package/src/modules/onboarding/i18n/es.json +10 -1
  49. package/src/modules/onboarding/i18n/pl.json +10 -1
  50. package/src/modules/onboarding/lib/deferred-provisioning.ts +295 -0
  51. package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
  52. package/src/modules/onboarding/lib/provisioning.ts +35 -0
  53. package/src/modules/onboarding/lib/service.ts +33 -0
  54. package/src/modules/onboarding/lib/verify-base-url.ts +107 -0
  55. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
  56. package/src/modules/onboarding/migrations/Migration20260611120000.ts +13 -0
@@ -0,0 +1,198 @@
1
+ // Adapted from PR #3007 (pkarw) — multi-tenant parallel onboarding repro for the
2
+ // 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at
3
+ // lease column introduced by the single-flight deferred-provisioning fix.
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';
6
+ import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
7
+
8
+ export const integrationMeta = {
9
+ dependsOnModules: ['onboarding'],
10
+ requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],
11
+ requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],
12
+ };
13
+
14
+ type OnboardingTenant = {
15
+ email: string;
16
+ organizationName: string;
17
+ token: string;
18
+ requestId?: string;
19
+ tenantId?: string;
20
+ };
21
+
22
+ type BrowserTenantSession = {
23
+ context: BrowserContext;
24
+ page: Page;
25
+ refreshRequests: string[];
26
+ };
27
+
28
+ const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
29
+ const ONBOARDING_PASSWORD = 'ParallelPass123!';
30
+
31
+ function hashToken(token: string): string {
32
+ return createHash('sha256').update(token).digest('hex');
33
+ }
34
+
35
+ async function replaceVerificationToken(email: string, token: string): Promise<string> {
36
+ return withClient(async (client) => {
37
+ const result = await client.query<{ id: string }>(
38
+ `update onboarding_requests
39
+ set token_hash = $2,
40
+ expires_at = now() + interval '24 hours',
41
+ updated_at = now()
42
+ where email = $1
43
+ returning id`,
44
+ [email, hashToken(token)],
45
+ );
46
+ expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
47
+ return result.rows[0].id;
48
+ });
49
+ }
50
+
51
+ async function readPreparationState(requestId: string): Promise<{
52
+ preparation_completed_at: Date | null;
53
+ preparation_started_at: Date | null;
54
+ }> {
55
+ return withClient(async (client) => {
56
+ const result = await client.query<{
57
+ preparation_completed_at: Date | null;
58
+ preparation_started_at: Date | null;
59
+ }>(
60
+ `select preparation_completed_at, preparation_started_at
61
+ from onboarding_requests
62
+ where id = $1`,
63
+ [requestId],
64
+ );
65
+ expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);
66
+ return result.rows[0];
67
+ });
68
+ }
69
+
70
+ async function waitForPreparationComplete(requestId: string): Promise<void> {
71
+ const deadline = Date.now() + 60_000;
72
+ let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;
73
+ while (Date.now() < deadline) {
74
+ lastState = await readPreparationState(requestId);
75
+ if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;
76
+ await new Promise((resolve) => setTimeout(resolve, 500));
77
+ }
78
+ expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();
79
+ expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();
80
+ }
81
+
82
+ async function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {
83
+ const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {
84
+ data: {
85
+ email: tenant.email,
86
+ firstName: 'Parallel',
87
+ lastName: 'Login',
88
+ organizationName: tenant.organizationName,
89
+ password: ONBOARDING_PASSWORD,
90
+ confirmPassword: ONBOARDING_PASSWORD,
91
+ termsAccepted: true,
92
+ marketingConsent: true,
93
+ locale: 'en',
94
+ },
95
+ });
96
+ expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);
97
+ }
98
+
99
+ async function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {
100
+ const context = await browser.newContext({ baseURL: BASE_URL });
101
+ const page = await context.newPage();
102
+ const refreshRequests: string[] = [];
103
+ page.on('request', (browserRequest) => {
104
+ const url = browserRequest.url();
105
+ if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);
106
+ });
107
+
108
+ await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);
109
+ await expect(page).toHaveURL(/\/onboarding\/preparing\?tenant=[0-9a-f-]+/);
110
+ await expect(page.getByText('We are preparing your workspace')).toBeVisible();
111
+ const tenantId = new URL(page.url()).searchParams.get('tenant');
112
+ expect(tenantId, 'verify redirect should include tenant id').toMatch(
113
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
114
+ );
115
+ return { context, page, refreshRequests, tenantId: tenantId! };
116
+ }
117
+
118
+ async function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {
119
+ return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {
120
+ headers: {
121
+ Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,
122
+ },
123
+ });
124
+ }
125
+
126
+ async function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {
127
+ expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();
128
+ await expect(session.page).toHaveURL(new RegExp(`/login\\?tenant=${tenant.tenantId}`));
129
+ await expect(session.page.locator('form[data-auth-ready="1"]')).toBeVisible();
130
+ await session.page.getByLabel('Email').fill(tenant.email);
131
+ await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);
132
+ await session.page.getByRole('button', { name: 'Sign in' }).click();
133
+ await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\/backend(?:\/.*)?$/);
134
+ await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);
135
+
136
+ const profileResult = await session.page.evaluate(async () => {
137
+ const response = await fetch('/api/auth/profile', { credentials: 'include' });
138
+ const body = await response.json().catch(() => null);
139
+ return { status: response.status, body };
140
+ });
141
+ expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);
142
+ const profile = profileResult.body;
143
+ expect(profile.email).toBe(tenant.email);
144
+ expect(profile.roles).toContain('admin');
145
+
146
+ await session.page.goto('/backend');
147
+ await expect(session.page).toHaveURL(/\/backend(?:\/.*)?$/);
148
+ expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);
149
+ }
150
+
151
+ test.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {
152
+ test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({
153
+ browser,
154
+ request,
155
+ }) => {
156
+ // Two full onboardings + parallel browser logins + DB-polled preparation
157
+ // far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).
158
+ test.setTimeout(120_000);
159
+ const unique = randomUUID().slice(0, 8);
160
+ const tenants: OnboardingTenant[] = [1, 2].map((index) => ({
161
+ email: `qa-onboarding-parallel-${unique}-${index}@example.test`,
162
+ organizationName: `QA Onboarding Parallel ${unique} ${index}`,
163
+ token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,
164
+ }));
165
+
166
+ await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));
167
+
168
+ for (const tenant of tenants) {
169
+ tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);
170
+ }
171
+
172
+ const browserSessions = await Promise.all(
173
+ tenants.map(async (tenant) => {
174
+ const session = await verifyTenantInBrowser(browser, tenant.token);
175
+ tenant.tenantId = session.tenantId;
176
+ return session;
177
+ }),
178
+ );
179
+
180
+ try {
181
+ const statusResponses = await Promise.all(
182
+ tenants.flatMap((tenant) =>
183
+ Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),
184
+ ),
185
+ );
186
+ for (const response of statusResponses) {
187
+ expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);
188
+ }
189
+
190
+ await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId!)));
191
+ await Promise.all(
192
+ tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),
193
+ );
194
+ } finally {
195
+ await Promise.all(browserSessions.map((session) => session.context.close()));
196
+ }
197
+ });
198
+ });
@@ -1,10 +1,15 @@
1
- import { NextResponse } from 'next/server'
1
+ 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 { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
5
+ import { assertAllowedAppOrigin, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
6
6
  import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
7
7
  import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
8
+ import {
9
+ resolveProvisioningIds,
10
+ runDeferredProvisioning,
11
+ } from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
12
+ import { isPreparationClaimActive } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
8
13
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
14
 
10
15
  export const metadata = {
@@ -51,9 +56,8 @@ export async function GET(req: Request) {
51
56
  return NextResponse.json({ ok: false, error: 'Not authorized for this tenant.' }, { status: 403 })
52
57
  }
53
58
 
54
- let baseUrl: string
55
59
  try {
56
- baseUrl = getSecurityEmailBaseUrl(req)
60
+ assertAllowedAppOrigin(req)
57
61
  } catch (error) {
58
62
  const mapped = mapSecurityEmailUrlError(error, {
59
63
  scope: 'onboarding.status',
@@ -62,6 +66,7 @@ export async function GET(req: Request) {
62
66
  if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
63
67
  throw error
64
68
  }
69
+
65
70
  const container = await createRequestContainer()
66
71
  const em = container.resolve('em') as EntityManager
67
72
  const service = new OnboardingService(em)
@@ -70,24 +75,48 @@ export async function GET(req: Request) {
70
75
  return NextResponse.json({ ok: false, error: 'Onboarding request not found.' }, { status: 404 })
71
76
  }
72
77
 
73
- let emailSent = Boolean(request.readyEmailSentAt)
78
+ const provisioningIds = resolveProvisioningIds(request)
79
+ if (provisioningIds && request.status === 'processing') {
80
+ await service.markCompleted(request, provisioningIds)
81
+ }
82
+ // Schedule deferred provisioning only while no runner holds a fresh claim —
83
+ // otherwise every ~1s poll piles another full seed + reindex chain onto the
84
+ // connection pool. The atomic claim inside runDeferredProvisioning remains
85
+ // the authoritative gate; this check just keeps polls cheap.
86
+ if (
87
+ provisioningIds &&
88
+ request.status === 'completed' &&
89
+ !request.preparationCompletedAt &&
90
+ !isPreparationClaimActive(request.preparationStartedAt)
91
+ ) {
92
+ after(async () => {
93
+ await runDeferredProvisioning({
94
+ requestId: request.id,
95
+ tenantId: provisioningIds.tenantId,
96
+ organizationId: provisioningIds.organizationId,
97
+ })
98
+ })
99
+ }
100
+
101
+ const emailSent = Boolean(request.readyEmailSentAt)
74
102
  const ready = request.status === 'completed' && Boolean(request.preparationCompletedAt)
75
- const loginUrl = ready && request.tenantId ? `${baseUrl}/login?tenant=${encodeURIComponent(request.tenantId)}` : null
103
+ const loginUrl = ready && request.tenantId ? `/login?tenant=${encodeURIComponent(request.tenantId)}` : null
76
104
 
77
105
  if (ready && request.tenantId && !request.readyEmailSentAt) {
78
- try {
79
- emailSent = await sendWorkspaceReadyEmail({
80
- requestId: request.id,
81
- tenantId: request.tenantId,
82
- })
83
- } catch (error) {
84
- console.error('[onboarding.status] ready email retry failed', {
106
+ const readyTenantId = request.tenantId
107
+ after(async () => {
108
+ await sendWorkspaceReadyEmail({
85
109
  requestId: request.id,
86
- tenantId: request.tenantId,
87
- organizationId: request.organizationId,
88
- error,
110
+ tenantId: readyTenantId,
111
+ }).catch((error) => {
112
+ console.error('[onboarding.status] ready email retry failed', {
113
+ requestId: request.id,
114
+ tenantId: readyTenantId,
115
+ organizationId: request.organizationId,
116
+ error,
117
+ })
89
118
  })
90
- }
119
+ })
91
120
  }
92
121
 
93
122
  return NextResponse.json({