@open-mercato/onboarding 0.6.6-develop.5412.1.e2a52b14f0 → 0.6.6-develop.5459.1.170077434e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +141 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +7 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +158 -0
- package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +7 -0
- package/dist/modules/onboarding/api/get/onboarding/status.js +35 -17
- package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
- package/dist/modules/onboarding/api/get/onboarding/verify.js +77 -195
- package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +10 -1
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/data/entities.js +3 -0
- package/dist/modules/onboarding/data/entities.js.map +2 -2
- package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js +31 -1
- package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js.map +2 -2
- package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js +36 -7
- package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js.map +2 -2
- package/dist/modules/onboarding/lib/deferred-provisioning.js +235 -0
- package/dist/modules/onboarding/lib/deferred-provisioning.js.map +7 -0
- package/dist/modules/onboarding/lib/preparation-claim.js +10 -0
- package/dist/modules/onboarding/lib/preparation-claim.js.map +7 -0
- package/dist/modules/onboarding/lib/provisioning.js +18 -0
- package/dist/modules/onboarding/lib/provisioning.js.map +7 -0
- package/dist/modules/onboarding/lib/service.js +31 -0
- package/dist/modules/onboarding/lib/service.js.map +2 -2
- package/dist/modules/onboarding/lib/verify-base-url.js +86 -0
- package/dist/modules/onboarding/lib/verify-base-url.js.map +7 -0
- package/dist/modules/onboarding/migrations/Migration20260611120000.js +13 -0
- package/dist/modules/onboarding/migrations/Migration20260611120000.js.map +7 -0
- package/generated/entities/onboarding_request/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +3 -3
- package/src/__tests__/deferred-provisioning.test.ts +210 -0
- package/src/__tests__/onboarding-submit-rate-limit.test.ts +22 -0
- package/src/__tests__/provisioning.test.ts +89 -0
- package/src/__tests__/status-endpoint-auth.test.ts +130 -3
- package/src/__tests__/verify-base-url.test.ts +65 -0
- package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +176 -0
- package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +198 -0
- package/src/modules/onboarding/api/get/onboarding/status.ts +46 -17
- package/src/modules/onboarding/api/get/onboarding/verify.ts +96 -227
- package/src/modules/onboarding/api/post/onboarding.ts +9 -0
- package/src/modules/onboarding/data/entities.ts +3 -0
- package/src/modules/onboarding/frontend/onboarding/OnboardingPageClient.tsx +32 -2
- package/src/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.tsx +38 -6
- package/src/modules/onboarding/i18n/de.json +10 -1
- package/src/modules/onboarding/i18n/en.json +10 -1
- package/src/modules/onboarding/i18n/es.json +10 -1
- package/src/modules/onboarding/i18n/pl.json +10 -1
- package/src/modules/onboarding/lib/deferred-provisioning.ts +295 -0
- package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
- package/src/modules/onboarding/lib/provisioning.ts +35 -0
- package/src/modules/onboarding/lib/service.ts +33 -0
- package/src/modules/onboarding/lib/verify-base-url.ts +107 -0
- package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/onboarding/migrations/Migration20260611120000.ts +13 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:onboarding] found
|
|
1
|
+
[build:onboarding] found 33 entry points
|
|
2
2
|
[build:onboarding] built successfully
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
import { withClient } from "@open-mercato/core/helpers/integration/dbFixtures";
|
|
4
|
+
const integrationMeta = {
|
|
5
|
+
dependsOnModules: ["onboarding"],
|
|
6
|
+
requiredEnvVars: ["SELF_SERVICE_ONBOARDING_ENABLED"],
|
|
7
|
+
requiredAnyEnvVars: ["CONSENT_INTEGRITY_SECRET", "AUTH_SECRET", "NEXTAUTH_SECRET", "JWT_SECRET"]
|
|
8
|
+
};
|
|
9
|
+
const ONBOARDING_PASSWORD = "IntegrationPass123!";
|
|
10
|
+
const BASE_URL = process.env.BASE_URL?.trim() || "http://localhost:3000";
|
|
11
|
+
function hashToken(token) {
|
|
12
|
+
return createHash("sha256").update(token).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
async function replaceVerificationToken(email, token) {
|
|
15
|
+
return withClient(async (client) => {
|
|
16
|
+
const result = await client.query(
|
|
17
|
+
`update onboarding_requests
|
|
18
|
+
set token_hash = $2,
|
|
19
|
+
expires_at = now() + interval '24 hours',
|
|
20
|
+
updated_at = now()
|
|
21
|
+
where email = $1
|
|
22
|
+
returning id`,
|
|
23
|
+
[email, hashToken(token)]
|
|
24
|
+
);
|
|
25
|
+
expect(result.rowCount, "onboarding request should exist after submitting the form").toBe(1);
|
|
26
|
+
return result.rows[0].id;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function readCompletedRequest(requestId) {
|
|
30
|
+
return withClient(async (client) => {
|
|
31
|
+
const result = await client.query(
|
|
32
|
+
`select id, status, tenant_id, organization_id, user_id, marketing_consent, completed_at
|
|
33
|
+
from onboarding_requests
|
|
34
|
+
where id = $1`,
|
|
35
|
+
[requestId]
|
|
36
|
+
);
|
|
37
|
+
expect(result.rowCount, "completed onboarding request should remain queryable").toBe(1);
|
|
38
|
+
return result.rows[0];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function readMarketingConsent(userId) {
|
|
42
|
+
return withClient(async (client) => {
|
|
43
|
+
const result = await client.query(
|
|
44
|
+
`select consent_type, is_granted, source, integrity_hash, granted_at
|
|
45
|
+
from user_consents
|
|
46
|
+
where user_id = $1 and consent_type = 'marketing_email'
|
|
47
|
+
order by created_at desc
|
|
48
|
+
limit 1`,
|
|
49
|
+
[userId]
|
|
50
|
+
);
|
|
51
|
+
expect(result.rowCount, "marketing consent should be persisted for the onboarded user").toBe(1);
|
|
52
|
+
return result.rows[0];
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
test.describe("TC-ONB-001: self-service onboarding with marketing consent", () => {
|
|
56
|
+
test("does not offer tenant login while workspace preparation is still running", async ({ page }) => {
|
|
57
|
+
const tenantId = "33333333-3333-4333-8333-333333333333";
|
|
58
|
+
await page.route("**/api/onboarding/onboarding/status?**", async (route) => {
|
|
59
|
+
await route.fulfill({
|
|
60
|
+
status: 200,
|
|
61
|
+
contentType: "application/json",
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
ok: true,
|
|
64
|
+
status: "processing",
|
|
65
|
+
ready: false,
|
|
66
|
+
emailSent: false,
|
|
67
|
+
tenantId,
|
|
68
|
+
loginUrl: null
|
|
69
|
+
})
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
await page.goto(`/onboarding/preparing?tenant=${tenantId}`);
|
|
73
|
+
await expect(page.getByText("We are preparing your workspace")).toBeVisible();
|
|
74
|
+
await expect(page.getByRole("link", { name: "Open tenant login" })).toHaveCount(0);
|
|
75
|
+
await expect(page.getByRole("link", { name: "Go to home page" })).toBeVisible();
|
|
76
|
+
});
|
|
77
|
+
test("shows a retryable error when workspace status polling fails", async ({ page }) => {
|
|
78
|
+
const tenantId = "33333333-3333-4333-8333-333333333333";
|
|
79
|
+
await page.route("**/api/onboarding/onboarding/status?**", async (route) => {
|
|
80
|
+
await route.fulfill({
|
|
81
|
+
status: 400,
|
|
82
|
+
contentType: "application/json",
|
|
83
|
+
body: JSON.stringify({ ok: false, error: "Invalid request origin" })
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
await page.goto(`/onboarding/preparing?tenant=${tenantId}`);
|
|
87
|
+
await expect(page.getByText("We are preparing your workspace")).toBeVisible();
|
|
88
|
+
const statusAlert = page.getByText("Workspace status check failed").locator("..");
|
|
89
|
+
await expect(statusAlert).toContainText("Invalid request origin");
|
|
90
|
+
await expect(page.getByRole("link", { name: "Open tenant login" })).toHaveCount(0);
|
|
91
|
+
});
|
|
92
|
+
test("creates the workspace, verifies from the email link, and logs in to the new tenant", async ({ page }) => {
|
|
93
|
+
const unique = randomUUID().slice(0, 8);
|
|
94
|
+
const email = `qa-onboarding-${unique}@example.test`;
|
|
95
|
+
const organizationName = `QA Onboarding ${unique}`;
|
|
96
|
+
const token = `integration-onboarding-${randomUUID().replace(/-/g, "")}`;
|
|
97
|
+
await page.goto("/onboarding");
|
|
98
|
+
await expect(page.getByText("Create your Open Mercato workspace")).toBeVisible();
|
|
99
|
+
await page.getByLabel("Work email").fill(email);
|
|
100
|
+
await page.getByLabel("First name").fill("QA");
|
|
101
|
+
await page.getByLabel("Last name").fill("Onboarding");
|
|
102
|
+
await page.getByLabel("Organization name").fill(organizationName);
|
|
103
|
+
await page.locator('input[name="password"]').fill(ONBOARDING_PASSWORD);
|
|
104
|
+
await page.locator('input[name="confirmPassword"]').fill(ONBOARDING_PASSWORD);
|
|
105
|
+
await page.locator("#terms").click();
|
|
106
|
+
await page.locator("#marketingConsent").click();
|
|
107
|
+
await page.getByRole("button", { name: "Send verification email" }).click();
|
|
108
|
+
await expect(page.getByRole("status")).toContainText("Check your inbox");
|
|
109
|
+
await expect(page.getByRole("status")).toContainText(email);
|
|
110
|
+
const requestId = await replaceVerificationToken(email, token);
|
|
111
|
+
const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;
|
|
112
|
+
await page.setContent(`<a href="${verifyUrl}">Verify workspace</a>`);
|
|
113
|
+
await page.getByRole("link", { name: "Verify workspace" }).click();
|
|
114
|
+
await expect(page).toHaveURL(/\/onboarding\/preparing\?tenant=[0-9a-f-]+/);
|
|
115
|
+
await expect(page.getByText("We are preparing your workspace")).toBeVisible();
|
|
116
|
+
const completed = await readCompletedRequest(requestId);
|
|
117
|
+
expect(completed.status).toBe("completed");
|
|
118
|
+
expect(completed.marketing_consent).toBe(true);
|
|
119
|
+
expect(completed.completed_at).toBeTruthy();
|
|
120
|
+
expect(completed.tenant_id, "tenant id should be recorded").toBeTruthy();
|
|
121
|
+
expect(completed.organization_id, "organization id should be recorded").toBeTruthy();
|
|
122
|
+
expect(completed.user_id, "user id should be recorded").toBeTruthy();
|
|
123
|
+
const consent = await readMarketingConsent(completed.user_id);
|
|
124
|
+
expect(consent.consent_type).toBe("marketing_email");
|
|
125
|
+
expect(consent.is_granted).toBe(true);
|
|
126
|
+
expect(consent.source).toBeTruthy();
|
|
127
|
+
expect(consent.granted_at).toBeTruthy();
|
|
128
|
+
expect(consent.integrity_hash, "consent integrity hash should be computed instead of redirecting to status=error").toBeTruthy();
|
|
129
|
+
await page.goto(`/login?tenant=${encodeURIComponent(completed.tenant_id)}`);
|
|
130
|
+
await expect(page.locator('form[data-auth-ready="1"]')).toBeVisible();
|
|
131
|
+
await expect(page.getByText(/You're logging in to/i)).toBeVisible();
|
|
132
|
+
await page.getByLabel("Email").fill(email);
|
|
133
|
+
await page.getByLabel("Password", { exact: true }).fill(ONBOARDING_PASSWORD);
|
|
134
|
+
await page.getByRole("button", { name: "Sign in" }).click();
|
|
135
|
+
await expect(page).toHaveURL(/\/backend(?:\/.*)?$/);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
export {
|
|
139
|
+
integrationMeta
|
|
140
|
+
};
|
|
141
|
+
//# sourceMappingURL=TC-ONB-001-self-service-consent.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { createHash, randomUUID } from 'node:crypto';\nimport { expect, test } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingRequestRow = {\n id: string;\n status: string;\n tenant_id: string | null;\n organization_id: string | null;\n user_id: string | null;\n marketing_consent: boolean | null;\n completed_at: Date | null;\n};\n\ntype UserConsentRow = {\n consent_type: string;\n is_granted: boolean;\n source: string | null;\n integrity_hash: string | null;\n granted_at: Date | null;\n};\n\nconst ONBOARDING_PASSWORD = 'IntegrationPass123!';\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email = $1\n returning id`,\n [email, hashToken(token)],\n );\n expect(result.rowCount, 'onboarding request should exist after submitting the form').toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readCompletedRequest(requestId: string): Promise<OnboardingRequestRow> {\n return withClient(async (client) => {\n const result = await client.query<OnboardingRequestRow>(\n `select id, status, tenant_id, organization_id, user_id, marketing_consent, completed_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, 'completed onboarding request should remain queryable').toBe(1);\n return result.rows[0];\n });\n}\n\nasync function readMarketingConsent(userId: string): Promise<UserConsentRow> {\n return withClient(async (client) => {\n const result = await client.query<UserConsentRow>(\n `select consent_type, is_granted, source, integrity_hash, granted_at\n from user_consents\n where user_id = $1 and consent_type = 'marketing_email'\n order by created_at desc\n limit 1`,\n [userId],\n );\n expect(result.rowCount, 'marketing consent should be persisted for the onboarded user').toBe(1);\n return result.rows[0];\n });\n}\n\ntest.describe('TC-ONB-001: self-service onboarding with marketing consent', () => {\n test('does not offer tenant login while workspace preparation is still running', async ({ page }) => {\n const tenantId = '33333333-3333-4333-8333-333333333333';\n await page.route('**/api/onboarding/onboarding/status?**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n ok: true,\n status: 'processing',\n ready: false,\n emailSent: false,\n tenantId,\n loginUrl: null,\n }),\n });\n });\n\n await page.goto(`/onboarding/preparing?tenant=${tenantId}`);\n\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);\n await expect(page.getByRole('link', { name: 'Go to home page' })).toBeVisible();\n });\n\n test('shows a retryable error when workspace status polling fails', async ({ page }) => {\n const tenantId = '33333333-3333-4333-8333-333333333333';\n await page.route('**/api/onboarding/onboarding/status?**', async (route) => {\n await route.fulfill({\n status: 400,\n contentType: 'application/json',\n body: JSON.stringify({ ok: false, error: 'Invalid request origin' }),\n });\n });\n\n await page.goto(`/onboarding/preparing?tenant=${tenantId}`);\n\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const statusAlert = page.getByText('Workspace status check failed').locator('..');\n await expect(statusAlert).toContainText('Invalid request origin');\n await expect(page.getByRole('link', { name: 'Open tenant login' })).toHaveCount(0);\n });\n\n test('creates the workspace, verifies from the email link, and logs in to the new tenant', async ({ page }) => {\n const unique = randomUUID().slice(0, 8);\n const email = `qa-onboarding-${unique}@example.test`;\n const organizationName = `QA Onboarding ${unique}`;\n const token = `integration-onboarding-${randomUUID().replace(/-/g, '')}`;\n\n await page.goto('/onboarding');\n await expect(page.getByText('Create your Open Mercato workspace')).toBeVisible();\n\n await page.getByLabel('Work email').fill(email);\n await page.getByLabel('First name').fill('QA');\n await page.getByLabel('Last name').fill('Onboarding');\n await page.getByLabel('Organization name').fill(organizationName);\n await page.locator('input[name=\"password\"]').fill(ONBOARDING_PASSWORD);\n await page.locator('input[name=\"confirmPassword\"]').fill(ONBOARDING_PASSWORD);\n await page.locator('#terms').click();\n await page.locator('#marketingConsent').click();\n await page.getByRole('button', { name: 'Send verification email' }).click();\n\n await expect(page.getByRole('status')).toContainText('Check your inbox');\n await expect(page.getByRole('status')).toContainText(email);\n\n const requestId = await replaceVerificationToken(email, token);\n const verifyUrl = `${BASE_URL}/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`;\n\n await page.setContent(`<a href=\"${verifyUrl}\">Verify workspace</a>`);\n await page.getByRole('link', { name: 'Verify workspace' }).click();\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n\n const completed = await readCompletedRequest(requestId);\n expect(completed.status).toBe('completed');\n expect(completed.marketing_consent).toBe(true);\n expect(completed.completed_at).toBeTruthy();\n expect(completed.tenant_id, 'tenant id should be recorded').toBeTruthy();\n expect(completed.organization_id, 'organization id should be recorded').toBeTruthy();\n expect(completed.user_id, 'user id should be recorded').toBeTruthy();\n\n const consent = await readMarketingConsent(completed.user_id!);\n expect(consent.consent_type).toBe('marketing_email');\n expect(consent.is_granted).toBe(true);\n expect(consent.source).toBeTruthy();\n expect(consent.granted_at).toBeTruthy();\n expect(consent.integrity_hash, 'consent integrity hash should be computed instead of redirecting to status=error').toBeTruthy();\n\n await page.goto(`/login?tenant=${encodeURIComponent(completed.tenant_id!)}`);\n await expect(page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await expect(page.getByText(/You're logging in to/i)).toBeVisible();\n await page.getByLabel('Email').fill(email);\n await page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await page.getByRole('button', { name: 'Sign in' }).click();\n await expect(page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { expect, test } from "@playwright/test";
|
|
3
|
+
import { withClient } from "@open-mercato/core/helpers/integration/dbFixtures";
|
|
4
|
+
const integrationMeta = {
|
|
5
|
+
dependsOnModules: ["onboarding"],
|
|
6
|
+
requiredEnvVars: ["SELF_SERVICE_ONBOARDING_ENABLED"],
|
|
7
|
+
requiredAnyEnvVars: ["CONSENT_INTEGRITY_SECRET", "AUTH_SECRET", "NEXTAUTH_SECRET", "JWT_SECRET"]
|
|
8
|
+
};
|
|
9
|
+
const BASE_URL = process.env.BASE_URL?.trim() || "http://localhost:3000";
|
|
10
|
+
const ONBOARDING_PASSWORD = "ParallelPass123!";
|
|
11
|
+
function hashToken(token) {
|
|
12
|
+
return createHash("sha256").update(token).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
async function replaceVerificationToken(email, token) {
|
|
15
|
+
return withClient(async (client) => {
|
|
16
|
+
const result = await client.query(
|
|
17
|
+
`update onboarding_requests
|
|
18
|
+
set token_hash = $2,
|
|
19
|
+
expires_at = now() + interval '24 hours',
|
|
20
|
+
updated_at = now()
|
|
21
|
+
where email = $1
|
|
22
|
+
returning id`,
|
|
23
|
+
[email, hashToken(token)]
|
|
24
|
+
);
|
|
25
|
+
expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
|
|
26
|
+
return result.rows[0].id;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function readPreparationState(requestId) {
|
|
30
|
+
return withClient(async (client) => {
|
|
31
|
+
const result = await client.query(
|
|
32
|
+
`select preparation_completed_at, preparation_started_at
|
|
33
|
+
from onboarding_requests
|
|
34
|
+
where id = $1`,
|
|
35
|
+
[requestId]
|
|
36
|
+
);
|
|
37
|
+
expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);
|
|
38
|
+
return result.rows[0];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function waitForPreparationComplete(requestId) {
|
|
42
|
+
const deadline = Date.now() + 6e4;
|
|
43
|
+
let lastState = null;
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
lastState = await readPreparationState(requestId);
|
|
46
|
+
if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
48
|
+
}
|
|
49
|
+
expect(lastState?.preparation_started_at, "deferred preparation lease should be cleared after completion").toBeNull();
|
|
50
|
+
expect(lastState?.preparation_completed_at, "workspace preparation should complete").toBeTruthy();
|
|
51
|
+
}
|
|
52
|
+
async function submitOnboarding(request, tenant) {
|
|
53
|
+
const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {
|
|
54
|
+
data: {
|
|
55
|
+
email: tenant.email,
|
|
56
|
+
firstName: "Parallel",
|
|
57
|
+
lastName: "Login",
|
|
58
|
+
organizationName: tenant.organizationName,
|
|
59
|
+
password: ONBOARDING_PASSWORD,
|
|
60
|
+
confirmPassword: ONBOARDING_PASSWORD,
|
|
61
|
+
termsAccepted: true,
|
|
62
|
+
marketingConsent: true,
|
|
63
|
+
locale: "en"
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);
|
|
67
|
+
}
|
|
68
|
+
async function verifyTenantInBrowser(browser, token) {
|
|
69
|
+
const context = await browser.newContext({ baseURL: BASE_URL });
|
|
70
|
+
const page = await context.newPage();
|
|
71
|
+
const refreshRequests = [];
|
|
72
|
+
page.on("request", (browserRequest) => {
|
|
73
|
+
const url = browserRequest.url();
|
|
74
|
+
if (url.includes("/api/auth/session/refresh")) refreshRequests.push(url);
|
|
75
|
+
});
|
|
76
|
+
await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);
|
|
77
|
+
await expect(page).toHaveURL(/\/onboarding\/preparing\?tenant=[0-9a-f-]+/);
|
|
78
|
+
await expect(page.getByText("We are preparing your workspace")).toBeVisible();
|
|
79
|
+
const tenantId = new URL(page.url()).searchParams.get("tenant");
|
|
80
|
+
expect(tenantId, "verify redirect should include tenant id").toMatch(
|
|
81
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
82
|
+
);
|
|
83
|
+
return { context, page, refreshRequests, tenantId };
|
|
84
|
+
}
|
|
85
|
+
async function pollOnboardingStatus(request, tenantId) {
|
|
86
|
+
return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {
|
|
87
|
+
headers: {
|
|
88
|
+
Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async function loginAndAssertBackend(session, tenant) {
|
|
93
|
+
expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();
|
|
94
|
+
await expect(session.page).toHaveURL(new RegExp(`/login\\?tenant=${tenant.tenantId}`));
|
|
95
|
+
await expect(session.page.locator('form[data-auth-ready="1"]')).toBeVisible();
|
|
96
|
+
await session.page.getByLabel("Email").fill(tenant.email);
|
|
97
|
+
await session.page.getByLabel("Password", { exact: true }).fill(ONBOARDING_PASSWORD);
|
|
98
|
+
await session.page.getByRole("button", { name: "Sign in" }).click();
|
|
99
|
+
await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\/backend(?:\/.*)?$/);
|
|
100
|
+
await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);
|
|
101
|
+
const profileResult = await session.page.evaluate(async () => {
|
|
102
|
+
const response = await fetch("/api/auth/profile", { credentials: "include" });
|
|
103
|
+
const body = await response.json().catch(() => null);
|
|
104
|
+
return { status: response.status, body };
|
|
105
|
+
});
|
|
106
|
+
expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);
|
|
107
|
+
const profile = profileResult.body;
|
|
108
|
+
expect(profile.email).toBe(tenant.email);
|
|
109
|
+
expect(profile.roles).toContain("admin");
|
|
110
|
+
await session.page.goto("/backend");
|
|
111
|
+
await expect(session.page).toHaveURL(/\/backend(?:\/.*)?$/);
|
|
112
|
+
expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);
|
|
113
|
+
}
|
|
114
|
+
test.describe("TC-ONB-002: multi-tenant onboarding parallel login", () => {
|
|
115
|
+
test("creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated", async ({
|
|
116
|
+
browser,
|
|
117
|
+
request
|
|
118
|
+
}) => {
|
|
119
|
+
test.setTimeout(12e4);
|
|
120
|
+
const unique = randomUUID().slice(0, 8);
|
|
121
|
+
const tenants = [1, 2].map((index) => ({
|
|
122
|
+
email: `qa-onboarding-parallel-${unique}-${index}@example.test`,
|
|
123
|
+
organizationName: `QA Onboarding Parallel ${unique} ${index}`,
|
|
124
|
+
token: `integration-parallel-${index}-${randomUUID().replace(/-/g, "")}`
|
|
125
|
+
}));
|
|
126
|
+
await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));
|
|
127
|
+
for (const tenant of tenants) {
|
|
128
|
+
tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);
|
|
129
|
+
}
|
|
130
|
+
const browserSessions = await Promise.all(
|
|
131
|
+
tenants.map(async (tenant) => {
|
|
132
|
+
const session = await verifyTenantInBrowser(browser, tenant.token);
|
|
133
|
+
tenant.tenantId = session.tenantId;
|
|
134
|
+
return session;
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
try {
|
|
138
|
+
const statusResponses = await Promise.all(
|
|
139
|
+
tenants.flatMap(
|
|
140
|
+
(tenant) => Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId))
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
for (const response of statusResponses) {
|
|
144
|
+
expect(response.status(), "status polling should not exhaust the app DB pool").toBe(200);
|
|
145
|
+
}
|
|
146
|
+
await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId)));
|
|
147
|
+
await Promise.all(
|
|
148
|
+
tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant))
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
await Promise.all(browserSessions.map((session) => session.context.close()));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
export {
|
|
156
|
+
integrationMeta
|
|
157
|
+
};
|
|
158
|
+
//# sourceMappingURL=TC-ONB-002-multi-tenant-parallel-login.spec.js.map
|
package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts"],
|
|
4
|
+
"sourcesContent": ["// Adapted from PR #3007 (pkarw) \u2014 multi-tenant parallel onboarding repro for the\n// 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at\n// lease column introduced by the single-flight deferred-provisioning fix.\nimport { createHash, randomUUID } from 'node:crypto';\nimport { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingTenant = {\n email: string;\n organizationName: string;\n token: string;\n requestId?: string;\n tenantId?: string;\n};\n\ntype BrowserTenantSession = {\n context: BrowserContext;\n page: Page;\n refreshRequests: string[];\n};\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\nconst ONBOARDING_PASSWORD = 'ParallelPass123!';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email = $1\n returning id`,\n [email, hashToken(token)],\n );\n expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readPreparationState(requestId: string): Promise<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n}> {\n return withClient(async (client) => {\n const result = await client.query<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n }>(\n `select preparation_completed_at, preparation_started_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);\n return result.rows[0];\n });\n}\n\nasync function waitForPreparationComplete(requestId: string): Promise<void> {\n const deadline = Date.now() + 60_000;\n let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;\n while (Date.now() < deadline) {\n lastState = await readPreparationState(requestId);\n if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();\n expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();\n}\n\nasync function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {\n const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {\n data: {\n email: tenant.email,\n firstName: 'Parallel',\n lastName: 'Login',\n organizationName: tenant.organizationName,\n password: ONBOARDING_PASSWORD,\n confirmPassword: ONBOARDING_PASSWORD,\n termsAccepted: true,\n marketingConsent: true,\n locale: 'en',\n },\n });\n expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);\n}\n\nasync function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {\n const context = await browser.newContext({ baseURL: BASE_URL });\n const page = await context.newPage();\n const refreshRequests: string[] = [];\n page.on('request', (browserRequest) => {\n const url = browserRequest.url();\n if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);\n });\n\n await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const tenantId = new URL(page.url()).searchParams.get('tenant');\n expect(tenantId, 'verify redirect should include tenant id').toMatch(\n /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,\n );\n return { context, page, refreshRequests, tenantId: tenantId! };\n}\n\nasync function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {\n return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {\n headers: {\n Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,\n },\n });\n}\n\nasync function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {\n expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();\n await expect(session.page).toHaveURL(new RegExp(`/login\\\\?tenant=${tenant.tenantId}`));\n await expect(session.page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await session.page.getByLabel('Email').fill(tenant.email);\n await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await session.page.getByRole('button', { name: 'Sign in' }).click();\n await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\\/backend(?:\\/.*)?$/);\n await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);\n\n const profileResult = await session.page.evaluate(async () => {\n const response = await fetch('/api/auth/profile', { credentials: 'include' });\n const body = await response.json().catch(() => null);\n return { status: response.status, body };\n });\n expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);\n const profile = profileResult.body;\n expect(profile.email).toBe(tenant.email);\n expect(profile.roles).toContain('admin');\n\n await session.page.goto('/backend');\n await expect(session.page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);\n}\n\ntest.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {\n test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({\n browser,\n request,\n }) => {\n // Two full onboardings + parallel browser logins + DB-polled preparation\n // far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).\n test.setTimeout(120_000);\n const unique = randomUUID().slice(0, 8);\n const tenants: OnboardingTenant[] = [1, 2].map((index) => ({\n email: `qa-onboarding-parallel-${unique}-${index}@example.test`,\n organizationName: `QA Onboarding Parallel ${unique} ${index}`,\n token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,\n }));\n\n await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));\n\n for (const tenant of tenants) {\n tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);\n }\n\n const browserSessions = await Promise.all(\n tenants.map(async (tenant) => {\n const session = await verifyTenantInBrowser(browser, tenant.token);\n tenant.tenantId = session.tenantId;\n return session;\n }),\n );\n\n try {\n const statusResponses = await Promise.all(\n tenants.flatMap((tenant) =>\n Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),\n ),\n );\n for (const response of statusResponses) {\n expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);\n }\n\n await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId!)));\n await Promise.all(\n tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),\n );\n } finally {\n await Promise.all(browserSessions.map((session) => session.context.close()));\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { NextResponse } from "next/server";
|
|
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 {
|
|
4
|
+
import { assertAllowedAppOrigin, mapSecurityEmailUrlError } from "@open-mercato/shared/lib/url";
|
|
5
5
|
import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/lib/service";
|
|
6
6
|
import { sendWorkspaceReadyEmail } from "@open-mercato/onboarding/modules/onboarding/lib/ready-email";
|
|
7
|
+
import {
|
|
8
|
+
resolveProvisioningIds,
|
|
9
|
+
runDeferredProvisioning
|
|
10
|
+
} from "@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning";
|
|
11
|
+
import { isPreparationClaimActive } from "@open-mercato/onboarding/modules/onboarding/lib/preparation-claim";
|
|
7
12
|
const metadata = {
|
|
8
13
|
path: "/onboarding/onboarding/status",
|
|
9
14
|
GET: {
|
|
@@ -42,9 +47,8 @@ async function GET(req) {
|
|
|
42
47
|
if (!loginTenantCookie || loginTenantCookie !== parsed.data.tenantId) {
|
|
43
48
|
return NextResponse.json({ ok: false, error: "Not authorized for this tenant." }, { status: 403 });
|
|
44
49
|
}
|
|
45
|
-
let baseUrl;
|
|
46
50
|
try {
|
|
47
|
-
|
|
51
|
+
assertAllowedAppOrigin(req);
|
|
48
52
|
} catch (error) {
|
|
49
53
|
const mapped = mapSecurityEmailUrlError(error, {
|
|
50
54
|
scope: "onboarding.status",
|
|
@@ -60,23 +64,37 @@ async function GET(req) {
|
|
|
60
64
|
if (!request) {
|
|
61
65
|
return NextResponse.json({ ok: false, error: "Onboarding request not found." }, { status: 404 });
|
|
62
66
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
const provisioningIds = resolveProvisioningIds(request);
|
|
68
|
+
if (provisioningIds && request.status === "processing") {
|
|
69
|
+
await service.markCompleted(request, provisioningIds);
|
|
70
|
+
}
|
|
71
|
+
if (provisioningIds && request.status === "completed" && !request.preparationCompletedAt && !isPreparationClaimActive(request.preparationStartedAt)) {
|
|
72
|
+
after(async () => {
|
|
73
|
+
await runDeferredProvisioning({
|
|
69
74
|
requestId: request.id,
|
|
70
|
-
tenantId:
|
|
75
|
+
tenantId: provisioningIds.tenantId,
|
|
76
|
+
organizationId: provisioningIds.organizationId
|
|
71
77
|
});
|
|
72
|
-
}
|
|
73
|
-
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const emailSent = Boolean(request.readyEmailSentAt);
|
|
81
|
+
const ready = request.status === "completed" && Boolean(request.preparationCompletedAt);
|
|
82
|
+
const loginUrl = ready && request.tenantId ? `/login?tenant=${encodeURIComponent(request.tenantId)}` : null;
|
|
83
|
+
if (ready && request.tenantId && !request.readyEmailSentAt) {
|
|
84
|
+
const readyTenantId = request.tenantId;
|
|
85
|
+
after(async () => {
|
|
86
|
+
await sendWorkspaceReadyEmail({
|
|
74
87
|
requestId: request.id,
|
|
75
|
-
tenantId:
|
|
76
|
-
|
|
77
|
-
error
|
|
88
|
+
tenantId: readyTenantId
|
|
89
|
+
}).catch((error) => {
|
|
90
|
+
console.error("[onboarding.status] ready email retry failed", {
|
|
91
|
+
requestId: request.id,
|
|
92
|
+
tenantId: readyTenantId,
|
|
93
|
+
organizationId: request.organizationId,
|
|
94
|
+
error
|
|
95
|
+
});
|
|
78
96
|
});
|
|
79
|
-
}
|
|
97
|
+
});
|
|
80
98
|
}
|
|
81
99
|
return NextResponse.json({
|
|
82
100
|
ok: true,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/onboarding/api/get/onboarding/status.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 {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;
|
|
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 console.error('[onboarding.status] ready email retry failed', {\n requestId: request.id,\n tenantId: readyTenantId,\n organizationId: request.organizationId,\n 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
|
}
|