@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.
- 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 +4 -5
- 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
|
@@ -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
|
+
}
|