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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js +141 -0
  3. package/dist/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.js.map +7 -0
  4. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +158 -0
  5. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +7 -0
  6. package/dist/modules/onboarding/api/get/onboarding/status.js +35 -17
  7. package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
  8. package/dist/modules/onboarding/api/get/onboarding/verify.js +77 -195
  9. package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
  10. package/dist/modules/onboarding/api/post/onboarding.js +10 -1
  11. package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
  12. package/dist/modules/onboarding/data/entities.js +3 -0
  13. package/dist/modules/onboarding/data/entities.js.map +2 -2
  14. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js +31 -1
  15. package/dist/modules/onboarding/frontend/onboarding/OnboardingPageClient.js.map +2 -2
  16. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js +36 -7
  17. package/dist/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.js.map +2 -2
  18. package/dist/modules/onboarding/lib/deferred-provisioning.js +235 -0
  19. package/dist/modules/onboarding/lib/deferred-provisioning.js.map +7 -0
  20. package/dist/modules/onboarding/lib/preparation-claim.js +10 -0
  21. package/dist/modules/onboarding/lib/preparation-claim.js.map +7 -0
  22. package/dist/modules/onboarding/lib/provisioning.js +18 -0
  23. package/dist/modules/onboarding/lib/provisioning.js.map +7 -0
  24. package/dist/modules/onboarding/lib/service.js +31 -0
  25. package/dist/modules/onboarding/lib/service.js.map +2 -2
  26. package/dist/modules/onboarding/lib/verify-base-url.js +86 -0
  27. package/dist/modules/onboarding/lib/verify-base-url.js.map +7 -0
  28. package/dist/modules/onboarding/migrations/Migration20260611120000.js +13 -0
  29. package/dist/modules/onboarding/migrations/Migration20260611120000.js.map +7 -0
  30. package/generated/entities/onboarding_request/index.ts +1 -0
  31. package/generated/entity-fields-registry.ts +1 -0
  32. package/package.json +4 -5
  33. package/src/__tests__/deferred-provisioning.test.ts +210 -0
  34. package/src/__tests__/onboarding-submit-rate-limit.test.ts +22 -0
  35. package/src/__tests__/provisioning.test.ts +89 -0
  36. package/src/__tests__/status-endpoint-auth.test.ts +130 -3
  37. package/src/__tests__/verify-base-url.test.ts +65 -0
  38. package/src/modules/onboarding/__integration__/TC-ONB-001-self-service-consent.spec.ts +176 -0
  39. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +198 -0
  40. package/src/modules/onboarding/api/get/onboarding/status.ts +46 -17
  41. package/src/modules/onboarding/api/get/onboarding/verify.ts +96 -227
  42. package/src/modules/onboarding/api/post/onboarding.ts +9 -0
  43. package/src/modules/onboarding/data/entities.ts +3 -0
  44. package/src/modules/onboarding/frontend/onboarding/OnboardingPageClient.tsx +32 -2
  45. package/src/modules/onboarding/frontend/onboarding/preparing/PreparingPageClient.tsx +38 -6
  46. package/src/modules/onboarding/i18n/de.json +10 -1
  47. package/src/modules/onboarding/i18n/en.json +10 -1
  48. package/src/modules/onboarding/i18n/es.json +10 -1
  49. package/src/modules/onboarding/i18n/pl.json +10 -1
  50. package/src/modules/onboarding/lib/deferred-provisioning.ts +295 -0
  51. package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
  52. package/src/modules/onboarding/lib/provisioning.ts +35 -0
  53. package/src/modules/onboarding/lib/service.ts +33 -0
  54. package/src/modules/onboarding/lib/verify-base-url.ts +107 -0
  55. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
  56. package/src/modules/onboarding/migrations/Migration20260611120000.ts +13 -0
@@ -77,7 +77,10 @@
77
77
  "onboarding.preparing.descriptionWithTenant": "Estamos terminando de preparar el entorno de demostración para {tenant}. Le enviaremos un correo con el enlace correcto de acceso al tenant en cuanto esté listo.",
78
78
  "onboarding.preparing.emailNotice": "No necesita mantener esta página abierta. Le enviaremos un correo cuando todo esté listo.",
79
79
  "onboarding.preparing.homeCta": "Ir a la página principal",
80
+ "onboarding.preparing.loginCta": "Abrir acceso al tenant",
80
81
  "onboarding.preparing.redirecting": "Su espacio de trabajo ya está listo. Ahora le redirigimos a la página de acceso del tenant.",
82
+ "onboarding.preparing.statusErrorBody": "No pudimos comprobar el estado del espacio de trabajo. Es posible que aún se esté preparando; esta página seguirá intentándolo.",
83
+ "onboarding.preparing.statusErrorTitle": "Falló la comprobación de estado",
81
84
  "onboarding.preparing.title": "Estamos preparando su espacio de trabajo",
82
85
  "onboarding.readyEmail.body": "Su espacio de trabajo de Open Mercato para {organizationName} ya está listo. Use el enlace seguro de abajo para iniciar sesión.",
83
86
  "onboarding.readyEmail.cta": "Abrir acceso",
@@ -87,5 +90,11 @@
87
90
  "onboarding.readyEmail.preview": "Su espacio de trabajo está listo. Use su enlace seguro de acceso para iniciar sesión.",
88
91
  "onboarding.readyEmail.subject": "Su espacio de trabajo de Open Mercato está listo",
89
92
  "onboarding.subtitle": "Cuéntenos un poco sobre usted y configuraremos todo.",
90
- "onboarding.title": "Cree su espacio de trabajo en Open Mercato"
93
+ "onboarding.title": "Cree su espacio de trabajo en Open Mercato",
94
+ "onboarding.verifyStatus.alreadyExists": "Ya tenemos una cuenta con este correo electrónico. Intente iniciar sesión o restablecer su contraseña.",
95
+ "onboarding.verifyStatus.error": "No pudimos completar la verificación. Intente de nuevo o comuníquese con soporte.",
96
+ "onboarding.verifyStatus.invalid": "El enlace de verificación no es válido o caducó. Envíe de nuevo el formulario de onboarding para recibir un enlace nuevo.",
97
+ "onboarding.verifyStatus.originNotAllowed": "El enlace de verificación se abrió desde un origen no permitido. Revise APP_URL y APP_ALLOWED_ORIGINS y abra el enlace de nuevo.",
98
+ "onboarding.verifyStatus.redirectMisconfigured": "El enlace de verificación no puede redirigir de forma segura porque APP_URL no coincide con la URL que procesó la solicitud. Revise APP_URL y APP_ALLOWED_ORIGINS y abra el enlace de nuevo.",
99
+ "onboarding.verifyStatus.urlNotConfigured": "La verificación de onboarding no está configurada. APP_URL debe estar definido antes de usar enlaces de verificación."
91
100
  }
@@ -77,7 +77,10 @@
77
77
  "onboarding.preparing.descriptionWithTenant": "Kończymy przygotowywać środowisko demo dla {tenant}. Gdy będzie gotowe, wyślemy Ci e-mail z poprawnym linkiem logowania do tenanta.",
78
78
  "onboarding.preparing.emailNotice": "Nie musisz zostawiać tej strony otwartej. Gdy wszystko będzie gotowe, wyślemy Ci e-mail.",
79
79
  "onboarding.preparing.homeCta": "Przejdź na stronę główną",
80
+ "onboarding.preparing.loginCta": "Otwórz logowanie do tenanta",
80
81
  "onboarding.preparing.redirecting": "Twój obszar roboczy jest gotowy. Przekierowujemy Cię teraz do strony logowania tenanta.",
82
+ "onboarding.preparing.statusErrorBody": "Nie udało się sprawdzić statusu obszaru roboczego. Obszar może nadal się przygotowywać; ta strona będzie ponawiać próbę.",
83
+ "onboarding.preparing.statusErrorTitle": "Nie udało się sprawdzić statusu obszaru",
81
84
  "onboarding.preparing.title": "Przygotowujemy Twój obszar roboczy",
82
85
  "onboarding.readyEmail.body": "Twój obszar roboczy Open Mercato dla {organizationName} jest już gotowy. Użyj bezpiecznego linku poniżej, aby się zalogować.",
83
86
  "onboarding.readyEmail.cta": "Otwórz logowanie",
@@ -87,5 +90,11 @@
87
90
  "onboarding.readyEmail.preview": "Twój obszar roboczy jest gotowy. Użyj bezpiecznego linku logowania, aby się zalogować.",
88
91
  "onboarding.readyEmail.subject": "Twój obszar roboczy Open Mercato jest gotowy",
89
92
  "onboarding.subtitle": "Opowiedz nam trochę o sobie, a my wszystko skonfigurujemy.",
90
- "onboarding.title": "Utwórz swój obszar roboczy Open Mercato"
93
+ "onboarding.title": "Utwórz swój obszar roboczy Open Mercato",
94
+ "onboarding.verifyStatus.alreadyExists": "Mamy już konto z tym adresem email. Spróbuj się zalogować lub zresetować hasło.",
95
+ "onboarding.verifyStatus.error": "Nie udało się zakończyć weryfikacji. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.",
96
+ "onboarding.verifyStatus.invalid": "Link weryfikacyjny jest nieprawidłowy albo wygasł. Wyślij formularz onboardingu ponownie, aby otrzymać nowy link.",
97
+ "onboarding.verifyStatus.originNotAllowed": "Link weryfikacyjny otwarto z niedozwolonego originu. Sprawdź APP_URL i APP_ALLOWED_ORIGINS, a potem otwórz link ponownie.",
98
+ "onboarding.verifyStatus.redirectMisconfigured": "Link weryfikacyjny nie może bezpiecznie przekierować, bo APP_URL nie zgadza się z URL-em, który obsłużył request. Sprawdź APP_URL i APP_ALLOWED_ORIGINS, a potem otwórz link ponownie.",
99
+ "onboarding.verifyStatus.urlNotConfigured": "Weryfikacja onboardingu nie jest skonfigurowana. APP_URL musi być ustawiony, zanim linki weryfikacyjne będą działać."
91
100
  }
@@ -0,0 +1,295 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { SearchIndexer } from '@open-mercato/search'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'
5
+ import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
6
+ import { getModules } from '@open-mercato/shared/lib/modules/registry'
7
+ import type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'
8
+ import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
9
+ import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
10
+ import { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'
11
+ import { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'
12
+ import { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'
13
+ import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
14
+ import { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
15
+
16
+ const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000
17
+ const SEED_EXAMPLES_TIMEOUT_MS = 15_000
18
+
19
+ export function resolveProvisioningIds(request: OnboardingRequest) {
20
+ if (!request.tenantId || !request.organizationId || !request.userId) return null
21
+ return {
22
+ tenantId: request.tenantId,
23
+ organizationId: request.organizationId,
24
+ userId: request.userId,
25
+ }
26
+ }
27
+
28
+ function createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {
29
+ return new Promise((_, reject) => {
30
+ setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)
31
+ })
32
+ }
33
+
34
+ async function runModuleSetupHook(args: {
35
+ moduleId: string
36
+ phase: 'seedExamples'
37
+ timeoutMs: number
38
+ run: () => Promise<void>
39
+ }) {
40
+ const startedAt = Date.now()
41
+ console.info('[onboarding.verify] module hook started', {
42
+ moduleId: args.moduleId,
43
+ phase: args.phase,
44
+ timeoutMs: args.timeoutMs,
45
+ })
46
+ try {
47
+ await Promise.race([
48
+ args.run(),
49
+ createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),
50
+ ])
51
+ console.info('[onboarding.verify] module hook completed', {
52
+ moduleId: args.moduleId,
53
+ phase: args.phase,
54
+ durationMs: Math.max(0, Date.now() - startedAt),
55
+ })
56
+ } catch (error) {
57
+ if (isUniqueViolation(error)) {
58
+ // Deferred provisioning is re-triggered on every preparing-page status
59
+ // poll until preparationCompletedAt is set. seedExamples is not fully
60
+ // idempotent (e.g. catalog product handles are unique-scoped), so a
61
+ // re-run that lands before completion collides on an already-seeded row.
62
+ // The workspace is already provisioned and the collision is expected and
63
+ // harmless — log at info so genuine failures still stand out.
64
+ console.info('[onboarding.verify] module hook skipped (already seeded)', {
65
+ moduleId: args.moduleId,
66
+ phase: args.phase,
67
+ durationMs: Math.max(0, Date.now() - startedAt),
68
+ })
69
+ } else {
70
+ console.error('[onboarding.verify] module hook failed', {
71
+ moduleId: args.moduleId,
72
+ phase: args.phase,
73
+ durationMs: Math.max(0, Date.now() - startedAt),
74
+ timeoutMs: args.timeoutMs,
75
+ error,
76
+ })
77
+ }
78
+ throw error
79
+ }
80
+ }
81
+
82
+ async function markWorkspaceReady(args: {
83
+ requestId: string
84
+ service: OnboardingService
85
+ }) {
86
+ const request = await args.service.findById(args.requestId)
87
+ // The status guard protects a request that was re-submitted (and reset to
88
+ // pending) while this chain was still running — completing it would let the
89
+ // new flow skip its own deferred provisioning.
90
+ if (!request || request.preparationCompletedAt || request.status !== 'completed') return
91
+ await args.service.markPreparationCompleted(request, new Date())
92
+ }
93
+
94
+ async function enqueueVectorReindex(args: {
95
+ container: { resolve: <T = unknown>(name: string) => T }
96
+ tenantId: string
97
+ organizationId: string
98
+ }) {
99
+ let searchIndexer: SearchIndexer | null = null
100
+ try {
101
+ searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')
102
+ } catch {
103
+ searchIndexer = null
104
+ }
105
+ if (!searchIndexer) return
106
+
107
+ await Promise.race([
108
+ searchIndexer.reindexAllToVector({
109
+ tenantId: args.tenantId,
110
+ organizationId: args.organizationId,
111
+ purgeFirst: true,
112
+ useQueue: true,
113
+ }),
114
+ createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),
115
+ ])
116
+ }
117
+
118
+ async function rebuildTenantQueryIndexes(args: {
119
+ em: EntityManager
120
+ tenantId: string
121
+ organizationId: string
122
+ }) {
123
+ const coverageRefreshKeys = new Set<string>()
124
+ try {
125
+ const allEntities = getEntityIds()
126
+ const entityIds = flattenSystemEntityIds(allEntities)
127
+ for (const entityType of entityIds) {
128
+ try {
129
+ await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId })
130
+ } catch (error) {
131
+ console.error('[onboarding.verify] failed to purge query index scope', {
132
+ entityType,
133
+ tenantId: args.tenantId,
134
+ error,
135
+ })
136
+ }
137
+ try {
138
+ await reindexEntity(args.em, {
139
+ entityType,
140
+ tenantId: args.tenantId,
141
+ force: true,
142
+ emitVectorizeEvents: false,
143
+ vectorService: null,
144
+ })
145
+ } catch (error) {
146
+ console.error('[onboarding.verify] failed to reindex entity', {
147
+ entityType,
148
+ tenantId: args.tenantId,
149
+ error,
150
+ })
151
+ }
152
+ coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`)
153
+ coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`)
154
+ }
155
+ } catch (error) {
156
+ console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId: args.tenantId, error })
157
+ }
158
+
159
+ if (!coverageRefreshKeys.size) return
160
+
161
+ for (const entry of coverageRefreshKeys) {
162
+ const [entityType, tenantKey, orgKey] = entry.split('|')
163
+ const orgScope = orgKey === '__null__' ? null : orgKey
164
+ try {
165
+ await refreshCoverageSnapshot(
166
+ args.em,
167
+ {
168
+ entityType,
169
+ tenantId: tenantKey,
170
+ organizationId: orgScope,
171
+ withDeleted: false,
172
+ },
173
+ )
174
+ } catch (error) {
175
+ console.error('[onboarding.verify] failed to refresh coverage snapshot', {
176
+ entityType,
177
+ tenantId: tenantKey,
178
+ organizationId: orgScope,
179
+ error,
180
+ })
181
+ }
182
+ }
183
+ }
184
+
185
+ export async function runDeferredProvisioning(args: {
186
+ requestId: string
187
+ tenantId: string
188
+ organizationId: string
189
+ }) {
190
+ const container = await createRequestContainer()
191
+ const em = container.resolve('em') as EntityManager
192
+ const service = new OnboardingService(em)
193
+
194
+ // Single-flight guard: the preparing page polls the status endpoint every
195
+ // second and each poll (plus the verify handler) schedules this chain. The
196
+ // atomic claim collapses those triggers into one run per request — without
197
+ // it, dozens of concurrent seed + full-reindex chains exhaust the PG
198
+ // connection pool (2026-06-11 demo outage). A stale claim (crashed runner)
199
+ // becomes reclaimable after PREPARATION_CLAIM_STALE_MS.
200
+ const claimedAt = new Date()
201
+ const claimed = await service.claimPreparation(
202
+ args.requestId,
203
+ claimedAt,
204
+ new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),
205
+ )
206
+ if (!claimed) {
207
+ console.info('[onboarding.verify] deferred provisioning skipped (already claimed or completed)', {
208
+ requestId: args.requestId,
209
+ tenantId: args.tenantId,
210
+ })
211
+ return
212
+ }
213
+
214
+ const modules = getModules()
215
+
216
+ for (const mod of modules) {
217
+ if (!mod.setup?.seedExamples) continue
218
+ // Heartbeat: keep the lease fresh while legitimately working so a slow run
219
+ // (many modules × 15s seed timeout + rebuild) can never look stale and get
220
+ // double-claimed by a later poll.
221
+ await service.renewPreparation(args.requestId, new Date()).catch(() => {})
222
+ try {
223
+ await runModuleSetupHook({
224
+ moduleId: mod.id,
225
+ phase: 'seedExamples',
226
+ timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,
227
+ run: () => mod.setup!.seedExamples!({
228
+ em,
229
+ tenantId: args.tenantId,
230
+ organizationId: args.organizationId,
231
+ container,
232
+ }),
233
+ })
234
+ } catch (error) {
235
+ if (isUniqueViolation(error)) {
236
+ console.info('[onboarding.verify] deferred seedExamples skipped (already applied)', {
237
+ moduleId: mod.id,
238
+ tenantId: args.tenantId,
239
+ organizationId: args.organizationId,
240
+ })
241
+ } else {
242
+ console.error('[onboarding.verify] deferred seedExamples failed', {
243
+ moduleId: mod.id,
244
+ tenantId: args.tenantId,
245
+ organizationId: args.organizationId,
246
+ error,
247
+ })
248
+ }
249
+ }
250
+ }
251
+
252
+ // The rebuild runs BEFORE the completion flag: preparationCompletedAt is the
253
+ // terminal gate for both the status-route scheduling and claimPreparation,
254
+ // so a runner that dies mid-rebuild must leave the flag unset — the stale
255
+ // claim then makes the whole chain reclaimable and the rebuild self-heals.
256
+ // rebuildTenantQueryIndexes never throws (it logs per-entity failures).
257
+ await service.renewPreparation(args.requestId, new Date()).catch(() => {})
258
+ await rebuildTenantQueryIndexes({
259
+ em,
260
+ tenantId: args.tenantId,
261
+ organizationId: args.organizationId,
262
+ })
263
+
264
+ await markWorkspaceReady({
265
+ requestId: args.requestId,
266
+ service,
267
+ })
268
+
269
+ // Non-fatal (#2954 contract): an email failure must not abort the chain.
270
+ // The status endpoint retries the ready email on later polls while
271
+ // readyEmailSentAt is unset.
272
+ await sendWorkspaceReadyEmail({
273
+ requestId: args.requestId,
274
+ tenantId: args.tenantId,
275
+ }).catch((error) => {
276
+ console.error('[onboarding.verify] ready email failed', {
277
+ requestId: args.requestId,
278
+ tenantId: args.tenantId,
279
+ organizationId: args.organizationId,
280
+ error,
281
+ })
282
+ })
283
+
284
+ await enqueueVectorReindex({
285
+ container,
286
+ tenantId: args.tenantId,
287
+ organizationId: args.organizationId,
288
+ }).catch((error) => {
289
+ console.warn('[onboarding.verify] vector reindex enqueue did not complete promptly', {
290
+ tenantId: args.tenantId,
291
+ organizationId: args.organizationId,
292
+ reason: error instanceof Error ? error.message : String(error),
293
+ })
294
+ })
295
+ }
@@ -0,0 +1,6 @@
1
+ export const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1000
2
+
3
+ export function isPreparationClaimActive(startedAt: Date | null | undefined, now: Date = new Date()): boolean {
4
+ if (!startedAt) return false
5
+ return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS
6
+ }
@@ -0,0 +1,35 @@
1
+ import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
2
+
3
+ export type BestEffortProvisioningResult = { ok: true } | { ok: false; error: unknown }
4
+
5
+ type BestEffortLogger = Pick<Console, 'error' | 'info'>
6
+
7
+ /**
8
+ * Runs a non-essential provisioning step without letting its failure abort
9
+ * onboarding. The tenant/org/primary user are created before these steps, so a
10
+ * throw or timeout here (reference-data seeding, marketing-consent recording)
11
+ * must NOT strand a freshly provisioned workspace — the verify handler still has
12
+ * to reach markCompleted so the user can sign in. Failures are logged for
13
+ * follow-up and reported back to the caller, never re-thrown.
14
+ */
15
+ export async function runBestEffortProvisioningStep(
16
+ step: string,
17
+ run: () => Promise<void>,
18
+ logger: BestEffortLogger = console,
19
+ ): Promise<BestEffortProvisioningResult> {
20
+ try {
21
+ await run()
22
+ return { ok: true }
23
+ } catch (error) {
24
+ if (isUniqueViolation(error)) {
25
+ // Expected when a concurrent verify / re-verify re-applies a step against
26
+ // rows that already exist (seed hooks are not fully idempotent). The
27
+ // workspace is already provisioned, so the collision is harmless — log at
28
+ // info to keep genuine non-fatal failures visible.
29
+ logger.info('[onboarding.verify] non-fatal provisioning step skipped (already applied)', { step })
30
+ return { ok: false, error }
31
+ }
32
+ logger.error('[onboarding.verify] non-fatal provisioning step failed', { step, error })
33
+ return { ok: false, error }
34
+ }
35
+ }
@@ -43,6 +43,7 @@ export class OnboardingService {
43
43
  existing.organizationId = null
44
44
  existing.userId = null
45
45
  existing.lastEmailSentAt = now
46
+ existing.preparationStartedAt = null
46
47
  existing.preparationCompletedAt = null
47
48
  existing.readyEmailSentAt = null
48
49
  await this.em.flush()
@@ -144,8 +145,40 @@ export class OnboardingService {
144
145
  await this.em.flush()
145
146
  }
146
147
 
148
+ async claimPreparation(requestId: string, claimedAt: Date, staleBefore: Date): Promise<boolean> {
149
+ const claimedRows = await this.em.nativeUpdate(
150
+ OnboardingRequest,
151
+ {
152
+ id: requestId,
153
+ status: 'completed',
154
+ preparationCompletedAt: null,
155
+ $or: [
156
+ { preparationStartedAt: null },
157
+ { preparationStartedAt: { $lt: staleBefore } },
158
+ ],
159
+ },
160
+ { preparationStartedAt: claimedAt, updatedAt: new Date() },
161
+ )
162
+ return claimedRows > 0
163
+ }
164
+
165
+ async renewPreparation(requestId: string, renewedAt: Date): Promise<boolean> {
166
+ const renewedRows = await this.em.nativeUpdate(
167
+ OnboardingRequest,
168
+ {
169
+ id: requestId,
170
+ status: 'completed',
171
+ preparationCompletedAt: null,
172
+ preparationStartedAt: { $ne: null },
173
+ },
174
+ { preparationStartedAt: renewedAt, updatedAt: new Date() },
175
+ )
176
+ return renewedRows > 0
177
+ }
178
+
147
179
  async markPreparationCompleted(request: OnboardingRequest, completedAt: Date) {
148
180
  request.preparationCompletedAt = completedAt
181
+ request.preparationStartedAt = null
149
182
  await this.em.flush()
150
183
  }
151
184
  }
@@ -0,0 +1,107 @@
1
+ import {
2
+ AppOriginConfigurationError,
3
+ AppOriginRejectedError,
4
+ getSecurityEmailBaseUrl,
5
+ resolveRequestOrigin,
6
+ } from '@open-mercato/shared/lib/url'
7
+
8
+ type EnvLike = Record<string, string | undefined>
9
+
10
+ export type VerifyRedirectBaseUrlResult =
11
+ | { ok: true; baseUrl: string }
12
+ | {
13
+ ok: false
14
+ status: 'origin_not_allowed' | 'redirect_misconfigured' | 'url_not_configured'
15
+ message: string
16
+ redirectOrigin: string | null
17
+ httpStatus: number
18
+ }
19
+
20
+ function normalizeOrigin(raw: string): string | null {
21
+ try {
22
+ return new URL(raw).origin
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ function originPort(url: URL): string {
29
+ if (url.port) return url.port
30
+ if (url.protocol === 'https:') return '443'
31
+ if (url.protocol === 'http:') return '80'
32
+ return ''
33
+ }
34
+
35
+ function isLoopbackHostname(hostname: string): boolean {
36
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
37
+ }
38
+
39
+ function originsMatchForRedirect(configuredOrigin: string, requestOrigin: string): boolean {
40
+ if (configuredOrigin === requestOrigin) return true
41
+ try {
42
+ const configured = new URL(configuredOrigin)
43
+ const request = new URL(requestOrigin)
44
+ return (
45
+ configured.protocol === request.protocol &&
46
+ isLoopbackHostname(configured.hostname) &&
47
+ isLoopbackHostname(request.hostname) &&
48
+ originPort(configured) === originPort(request)
49
+ )
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ export function resolveVerifyRedirectBaseUrl(
56
+ req: Request,
57
+ env: EnvLike = process.env,
58
+ ): VerifyRedirectBaseUrlResult {
59
+ try {
60
+ const baseUrl = getSecurityEmailBaseUrl(req, env)
61
+ const configuredOrigin = normalizeOrigin(baseUrl)
62
+ const requestOrigin = normalizeOrigin(resolveRequestOrigin(req)) ?? normalizeOrigin(req.url)
63
+ if (configuredOrigin && requestOrigin && !originsMatchForRedirect(configuredOrigin, requestOrigin)) {
64
+ console.error('[onboarding.verify] APP_URL does not match verification request origin', {
65
+ requestUrl: req.url,
66
+ configuredOrigin,
67
+ requestOrigin,
68
+ })
69
+ return {
70
+ ok: false,
71
+ status: 'redirect_misconfigured',
72
+ message: 'Onboarding verification redirect is misconfigured.',
73
+ redirectOrigin: requestOrigin,
74
+ httpStatus: 400,
75
+ }
76
+ }
77
+ return { ok: true, baseUrl }
78
+ } catch (error) {
79
+ if (error instanceof AppOriginRejectedError) {
80
+ console.error('[onboarding.verify] rejected request origin for redirect base', {
81
+ requestUrl: req.url,
82
+ reason: error.message,
83
+ })
84
+ return {
85
+ ok: false,
86
+ status: 'origin_not_allowed',
87
+ message: 'Onboarding verification request origin is not allowed.',
88
+ redirectOrigin: null,
89
+ httpStatus: 400,
90
+ }
91
+ }
92
+ if (error instanceof AppOriginConfigurationError) {
93
+ console.error('[onboarding.verify] APP_URL is required for onboarding verification redirects', {
94
+ requestUrl: req.url,
95
+ reason: error.message,
96
+ })
97
+ return {
98
+ ok: false,
99
+ status: 'url_not_configured',
100
+ message: 'Onboarding verification redirect is not configured.',
101
+ redirectOrigin: null,
102
+ httpStatus: 500,
103
+ }
104
+ }
105
+ throw error
106
+ }
107
+ }
@@ -176,6 +176,16 @@
176
176
  "length": 6,
177
177
  "mappedType": "datetime"
178
178
  },
179
+ "preparation_started_at": {
180
+ "name": "preparation_started_at",
181
+ "type": "timestamptz",
182
+ "unsigned": false,
183
+ "autoincrement": false,
184
+ "primary": false,
185
+ "nullable": true,
186
+ "length": 6,
187
+ "mappedType": "datetime"
188
+ },
179
189
  "preparation_completed_at": {
180
190
  "name": "preparation_completed_at",
181
191
  "type": "timestamptz",
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260611120000 extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table "onboarding_requests" add column "preparation_started_at" timestamptz null;`);
7
+ }
8
+
9
+ override async down(): Promise<void> {
10
+ this.addSql(`alter table "onboarding_requests" drop column "preparation_started_at";`);
11
+ }
12
+
13
+ }