@open-mercato/onboarding 0.6.5-develop.5382.1.f542de69af → 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,7 @@
1
+ {
2
+ "version": 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 { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'\nimport { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'\nimport { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'\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\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\nasync function rebuildTenantQueryIndexes(args: {\n em: EntityManager\n tenantId: string\n organizationId: string\n}) {\n const coverageRefreshKeys = new Set<string>()\n try {\n const allEntities = getEntityIds()\n const entityIds = flattenSystemEntityIds(allEntities)\n for (const entityType of entityIds) {\n try {\n await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId })\n } catch (error) {\n console.error('[onboarding.verify] failed to purge query index scope', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n try {\n await reindexEntity(args.em, {\n entityType,\n tenantId: args.tenantId,\n force: true,\n emitVectorizeEvents: false,\n vectorService: null,\n })\n } catch (error) {\n console.error('[onboarding.verify] failed to reindex entity', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`)\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`)\n }\n } catch (error) {\n console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId: args.tenantId, error })\n }\n\n if (!coverageRefreshKeys.size) return\n\n for (const entry of coverageRefreshKeys) {\n const [entityType, tenantKey, orgKey] = entry.split('|')\n const orgScope = orgKey === '__null__' ? null : orgKey\n try {\n await refreshCoverageSnapshot(\n args.em,\n {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\n withDeleted: false,\n },\n )\n } catch (error) {\n console.error('[onboarding.verify] failed to refresh coverage snapshot', {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\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 const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n // Heartbeat: keep the lease fresh while legitimately working so a slow run\n // (many modules \u00D7 15s seed timeout + rebuild) can never look stale and get\n // double-claimed by a later poll.\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 rebuild runs BEFORE the completion flag: preparationCompletedAt is the\n // terminal gate for both the status-route scheduling and claimPreparation,\n // so a runner that dies mid-rebuild must leave the flag unset \u2014 the stale\n // claim then makes the whole chain reclaimable and the rebuild self-heals.\n // rebuildTenantQueryIndexes never throws (it logs per-entity failures).\n await service.renewPreparation(args.requestId, new Date()).catch(() => {})\n await rebuildTenantQueryIndexes({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n\n await markWorkspaceReady({\n requestId: args.requestId,\n service,\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": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,10 @@
1
+ const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1e3;
2
+ function isPreparationClaimActive(startedAt, now = /* @__PURE__ */ new Date()) {
3
+ if (!startedAt) return false;
4
+ return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS;
5
+ }
6
+ export {
7
+ PREPARATION_CLAIM_STALE_MS,
8
+ isPreparationClaimActive
9
+ };
10
+ //# sourceMappingURL=preparation-claim.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/onboarding/lib/preparation-claim.ts"],
4
+ "sourcesContent": ["export const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1000\n\nexport function isPreparationClaimActive(startedAt: Date | null | undefined, now: Date = new Date()): boolean {\n if (!startedAt) return false\n return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS\n}\n"],
5
+ "mappings": "AAAO,MAAM,6BAA6B,KAAK,KAAK;AAE7C,SAAS,yBAAyB,WAAoC,MAAY,oBAAI,KAAK,GAAY;AAC5G,MAAI,CAAC,UAAW,QAAO;AACvB,SAAO,UAAU,QAAQ,IAAI,IAAI,QAAQ,IAAI;AAC/C;",
6
+ "names": []
7
+ }
@@ -0,0 +1,18 @@
1
+ import { isUniqueViolation } from "@open-mercato/shared/lib/crud/errors";
2
+ async function runBestEffortProvisioningStep(step, run, logger = console) {
3
+ try {
4
+ await run();
5
+ return { ok: true };
6
+ } catch (error) {
7
+ if (isUniqueViolation(error)) {
8
+ logger.info("[onboarding.verify] non-fatal provisioning step skipped (already applied)", { step });
9
+ return { ok: false, error };
10
+ }
11
+ logger.error("[onboarding.verify] non-fatal provisioning step failed", { step, error });
12
+ return { ok: false, error };
13
+ }
14
+ }
15
+ export {
16
+ runBestEffortProvisioningStep
17
+ };
18
+ //# sourceMappingURL=provisioning.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/onboarding/lib/provisioning.ts"],
4
+ "sourcesContent": ["import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\n\nexport type BestEffortProvisioningResult = { ok: true } | { ok: false; error: unknown }\n\ntype BestEffortLogger = Pick<Console, 'error' | 'info'>\n\n/**\n * Runs a non-essential provisioning step without letting its failure abort\n * onboarding. The tenant/org/primary user are created before these steps, so a\n * throw or timeout here (reference-data seeding, marketing-consent recording)\n * must NOT strand a freshly provisioned workspace \u2014 the verify handler still has\n * to reach markCompleted so the user can sign in. Failures are logged for\n * follow-up and reported back to the caller, never re-thrown.\n */\nexport async function runBestEffortProvisioningStep(\n step: string,\n run: () => Promise<void>,\n logger: BestEffortLogger = console,\n): Promise<BestEffortProvisioningResult> {\n try {\n await run()\n return { ok: true }\n } catch (error) {\n if (isUniqueViolation(error)) {\n // Expected when a concurrent verify / re-verify re-applies a step against\n // rows that already exist (seed hooks are not fully idempotent). The\n // workspace is already provisioned, so the collision is harmless \u2014 log at\n // info to keep genuine non-fatal failures visible.\n logger.info('[onboarding.verify] non-fatal provisioning step skipped (already applied)', { step })\n return { ok: false, error }\n }\n logger.error('[onboarding.verify] non-fatal provisioning step failed', { step, error })\n return { ok: false, error }\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,yBAAyB;AAclC,eAAsB,8BACpB,MACA,KACA,SAA2B,SACY;AACvC,MAAI;AACF,UAAM,IAAI;AACV,WAAO,EAAE,IAAI,KAAK;AAAA,EACpB,SAAS,OAAO;AACd,QAAI,kBAAkB,KAAK,GAAG;AAK5B,aAAO,KAAK,6EAA6E,EAAE,KAAK,CAAC;AACjG,aAAO,EAAE,IAAI,OAAO,MAAM;AAAA,IAC5B;AACA,WAAO,MAAM,0DAA0D,EAAE,MAAM,MAAM,CAAC;AACtF,WAAO,EAAE,IAAI,OAAO,MAAM;AAAA,EAC5B;AACF;",
6
+ "names": []
7
+ }
@@ -36,6 +36,7 @@ class OnboardingService {
36
36
  existing.organizationId = null;
37
37
  existing.userId = null;
38
38
  existing.lastEmailSentAt = now;
39
+ existing.preparationStartedAt = null;
39
40
  existing.preparationCompletedAt = null;
40
41
  existing.readyEmailSentAt = null;
41
42
  await this.em.flush();
@@ -126,8 +127,38 @@ class OnboardingService {
126
127
  request.readyEmailSentAt = sentAt;
127
128
  await this.em.flush();
128
129
  }
130
+ async claimPreparation(requestId, claimedAt, staleBefore) {
131
+ const claimedRows = await this.em.nativeUpdate(
132
+ OnboardingRequest,
133
+ {
134
+ id: requestId,
135
+ status: "completed",
136
+ preparationCompletedAt: null,
137
+ $or: [
138
+ { preparationStartedAt: null },
139
+ { preparationStartedAt: { $lt: staleBefore } }
140
+ ]
141
+ },
142
+ { preparationStartedAt: claimedAt, updatedAt: /* @__PURE__ */ new Date() }
143
+ );
144
+ return claimedRows > 0;
145
+ }
146
+ async renewPreparation(requestId, renewedAt) {
147
+ const renewedRows = await this.em.nativeUpdate(
148
+ OnboardingRequest,
149
+ {
150
+ id: requestId,
151
+ status: "completed",
152
+ preparationCompletedAt: null,
153
+ preparationStartedAt: { $ne: null }
154
+ },
155
+ { preparationStartedAt: renewedAt, updatedAt: /* @__PURE__ */ new Date() }
156
+ );
157
+ return renewedRows > 0;
158
+ }
129
159
  async markPreparationCompleted(request, completedAt) {
130
160
  request.preparationCompletedAt = completedAt;
161
+ request.preparationStartedAt = null;
131
162
  await this.em.flush();
132
163
  }
133
164
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/lib/service.ts"],
4
- "sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport { hash } from 'bcryptjs'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { OnboardingRequest } from '../data/entities'\nimport type { OnboardingStartInput } from '../data/validators'\n\ntype CreateRequestOptions = {\n expiresInHours?: number\n}\n\nexport class OnboardingService {\n constructor(private readonly em: EntityManager) {}\n\n async createOrUpdateRequest(input: OnboardingStartInput, options: CreateRequestOptions = {}) {\n const expiresInHours = options.expiresInHours ?? 24\n const token = randomBytes(32).toString('hex')\n const tokenHash = hashToken(token)\n const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)\n const now = new Date()\n const passwordHash = await hash(input.password, 10)\n\n const existing = await this.em.findOne(OnboardingRequest, { email: input.email })\n if (existing) {\n const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt\n if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {\n const remainingMs = 10 * 60 * 1000 - (Date.now() - lastSentAt.getTime())\n const waitMinutes = Math.max(1, Math.ceil(remainingMs / (60 * 1000)))\n throw new Error(`PENDING_REQUEST:${waitMinutes}`)\n }\n existing.tokenHash = tokenHash\n existing.status = 'pending'\n existing.firstName = input.firstName\n existing.lastName = input.lastName\n existing.organizationName = input.organizationName\n existing.locale = input.locale ?? existing.locale ?? 'en'\n existing.termsAccepted = true\n existing.marketingConsent = input.marketingConsent ?? false\n existing.passwordHash = passwordHash\n existing.expiresAt = expiresAt\n existing.completedAt = null\n existing.processingStartedAt = null\n existing.tenantId = null\n existing.organizationId = null\n existing.userId = null\n existing.lastEmailSentAt = now\n existing.preparationCompletedAt = null\n existing.readyEmailSentAt = null\n await this.em.flush()\n return { request: existing, token }\n }\n\n const request = this.em.create(OnboardingRequest, {\n email: input.email,\n tokenHash,\n status: 'pending',\n firstName: input.firstName,\n lastName: input.lastName,\n organizationName: input.organizationName,\n locale: input.locale ?? 'en',\n termsAccepted: true,\n marketingConsent: input.marketingConsent ?? false,\n passwordHash,\n expiresAt,\n processingStartedAt: null,\n lastEmailSentAt: now,\n createdAt: now,\n updatedAt: now,\n })\n await this.em.persist(request).flush()\n return { request, token }\n }\n\n async findPendingByToken(token: string) {\n const tokenHash = hashToken(token)\n const now = new Date()\n return this.em.findOne(OnboardingRequest, {\n tokenHash,\n status: 'pending',\n expiresAt: { $gt: now } as any,\n })\n }\n\n async findByToken(token: string) {\n const tokenHash = hashToken(token)\n return this.em.findOne(OnboardingRequest, { tokenHash })\n }\n\n async findById(id: string) {\n return this.em.findOne(OnboardingRequest, { id })\n }\n\n async findLatestByTenantId(tenantId: string) {\n return this.em.findOne(\n OnboardingRequest,\n { tenantId, deletedAt: null },\n { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },\n )\n }\n\n async startProcessing(request: OnboardingRequest, startedAt: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'pending' },\n { status: 'processing', processingStartedAt: startedAt, updatedAt: new Date() },\n )\n if (claimedRows === 0) return false\n request.status = 'processing'\n request.processingStartedAt = startedAt\n return true\n }\n\n async resetProcessing(request: OnboardingRequest): Promise<boolean> {\n const revertedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'processing' },\n { status: 'pending', processingStartedAt: null, updatedAt: new Date() },\n )\n if (revertedRows === 0) return false\n request.status = 'pending'\n request.processingStartedAt = null\n return true\n }\n\n async updateProvisioningIds(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n await this.em.flush()\n }\n\n async markCompleted(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.status = 'completed'\n request.completedAt = new Date()\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n request.processingStartedAt = null\n request.passwordHash = null\n await this.em.flush()\n }\n\n async markReadyEmailSent(request: OnboardingRequest, sentAt: Date) {\n request.readyEmailSentAt = sentAt\n await this.em.flush()\n }\n\n async markPreparationCompleted(request: OnboardingRequest, completedAt: Date) {\n request.preparationCompletedAt = completedAt\n await this.em.flush()\n }\n}\n\nfunction hashToken(token: string) {\n return createHash('sha256').update(token).digest('hex')\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport { hash } from 'bcryptjs'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { OnboardingRequest } from '../data/entities'\nimport type { OnboardingStartInput } from '../data/validators'\n\ntype CreateRequestOptions = {\n expiresInHours?: number\n}\n\nexport class OnboardingService {\n constructor(private readonly em: EntityManager) {}\n\n async createOrUpdateRequest(input: OnboardingStartInput, options: CreateRequestOptions = {}) {\n const expiresInHours = options.expiresInHours ?? 24\n const token = randomBytes(32).toString('hex')\n const tokenHash = hashToken(token)\n const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000)\n const now = new Date()\n const passwordHash = await hash(input.password, 10)\n\n const existing = await this.em.findOne(OnboardingRequest, { email: input.email })\n if (existing) {\n const lastSentAt = existing.lastEmailSentAt ?? existing.updatedAt ?? existing.createdAt\n if (['pending', 'processing'].includes(existing.status) && lastSentAt && lastSentAt.getTime() > Date.now() - 10 * 60 * 1000) {\n const remainingMs = 10 * 60 * 1000 - (Date.now() - lastSentAt.getTime())\n const waitMinutes = Math.max(1, Math.ceil(remainingMs / (60 * 1000)))\n throw new Error(`PENDING_REQUEST:${waitMinutes}`)\n }\n existing.tokenHash = tokenHash\n existing.status = 'pending'\n existing.firstName = input.firstName\n existing.lastName = input.lastName\n existing.organizationName = input.organizationName\n existing.locale = input.locale ?? existing.locale ?? 'en'\n existing.termsAccepted = true\n existing.marketingConsent = input.marketingConsent ?? false\n existing.passwordHash = passwordHash\n existing.expiresAt = expiresAt\n existing.completedAt = null\n existing.processingStartedAt = null\n existing.tenantId = null\n existing.organizationId = null\n existing.userId = null\n existing.lastEmailSentAt = now\n existing.preparationStartedAt = null\n existing.preparationCompletedAt = null\n existing.readyEmailSentAt = null\n await this.em.flush()\n return { request: existing, token }\n }\n\n const request = this.em.create(OnboardingRequest, {\n email: input.email,\n tokenHash,\n status: 'pending',\n firstName: input.firstName,\n lastName: input.lastName,\n organizationName: input.organizationName,\n locale: input.locale ?? 'en',\n termsAccepted: true,\n marketingConsent: input.marketingConsent ?? false,\n passwordHash,\n expiresAt,\n processingStartedAt: null,\n lastEmailSentAt: now,\n createdAt: now,\n updatedAt: now,\n })\n await this.em.persist(request).flush()\n return { request, token }\n }\n\n async findPendingByToken(token: string) {\n const tokenHash = hashToken(token)\n const now = new Date()\n return this.em.findOne(OnboardingRequest, {\n tokenHash,\n status: 'pending',\n expiresAt: { $gt: now } as any,\n })\n }\n\n async findByToken(token: string) {\n const tokenHash = hashToken(token)\n return this.em.findOne(OnboardingRequest, { tokenHash })\n }\n\n async findById(id: string) {\n return this.em.findOne(OnboardingRequest, { id })\n }\n\n async findLatestByTenantId(tenantId: string) {\n return this.em.findOne(\n OnboardingRequest,\n { tenantId, deletedAt: null },\n { orderBy: { updatedAt: 'DESC', createdAt: 'DESC' } },\n )\n }\n\n async startProcessing(request: OnboardingRequest, startedAt: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'pending' },\n { status: 'processing', processingStartedAt: startedAt, updatedAt: new Date() },\n )\n if (claimedRows === 0) return false\n request.status = 'processing'\n request.processingStartedAt = startedAt\n return true\n }\n\n async resetProcessing(request: OnboardingRequest): Promise<boolean> {\n const revertedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n { id: request.id, status: 'processing' },\n { status: 'pending', processingStartedAt: null, updatedAt: new Date() },\n )\n if (revertedRows === 0) return false\n request.status = 'pending'\n request.processingStartedAt = null\n return true\n }\n\n async updateProvisioningIds(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n await this.em.flush()\n }\n\n async markCompleted(request: OnboardingRequest, data: { tenantId: string; organizationId: string; userId: string }) {\n request.status = 'completed'\n request.completedAt = new Date()\n request.tenantId = data.tenantId\n request.organizationId = data.organizationId\n request.userId = data.userId\n request.processingStartedAt = null\n request.passwordHash = null\n await this.em.flush()\n }\n\n async markReadyEmailSent(request: OnboardingRequest, sentAt: Date) {\n request.readyEmailSentAt = sentAt\n await this.em.flush()\n }\n\n async claimPreparation(requestId: string, claimedAt: Date, staleBefore: Date): Promise<boolean> {\n const claimedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n {\n id: requestId,\n status: 'completed',\n preparationCompletedAt: null,\n $or: [\n { preparationStartedAt: null },\n { preparationStartedAt: { $lt: staleBefore } },\n ],\n },\n { preparationStartedAt: claimedAt, updatedAt: new Date() },\n )\n return claimedRows > 0\n }\n\n async renewPreparation(requestId: string, renewedAt: Date): Promise<boolean> {\n const renewedRows = await this.em.nativeUpdate(\n OnboardingRequest,\n {\n id: requestId,\n status: 'completed',\n preparationCompletedAt: null,\n preparationStartedAt: { $ne: null },\n },\n { preparationStartedAt: renewedAt, updatedAt: new Date() },\n )\n return renewedRows > 0\n }\n\n async markPreparationCompleted(request: OnboardingRequest, completedAt: Date) {\n request.preparationCompletedAt = completedAt\n request.preparationStartedAt = null\n await this.em.flush()\n }\n}\n\nfunction hashToken(token: string) {\n return createHash('sha256').update(token).digest('hex')\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,86 @@
1
+ import {
2
+ AppOriginConfigurationError,
3
+ AppOriginRejectedError,
4
+ getSecurityEmailBaseUrl,
5
+ resolveRequestOrigin
6
+ } from "@open-mercato/shared/lib/url";
7
+ function normalizeOrigin(raw) {
8
+ try {
9
+ return new URL(raw).origin;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ function originPort(url) {
15
+ if (url.port) return url.port;
16
+ if (url.protocol === "https:") return "443";
17
+ if (url.protocol === "http:") return "80";
18
+ return "";
19
+ }
20
+ function isLoopbackHostname(hostname) {
21
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
22
+ }
23
+ function originsMatchForRedirect(configuredOrigin, requestOrigin) {
24
+ if (configuredOrigin === requestOrigin) return true;
25
+ try {
26
+ const configured = new URL(configuredOrigin);
27
+ const request = new URL(requestOrigin);
28
+ return configured.protocol === request.protocol && isLoopbackHostname(configured.hostname) && isLoopbackHostname(request.hostname) && originPort(configured) === originPort(request);
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function resolveVerifyRedirectBaseUrl(req, env = process.env) {
34
+ try {
35
+ const baseUrl = getSecurityEmailBaseUrl(req, env);
36
+ const configuredOrigin = normalizeOrigin(baseUrl);
37
+ const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url);
38
+ if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {
39
+ console.error("[onboarding.verify] APP_URL does not match verification request origin", {
40
+ requestUrl: req.url,
41
+ configuredOrigin,
42
+ requestOrigin
43
+ });
44
+ return {
45
+ ok: false,
46
+ status: "redirect_misconfigured",
47
+ message: "Onboarding verification redirect is misconfigured.",
48
+ redirectOrigin: requestOrigin,
49
+ httpStatus: 400
50
+ };
51
+ }
52
+ return { ok: true, baseUrl };
53
+ } catch (error) {
54
+ if (error instanceof AppOriginRejectedError) {
55
+ console.error("[onboarding.verify] rejected request origin for redirect base", {
56
+ requestUrl: req.url,
57
+ reason: error.message
58
+ });
59
+ return {
60
+ ok: false,
61
+ status: "origin_not_allowed",
62
+ message: "Onboarding verification request origin is not allowed.",
63
+ redirectOrigin: null,
64
+ httpStatus: 400
65
+ };
66
+ }
67
+ if (error instanceof AppOriginConfigurationError) {
68
+ console.error("[onboarding.verify] APP_URL is required for onboarding verification redirects", {
69
+ requestUrl: req.url,
70
+ reason: error.message
71
+ });
72
+ return {
73
+ ok: false,
74
+ status: "url_not_configured",
75
+ message: "Onboarding verification redirect is not configured.",
76
+ redirectOrigin: null,
77
+ httpStatus: 500
78
+ };
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+ export {
84
+ resolveVerifyRedirectBaseUrl
85
+ };
86
+ //# sourceMappingURL=verify-base-url.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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 console.error('[onboarding.verify] 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 console.error('[onboarding.verify] 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 console.error('[onboarding.verify] 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,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,cAAQ,MAAM,0EAA0E;AAAA,QACtF,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,cAAQ,MAAM,iEAAiE;AAAA,QAC7E,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,cAAQ,MAAM,iFAAiF;AAAA,QAC7F,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
+ "names": []
7
+ }
@@ -0,0 +1,13 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20260611120000 extends Migration {
3
+ async up() {
4
+ this.addSql(`alter table "onboarding_requests" add column "preparation_started_at" timestamptz null;`);
5
+ }
6
+ async down() {
7
+ this.addSql(`alter table "onboarding_requests" drop column "preparation_started_at";`);
8
+ }
9
+ }
10
+ export {
11
+ Migration20260611120000
12
+ };
13
+ //# sourceMappingURL=Migration20260611120000.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/onboarding/migrations/Migration20260611120000.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260611120000 extends Migration {\n\n override async up(): Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" add column \"preparation_started_at\" timestamptz null;`);\n }\n\n override async down(): Promise<void> {\n this.addSql(`alter table \"onboarding_requests\" drop column \"preparation_started_at\";`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,gCAAgC,UAAU;AAAA,EAErD,MAAe,KAAoB;AACjC,SAAK,OAAO,yFAAyF;AAAA,EACvG;AAAA,EAEA,MAAe,OAAsB;AACnC,SAAK,OAAO,yEAAyE;AAAA,EACvF;AAEF;",
6
+ "names": []
7
+ }
@@ -16,6 +16,7 @@ export const tenant_id = "tenant_id";
16
16
  export const organization_id = "organization_id";
17
17
  export const user_id = "user_id";
18
18
  export const last_email_sent_at = "last_email_sent_at";
19
+ export const preparation_started_at = "preparation_started_at";
19
20
  export const preparation_completed_at = "preparation_completed_at";
20
21
  export const ready_email_sent_at = "ready_email_sent_at";
21
22
  export const created_at = "created_at";
@@ -20,6 +20,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
20
20
  "organization_id": "organization_id",
21
21
  "user_id": "user_id",
22
22
  "last_email_sent_at": "last_email_sent_at",
23
+ "preparation_started_at": "preparation_started_at",
23
24
  "preparation_completed_at": "preparation_completed_at",
24
25
  "ready_email_sent_at": "ready_email_sent_at",
25
26
  "created_at": "created_at",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/onboarding",
3
- "version": "0.6.5-develop.5382.1.f542de69af",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -69,10 +69,10 @@
69
69
  }
70
70
  },
71
71
  "peerDependencies": {
72
- "@open-mercato/shared": "0.6.5-develop.5382.1.f542de69af"
72
+ "@open-mercato/shared": "0.6.5"
73
73
  },
74
74
  "devDependencies": {
75
- "@open-mercato/shared": "0.6.5-develop.5382.1.f542de69af",
75
+ "@open-mercato/shared": "0.6.5",
76
76
  "@types/jest": "^30.0.0",
77
77
  "jest": "^30.4.2",
78
78
  "ts-jest": "^29.4.11"
@@ -84,6 +84,5 @@
84
84
  "type": "git",
85
85
  "url": "https://github.com/open-mercato/open-mercato",
86
86
  "directory": "packages/onboarding"
87
- },
88
- "stableVersion": "0.6.4"
87
+ }
89
88
  }
@@ -0,0 +1,210 @@
1
+ const seedExamples = jest.fn(async () => {
2
+ await new Promise((resolve) => setImmediate(resolve))
3
+ })
4
+ const purgeIndexScope = jest.fn(async () => {})
5
+ const reindexEntity = jest.fn(async () => {})
6
+ const refreshCoverageSnapshot = jest.fn(async () => {})
7
+ const sendWorkspaceReadyEmail = jest.fn(async () => true)
8
+
9
+ type ClaimState = {
10
+ status: string
11
+ preparationStartedAt: Date | null
12
+ preparationCompletedAt: Date | null
13
+ }
14
+
15
+ const claimState: ClaimState = {
16
+ status: 'completed',
17
+ preparationStartedAt: null,
18
+ preparationCompletedAt: null,
19
+ }
20
+
21
+ const claimPreparation = jest.fn(async (_requestId: string, claimedAt: Date, staleBefore: Date) => {
22
+ if (claimState.status !== 'completed') return false
23
+ if (claimState.preparationCompletedAt) return false
24
+ if (claimState.preparationStartedAt && claimState.preparationStartedAt.getTime() >= staleBefore.getTime()) {
25
+ return false
26
+ }
27
+ claimState.preparationStartedAt = claimedAt
28
+ return true
29
+ })
30
+
31
+ const renewPreparation = jest.fn(async (_requestId: string, renewedAt: Date) => {
32
+ if (claimState.status !== 'completed') return false
33
+ if (claimState.preparationCompletedAt) return false
34
+ if (!claimState.preparationStartedAt) return false
35
+ claimState.preparationStartedAt = renewedAt
36
+ return true
37
+ })
38
+
39
+ const findById = jest.fn(async (id: string) => ({
40
+ id,
41
+ status: claimState.status,
42
+ preparationCompletedAt: claimState.preparationCompletedAt,
43
+ }))
44
+
45
+ const markPreparationCompleted = jest.fn(async (_request: unknown, completedAt: Date) => {
46
+ claimState.preparationCompletedAt = completedAt
47
+ claimState.preparationStartedAt = null
48
+ })
49
+
50
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
51
+ createRequestContainer: jest.fn(async () => ({
52
+ resolve: (name: string) => {
53
+ if (name === 'em') return {}
54
+ throw new Error(`unexpected resolve(${name})`)
55
+ },
56
+ })),
57
+ }))
58
+
59
+ jest.mock('@open-mercato/shared/lib/modules/registry', () => ({
60
+ getModules: () => [{ id: 'catalog', setup: { seedExamples } }],
61
+ }))
62
+
63
+ jest.mock('@open-mercato/shared/lib/entities/system-entities', () => ({
64
+ flattenSystemEntityIds: () => ['catalog:product'],
65
+ }))
66
+
67
+ jest.mock('@open-mercato/shared/lib/encryption/entityIds', () => ({
68
+ getEntityIds: () => ({}),
69
+ }))
70
+
71
+ jest.mock('@open-mercato/core/modules/query_index/lib/reindexer', () => ({
72
+ reindexEntity: (...args: unknown[]) => reindexEntity(...(args as [])),
73
+ }))
74
+
75
+ jest.mock('@open-mercato/core/modules/query_index/lib/purge', () => ({
76
+ purgeIndexScope: (...args: unknown[]) => purgeIndexScope(...(args as [])),
77
+ }))
78
+
79
+ jest.mock('@open-mercato/core/modules/query_index/lib/coverage', () => ({
80
+ refreshCoverageSnapshot: (...args: unknown[]) => refreshCoverageSnapshot(...(args as [])),
81
+ }))
82
+
83
+ jest.mock('@open-mercato/onboarding/modules/onboarding/lib/ready-email', () => ({
84
+ sendWorkspaceReadyEmail: (...args: unknown[]) => sendWorkspaceReadyEmail(...(args as [])),
85
+ }))
86
+
87
+ jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
88
+ OnboardingService: jest.fn().mockImplementation(() => ({
89
+ claimPreparation,
90
+ renewPreparation,
91
+ findById,
92
+ markPreparationCompleted,
93
+ })),
94
+ }))
95
+
96
+ import { runDeferredProvisioning } from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
97
+
98
+ const RUN_ARGS = {
99
+ requestId: 'req-1',
100
+ tenantId: '11111111-1111-4111-8111-111111111111',
101
+ organizationId: '33333333-3333-4333-8333-333333333333',
102
+ }
103
+
104
+ describe('runDeferredProvisioning single-flight claim', () => {
105
+ beforeEach(() => {
106
+ jest.useFakeTimers({ doNotFake: ['setImmediate'] })
107
+ jest.clearAllMocks()
108
+ claimState.status = 'completed'
109
+ claimState.preparationStartedAt = null
110
+ claimState.preparationCompletedAt = null
111
+ sendWorkspaceReadyEmail.mockResolvedValue(true)
112
+ })
113
+
114
+ afterEach(() => {
115
+ jest.useRealTimers()
116
+ })
117
+
118
+ it('runs the provisioning chain only once when triggered concurrently by status polls (demo pool-exhaustion repro)', async () => {
119
+ // Repro for the 2026-06-11 demo outage: the preparing page polls
120
+ // /onboarding/status every ~1s and each poll scheduled a FULL
121
+ // runDeferredProvisioning chain (seedExamples for every module + a forced
122
+ // purge/reindex of every system entity) until preparationCompletedAt was
123
+ // flushed. Dozens of concurrent chains exhausted the 20-connection PG pool,
124
+ // the completion flag write itself timed out, and the loop never
125
+ // terminated. Concurrent triggers must collapse into exactly one run.
126
+ await Promise.all([
127
+ runDeferredProvisioning(RUN_ARGS),
128
+ runDeferredProvisioning(RUN_ARGS),
129
+ runDeferredProvisioning(RUN_ARGS),
130
+ ])
131
+
132
+ expect(seedExamples).toHaveBeenCalledTimes(1)
133
+ expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
134
+ expect(purgeIndexScope).toHaveBeenCalledTimes(1)
135
+ expect(reindexEntity).toHaveBeenCalledTimes(1)
136
+ expect(sendWorkspaceReadyEmail).toHaveBeenCalledTimes(1)
137
+ })
138
+
139
+ it('does not run any heavy work when preparation already completed', async () => {
140
+ claimState.preparationCompletedAt = new Date()
141
+
142
+ await runDeferredProvisioning(RUN_ARGS)
143
+
144
+ expect(seedExamples).not.toHaveBeenCalled()
145
+ expect(purgeIndexScope).not.toHaveBeenCalled()
146
+ expect(reindexEntity).not.toHaveBeenCalled()
147
+ expect(sendWorkspaceReadyEmail).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('re-claims and recovers when a previous claim went stale', async () => {
151
+ claimState.preparationStartedAt = new Date(Date.now() - 20 * 60 * 1000)
152
+
153
+ await runDeferredProvisioning(RUN_ARGS)
154
+
155
+ expect(seedExamples).toHaveBeenCalledTimes(1)
156
+ expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
157
+ })
158
+
159
+ it('renews the lease while working so a slow run never looks stale', async () => {
160
+ await runDeferredProvisioning(RUN_ARGS)
161
+
162
+ expect(renewPreparation).toHaveBeenCalled()
163
+ expect(renewPreparation.mock.invocationCallOrder[0]).toBeLessThan(
164
+ markPreparationCompleted.mock.invocationCallOrder[0],
165
+ )
166
+ })
167
+
168
+ it('does not complete a request that was re-submitted (reset to pending) mid-chain', async () => {
169
+ // A user can re-onboard the same email while an old chain is still running:
170
+ // createOrUpdateRequest resets the request to pending. The stale chain must
171
+ // not mark THAT request prepared — the new flow owns deferred provisioning.
172
+ findById.mockImplementationOnce(async (id: string) => ({
173
+ id,
174
+ status: 'pending',
175
+ preparationCompletedAt: null,
176
+ }))
177
+
178
+ await runDeferredProvisioning(RUN_ARGS)
179
+
180
+ expect(markPreparationCompleted).not.toHaveBeenCalled()
181
+ })
182
+
183
+ it('marks preparation completed only after the query-index rebuild (death mid-rebuild stays recoverable)', async () => {
184
+ // preparationCompletedAt is the terminal gate for both the status-route
185
+ // scheduling and claimPreparation. If it were written before the rebuild,
186
+ // a runner dying mid-rebuild would leave the tenant permanently without
187
+ // index rows — nothing would ever reclaim the run.
188
+ await runDeferredProvisioning(RUN_ARGS)
189
+
190
+ expect(reindexEntity).toHaveBeenCalled()
191
+ expect(markPreparationCompleted).toHaveBeenCalled()
192
+ expect(reindexEntity.mock.invocationCallOrder[0]).toBeLessThan(
193
+ markPreparationCompleted.mock.invocationCallOrder[0],
194
+ )
195
+ })
196
+
197
+ it('still rebuilds query indexes when the ready email fails', async () => {
198
+ // #2954's contract: post-provisioning steps are non-fatal. A transient
199
+ // SMTP failure must not abort the chain before the query-index rebuild,
200
+ // otherwise the tenant is left permanently without index rows (the
201
+ // completion flag is already set, so nothing ever retries the rebuild).
202
+ sendWorkspaceReadyEmail.mockRejectedValue(new Error('smtp down'))
203
+
204
+ await expect(runDeferredProvisioning(RUN_ARGS)).resolves.toBeUndefined()
205
+
206
+ expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
207
+ expect(purgeIndexScope).toHaveBeenCalledTimes(1)
208
+ expect(reindexEntity).toHaveBeenCalledTimes(1)
209
+ })
210
+ })
@@ -0,0 +1,22 @@
1
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
2
+ createRequestContainer: jest.fn(),
3
+ }))
4
+
5
+ import { metadata, openApi } from '../modules/onboarding/api/post/onboarding'
6
+
7
+ describe('onboarding submission rate limiting', () => {
8
+ it('declares an IP rate limit so the API dispatcher throttles unauthenticated submissions', () => {
9
+ expect(metadata.POST.requireAuth).toBe(false)
10
+ expect(metadata.POST.rateLimit).toEqual({
11
+ points: 10,
12
+ duration: 60,
13
+ blockDuration: 60,
14
+ keyPrefix: 'onboarding',
15
+ })
16
+ })
17
+
18
+ it('documents the 429 response in the OpenAPI doc', () => {
19
+ const errors = openApi.methods?.POST?.errors ?? []
20
+ expect(errors.some((entry) => entry.status === 429)).toBe(true)
21
+ })
22
+ })