@open-mercato/core 0.9.1-develop.7519.1.38f51f8dbb → 0.9.1-develop.7536.1.f9a5776a07
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/auth/api/logout.js +14 -6
- package/dist/modules/auth/api/logout.js.map +2 -2
- package/dist/modules/auth/api/session/refresh.js +6 -1
- package/dist/modules/auth/api/session/refresh.js.map +2 -2
- package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
- package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
- package/dist/modules/catalog/ai-tools/types.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/[id]/page.js +5 -2
- package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
- package/dist/modules/currencies/commands/currencies.js +399 -137
- package/dist/modules/currencies/commands/currencies.js.map +2 -2
- package/dist/modules/currencies/lib/seeds.js +2 -0
- package/dist/modules/currencies/lib/seeds.js.map +2 -2
- package/dist/modules/customer_accounts/api/login.js +14 -7
- package/dist/modules/customer_accounts/api/login.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
- package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
- package/dist/modules/customers/commands/companies.js +10 -3
- package/dist/modules/customers/commands/companies.js.map +2 -2
- package/dist/modules/customers/commands/interactionRestoreDetails.js +75 -0
- package/dist/modules/customers/commands/interactionRestoreDetails.js.map +7 -0
- package/dist/modules/customers/commands/people.js +10 -3
- package/dist/modules/customers/commands/people.js.map +2 -2
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/form-submissions/route.js +16 -1
- package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/runtime-helpers.js +16 -0
- package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
- package/dist/modules/forms/data/validators.js.map +2 -2
- package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
- package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
- package/dist/modules/forms/lib/runtime-principal.js +27 -10
- package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
- package/dist/modules/forms/services/distribution-service.js +35 -13
- package/dist/modules/forms/services/distribution-service.js.map +2 -2
- package/dist/modules/forms/services/submission-service.js +4 -0
- package/dist/modules/forms/services/submission-service.js.map +2 -2
- package/dist/modules/staff/commands/leave-requests.js +54 -24
- package/dist/modules/staff/commands/leave-requests.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
- package/dist/modules/warranty_claims/commands/claims.js +1 -1
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
- package/dist/modules/wms/commands/inventory-actions.js +19 -3
- package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
- package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
- package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
- package/dist/modules/workflows/lib/parallel-handler.js +20 -14
- package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +21 -23
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/logout.ts +15 -6
- package/src/modules/auth/api/session/refresh.ts +6 -1
- package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
- package/src/modules/catalog/ai-tools/types.ts +1 -0
- package/src/modules/currencies/backend/currencies/[id]/page.tsx +5 -1
- package/src/modules/currencies/commands/currencies.ts +475 -168
- package/src/modules/currencies/i18n/de.json +4 -0
- package/src/modules/currencies/i18n/en.json +4 -0
- package/src/modules/currencies/i18n/es.json +4 -0
- package/src/modules/currencies/i18n/ko.json +4 -0
- package/src/modules/currencies/i18n/pl.json +4 -0
- package/src/modules/currencies/lib/seeds.ts +2 -0
- package/src/modules/customer_accounts/api/login.ts +15 -7
- package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
- package/src/modules/customers/commands/companies.ts +16 -4
- package/src/modules/customers/commands/interactionRestoreDetails.ts +110 -0
- package/src/modules/customers/commands/people.ts +16 -4
- package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
- package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
- package/src/modules/forms/api/form-submissions/route.ts +21 -2
- package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
- package/src/modules/forms/api/runtime-helpers.ts +16 -0
- package/src/modules/forms/data/validators.ts +2 -1
- package/src/modules/forms/i18n/de.json +1 -0
- package/src/modules/forms/i18n/en.json +1 -0
- package/src/modules/forms/i18n/es.json +1 -0
- package/src/modules/forms/i18n/ko.json +1 -0
- package/src/modules/forms/i18n/pl.json +1 -0
- package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
- package/src/modules/forms/lib/runtime-principal.ts +35 -11
- package/src/modules/forms/services/distribution-service.ts +35 -13
- package/src/modules/forms/services/submission-service.ts +12 -0
- package/src/modules/staff/commands/leave-requests.ts +54 -23
- package/src/modules/staff/i18n/de.json +2 -0
- package/src/modules/staff/i18n/en.json +2 -0
- package/src/modules/staff/i18n/es.json +2 -0
- package/src/modules/staff/i18n/ko.json +2 -0
- package/src/modules/staff/i18n/pl.json +2 -0
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
- package/src/modules/warranty_claims/commands/claims.ts +1 -1
- package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
- package/src/modules/wms/commands/inventory-actions.ts +30 -10
- package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
- package/src/modules/workflows/lib/parallel-handler.ts +24 -15
- package/src/modules/workflows/lib/workflow-executor.ts +24 -27
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
"currencies.create.title": "Währung erstellen",
|
|
7
7
|
"currencies.currencies.update": "Währung aktualisieren",
|
|
8
8
|
"currencies.edit.title": "Währung bearbeiten",
|
|
9
|
+
"currencies.errors.baseCurrencyUnset": "Wählen Sie stattdessen eine andere Währung als Basiswährung, anstatt sie zu entfernen.",
|
|
10
|
+
"currencies.errors.undoHasRates": "Diese Änderung kann nicht rückgängig gemacht werden, da inzwischen Wechselkurse diese Währung verwenden. Löschen oder deaktivieren Sie diese Wechselkurse zuerst.",
|
|
11
|
+
"currencies.errors.undoWouldClearBase": "Diese Änderung kann nicht rückgängig gemacht werden, da die Organisation sonst keine Basiswährung hätte.",
|
|
9
12
|
"currencies.exchange_rates.update": "Wechselkurs aktualisieren",
|
|
10
13
|
"currencies.fetch.description": "Verwalten Sie den automatischen Abruf von Währungskursen von externen Anbietern",
|
|
11
14
|
"currencies.fetch.disabled": "Deaktiviert",
|
|
@@ -54,6 +57,7 @@
|
|
|
54
57
|
"currencies.form.field.decimalSeparator": "Dezimaltrennzeichen",
|
|
55
58
|
"currencies.form.field.isActive": "Aktiv",
|
|
56
59
|
"currencies.form.field.isBase": "Basiswährung",
|
|
60
|
+
"currencies.form.field.isBaseLocked": "Dies ist die Basiswährung. Um sie zu ändern, legen Sie eine andere Währung als Basis fest.",
|
|
57
61
|
"currencies.form.field.name": "Währungsname",
|
|
58
62
|
"currencies.form.field.namePlaceholder": "US-Dollar",
|
|
59
63
|
"currencies.form.field.symbol": "Symbol",
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
"currencies.create.title": "Create Currency",
|
|
7
7
|
"currencies.currencies.update": "Update currency",
|
|
8
8
|
"currencies.edit.title": "Edit Currency",
|
|
9
|
+
"currencies.errors.baseCurrencyUnset": "Choose another currency as the base instead of clearing it.",
|
|
10
|
+
"currencies.errors.undoHasRates": "This change can't be undone because exchange rates now use this currency. Delete or deactivate those exchange rates first.",
|
|
11
|
+
"currencies.errors.undoWouldClearBase": "This change can't be undone because it would leave the organization without a base currency.",
|
|
9
12
|
"currencies.exchange_rates.update": "Update exchange rate",
|
|
10
13
|
"currencies.fetch.description": "Manage automatic currency exchange rate fetching from external providers",
|
|
11
14
|
"currencies.fetch.disabled": "Disabled",
|
|
@@ -54,6 +57,7 @@
|
|
|
54
57
|
"currencies.form.field.decimalSeparator": "Decimal Separator",
|
|
55
58
|
"currencies.form.field.isActive": "Active",
|
|
56
59
|
"currencies.form.field.isBase": "Base Currency",
|
|
60
|
+
"currencies.form.field.isBaseLocked": "This is the base currency. To change it, set another currency as the base.",
|
|
57
61
|
"currencies.form.field.name": "Currency Name",
|
|
58
62
|
"currencies.form.field.namePlaceholder": "US Dollar",
|
|
59
63
|
"currencies.form.field.symbol": "Symbol",
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
"currencies.create.title": "Crear Moneda",
|
|
7
7
|
"currencies.currencies.update": "Actualizar moneda",
|
|
8
8
|
"currencies.edit.title": "Editar Moneda",
|
|
9
|
+
"currencies.errors.baseCurrencyUnset": "Elija otra moneda como base en lugar de quitarla.",
|
|
10
|
+
"currencies.errors.undoHasRates": "Este cambio no se puede deshacer porque ahora hay tipos de cambio que usan esta moneda. Elimine o desactive primero esos tipos de cambio.",
|
|
11
|
+
"currencies.errors.undoWouldClearBase": "Este cambio no se puede deshacer porque dejaría a la organización sin moneda base.",
|
|
9
12
|
"currencies.exchange_rates.update": "Actualizar tipo de cambio",
|
|
10
13
|
"currencies.fetch.description": "Gestionar la obtención automática de tipos de cambio desde proveedores externos",
|
|
11
14
|
"currencies.fetch.disabled": "Deshabilitado",
|
|
@@ -54,6 +57,7 @@
|
|
|
54
57
|
"currencies.form.field.decimalSeparator": "Separador Decimal",
|
|
55
58
|
"currencies.form.field.isActive": "Activa",
|
|
56
59
|
"currencies.form.field.isBase": "Moneda Base",
|
|
60
|
+
"currencies.form.field.isBaseLocked": "Esta es la moneda base. Para cambiarla, establezca otra moneda como base.",
|
|
57
61
|
"currencies.form.field.name": "Nombre de Moneda",
|
|
58
62
|
"currencies.form.field.namePlaceholder": "Dólar estadounidense",
|
|
59
63
|
"currencies.form.field.symbol": "Símbolo",
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
"currencies.create.title": "통화 생성",
|
|
7
7
|
"currencies.currencies.update": "통화 수정",
|
|
8
8
|
"currencies.edit.title": "통화 편집",
|
|
9
|
+
"currencies.errors.baseCurrencyUnset": "기준 통화를 해제하는 대신 다른 통화를 기준 통화로 선택하세요.",
|
|
10
|
+
"currencies.errors.undoHasRates": "이제 이 통화를 사용하는 환율이 있어 이 변경을 취소할 수 없습니다. 먼저 해당 환율을 삭제하거나 비활성화하세요.",
|
|
11
|
+
"currencies.errors.undoWouldClearBase": "이 변경을 취소하면 조직에 기준 통화가 없게 되므로 취소할 수 없습니다.",
|
|
9
12
|
"currencies.exchange_rates.update": "환율 수정",
|
|
10
13
|
"currencies.fetch.description": "외부 제공업체로부터 자동 환율 가져오기를 관리합니다",
|
|
11
14
|
"currencies.fetch.disabled": "비활성화됨",
|
|
@@ -54,6 +57,7 @@
|
|
|
54
57
|
"currencies.form.field.decimalSeparator": "소수 구분 기호",
|
|
55
58
|
"currencies.form.field.isActive": "활성",
|
|
56
59
|
"currencies.form.field.isBase": "기준 통화",
|
|
60
|
+
"currencies.form.field.isBaseLocked": "기준 통화입니다. 변경하려면 다른 통화를 기준 통화로 설정하세요.",
|
|
57
61
|
"currencies.form.field.name": "통화 이름",
|
|
58
62
|
"currencies.form.field.namePlaceholder": "미국 달러",
|
|
59
63
|
"currencies.form.field.symbol": "기호",
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
"currencies.create.title": "Utwórz Walutę",
|
|
7
7
|
"currencies.currencies.update": "Aktualizuj walutę",
|
|
8
8
|
"currencies.edit.title": "Edytuj Walutę",
|
|
9
|
+
"currencies.errors.baseCurrencyUnset": "Zamiast odznaczać walutę bazową, wybierz inną walutę jako bazową.",
|
|
10
|
+
"currencies.errors.undoHasRates": "Tej zmiany nie można cofnąć, ponieważ kursy wymiany korzystają teraz z tej waluty. Najpierw usuń lub dezaktywuj te kursy wymiany.",
|
|
11
|
+
"currencies.errors.undoWouldClearBase": "Tej zmiany nie można cofnąć, ponieważ organizacja zostałaby bez waluty bazowej.",
|
|
9
12
|
"currencies.exchange_rates.update": "Aktualizuj kurs wymiany",
|
|
10
13
|
"currencies.fetch.description": "Zarządzaj automatycznym pobieraniem kursów walut od zewnętrznych dostawców",
|
|
11
14
|
"currencies.fetch.disabled": "Wyłączony",
|
|
@@ -54,6 +57,7 @@
|
|
|
54
57
|
"currencies.form.field.decimalSeparator": "Separator Dziesiętny",
|
|
55
58
|
"currencies.form.field.isActive": "Aktywna",
|
|
56
59
|
"currencies.form.field.isBase": "Waluta Bazowa",
|
|
60
|
+
"currencies.form.field.isBaseLocked": "To jest waluta bazowa. Aby ją zmienić, ustaw inną walutę jako bazową.",
|
|
57
61
|
"currencies.form.field.name": "Nazwa Waluty",
|
|
58
62
|
"currencies.form.field.namePlaceholder": "Dolar amerykański",
|
|
59
63
|
"currencies.form.field.symbol": "Symbol",
|
|
@@ -126,6 +126,7 @@ export async function seedExampleCurrencies(em: EntityManager, scope: CurrencySe
|
|
|
126
126
|
for (const entry of existingEntries) {
|
|
127
127
|
existingMap.set(entry.code, entry)
|
|
128
128
|
}
|
|
129
|
+
const hasLiveBase = existingEntries.some((entry) => entry.isBase && !entry.deletedAt)
|
|
129
130
|
|
|
130
131
|
let touched = false
|
|
131
132
|
for (const curr of SEED_CURRENCIES) {
|
|
@@ -143,6 +144,7 @@ export async function seedExampleCurrencies(em: EntityManager, scope: CurrencySe
|
|
|
143
144
|
tenantId: scope.tenantId,
|
|
144
145
|
organizationId: scope.organizationId,
|
|
145
146
|
...curr,
|
|
147
|
+
isBase: curr.isBase && !hasLiveBase,
|
|
146
148
|
createdAt: new Date(),
|
|
147
149
|
updatedAt: new Date(),
|
|
148
150
|
})
|
|
@@ -8,6 +8,7 @@ import { CustomerUserService } from '@open-mercato/core/modules/customer_account
|
|
|
8
8
|
import { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'
|
|
9
9
|
import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
|
|
10
10
|
import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
|
|
11
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
11
12
|
import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
12
13
|
import { getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'
|
|
13
14
|
import {
|
|
@@ -28,6 +29,10 @@ export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth:
|
|
|
28
29
|
// so account existence cannot be inferred from a timing side channel. Mirrors signup.ts.
|
|
29
30
|
const TIMING_EQUALIZATION_HASH = '$2b$10$.F2A6UHFzk.d8trNdfqt4OLz05Nf3IOuMmN6VJKflhD4.rz.prR8i'
|
|
30
31
|
|
|
32
|
+
function reportLoginEventError(error: unknown): void {
|
|
33
|
+
getTelemetryRuntime()?.reportError(error, { module: 'customer_accounts', code: 'customer_accounts.login_event_failed' })
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
export async function POST(req: Request) {
|
|
32
37
|
let body: unknown
|
|
33
38
|
try {
|
|
@@ -77,26 +82,26 @@ export async function POST(req: Request) {
|
|
|
77
82
|
// conveyed out-of-band (e.g. email), never in the synchronous response.
|
|
78
83
|
if (!user || !user.passwordHash) {
|
|
79
84
|
await bcryptCompare(password, TIMING_EQUALIZATION_HASH)
|
|
80
|
-
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_credentials', tenantId }).catch(
|
|
85
|
+
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_credentials', tenantId }).catch(reportLoginEventError)
|
|
81
86
|
return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
if (!user.isActive) {
|
|
85
90
|
await bcryptCompare(password, TIMING_EQUALIZATION_HASH)
|
|
86
|
-
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'inactive', tenantId }).catch(
|
|
91
|
+
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'inactive', tenantId }).catch(reportLoginEventError)
|
|
87
92
|
return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
if (customerUserService.checkLockout(user)) {
|
|
91
96
|
await bcryptCompare(password, TIMING_EQUALIZATION_HASH)
|
|
92
|
-
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'locked', tenantId }).catch(
|
|
97
|
+
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'locked', tenantId }).catch(reportLoginEventError)
|
|
93
98
|
return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
const passwordValid = await customerUserService.verifyPassword(user, password)
|
|
97
102
|
if (!passwordValid) {
|
|
98
103
|
await customerUserService.incrementFailedAttempts(user)
|
|
99
|
-
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_password', tenantId }).catch(
|
|
104
|
+
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'invalid_password', tenantId }).catch(reportLoginEventError)
|
|
100
105
|
return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
|
|
101
106
|
}
|
|
102
107
|
|
|
@@ -105,11 +110,14 @@ export async function POST(req: Request) {
|
|
|
105
110
|
email,
|
|
106
111
|
reason: 'email_not_verified',
|
|
107
112
|
tenantId,
|
|
108
|
-
}).catch(
|
|
113
|
+
}).catch(reportLoginEventError)
|
|
109
114
|
return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
await customerUserService.
|
|
117
|
+
if (!(await customerUserService.resetFailedAttemptsUnlessLocked(user))) {
|
|
118
|
+
void emitCustomerAccountsEvent('customer_accounts.login.failed', { email, reason: 'locked', tenantId }).catch(reportLoginEventError)
|
|
119
|
+
return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
|
|
120
|
+
}
|
|
113
121
|
await customerUserService.updateLastLoginAt(user)
|
|
114
122
|
|
|
115
123
|
if (compoundKey) {
|
|
@@ -130,7 +138,7 @@ export async function POST(req: Request) {
|
|
|
130
138
|
email: user.email,
|
|
131
139
|
tenantId,
|
|
132
140
|
organizationId: user.organizationId,
|
|
133
|
-
}).catch(
|
|
141
|
+
}).catch(reportLoginEventError)
|
|
134
142
|
|
|
135
143
|
const res = NextResponse.json({
|
|
136
144
|
ok: true,
|
|
@@ -8,6 +8,14 @@ const BCRYPT_COST = 10
|
|
|
8
8
|
const MAX_FAILED_ATTEMPTS = 5
|
|
9
9
|
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
|
|
10
10
|
|
|
11
|
+
type CustomerUserLockoutDatabase = {
|
|
12
|
+
customer_users: {
|
|
13
|
+
id: string
|
|
14
|
+
failed_login_attempts: number
|
|
15
|
+
locked_until: Date | null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export class CustomerUserService {
|
|
12
20
|
constructor(private em: EntityManager) {}
|
|
13
21
|
|
|
@@ -81,14 +89,32 @@ export class CustomerUserService {
|
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
async incrementFailedAttempts(user: CustomerUser): Promise<void> {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
const lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS)
|
|
93
|
+
await this.em.getKysely<CustomerUserLockoutDatabase>()
|
|
94
|
+
.updateTable('customer_users')
|
|
95
|
+
.set((eb) => ({
|
|
96
|
+
failed_login_attempts: eb('failed_login_attempts', '+', 1),
|
|
97
|
+
locked_until: eb
|
|
98
|
+
.case()
|
|
99
|
+
.when('failed_login_attempts', '>=', MAX_FAILED_ATTEMPTS - 1)
|
|
100
|
+
.then(eb.fn<Date>('greatest', [eb.ref('locked_until'), eb.val(lockedUntil)]))
|
|
101
|
+
.else(eb.ref('locked_until'))
|
|
102
|
+
.end(),
|
|
103
|
+
}))
|
|
104
|
+
.where('id', '=', user.id)
|
|
105
|
+
.execute()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async resetFailedAttemptsUnlessLocked(user: CustomerUser): Promise<boolean> {
|
|
109
|
+
const now = new Date()
|
|
110
|
+
const row = await this.em.getKysely<CustomerUserLockoutDatabase>()
|
|
111
|
+
.updateTable('customer_users')
|
|
112
|
+
.set({ failed_login_attempts: 0, locked_until: null })
|
|
113
|
+
.where('id', '=', user.id)
|
|
114
|
+
.where((eb) => eb.or([eb('locked_until', 'is', null), eb('locked_until', '<=', now)]))
|
|
115
|
+
.returning('id')
|
|
116
|
+
.executeTakeFirst()
|
|
117
|
+
return row !== undefined
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
async resetFailedAttempts(user: CustomerUser): Promise<void> {
|
|
@@ -96,8 +122,6 @@ export class CustomerUserService {
|
|
|
96
122
|
failedLoginAttempts: 0,
|
|
97
123
|
lockedUntil: null,
|
|
98
124
|
})
|
|
99
|
-
user.failedLoginAttempts = 0
|
|
100
|
-
user.lockedUntil = null
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
async updatePassword(user: CustomerUser, newPassword: string, em?: EntityManager): Promise<void> {
|
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
CustomerPersonProfile,
|
|
27
27
|
CustomerTagAssignment,
|
|
28
28
|
} from '../data/entities'
|
|
29
|
+
import {
|
|
30
|
+
captureInteractionRestoreDetails,
|
|
31
|
+
omitInteractionRestoreDetails,
|
|
32
|
+
restoreInteractionRestoreDetails,
|
|
33
|
+
type InteractionRestoreDetails,
|
|
34
|
+
} from './interactionRestoreDetails'
|
|
29
35
|
import {
|
|
30
36
|
companyCreateSchema,
|
|
31
37
|
companyUpdateSchema,
|
|
@@ -189,7 +195,7 @@ type CompanyInteractionSnapshot = {
|
|
|
189
195
|
updatedAt: Date
|
|
190
196
|
deletedAt: Date | null
|
|
191
197
|
custom?: Record<string, unknown>
|
|
192
|
-
}
|
|
198
|
+
} & InteractionRestoreDetails
|
|
193
199
|
|
|
194
200
|
type CompanySnapshot = {
|
|
195
201
|
entity: {
|
|
@@ -246,7 +252,11 @@ type CompanyUndoPayload = {
|
|
|
246
252
|
after?: CompanySnapshot | null
|
|
247
253
|
}
|
|
248
254
|
|
|
249
|
-
async function loadCompanySnapshot(
|
|
255
|
+
async function loadCompanySnapshot(
|
|
256
|
+
em: EntityManager,
|
|
257
|
+
id: string,
|
|
258
|
+
options: { includeInteractionRestoreDetails?: boolean } = {},
|
|
259
|
+
): Promise<CompanySnapshot | null> {
|
|
250
260
|
const entity = await em.findOne(CustomerEntity, { id, deletedAt: null })
|
|
251
261
|
if (!entity || entity.kind !== 'company') return null
|
|
252
262
|
const profile = await em.findOne(CustomerCompanyProfile, { entity })
|
|
@@ -408,6 +418,7 @@ async function loadCompanySnapshot(em: EntityManager, id: string): Promise<Compa
|
|
|
408
418
|
createdAt: interaction.createdAt,
|
|
409
419
|
updatedAt: interaction.updatedAt,
|
|
410
420
|
deletedAt: interaction.deletedAt ?? null,
|
|
421
|
+
...(options.includeInteractionRestoreDetails ? captureInteractionRestoreDetails(interaction) : {}),
|
|
411
422
|
custom: await loadCustomFieldSnapshot(em, {
|
|
412
423
|
entityId: INTERACTION_ENTITY_ID,
|
|
413
424
|
recordId: interaction.id,
|
|
@@ -998,7 +1009,7 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
|
|
|
998
1009
|
async prepare(input, ctx) {
|
|
999
1010
|
const id = requireId(input, 'Company id required')
|
|
1000
1011
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
1001
|
-
const snapshot = await loadCompanySnapshot(em, id)
|
|
1012
|
+
const snapshot = await loadCompanySnapshot(em, id, { includeInteractionRestoreDetails: true })
|
|
1002
1013
|
return snapshot ? { before: snapshot } : {}
|
|
1003
1014
|
},
|
|
1004
1015
|
async execute(input, ctx) {
|
|
@@ -1182,7 +1193,7 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
|
|
|
1182
1193
|
resourceId: before.entity.id,
|
|
1183
1194
|
tenantId: before.entity.tenantId,
|
|
1184
1195
|
organizationId: before.entity.organizationId,
|
|
1185
|
-
snapshotBefore: before,
|
|
1196
|
+
snapshotBefore: omitInteractionRestoreDetails(before),
|
|
1186
1197
|
payload: {
|
|
1187
1198
|
undo: {
|
|
1188
1199
|
before,
|
|
@@ -1421,6 +1432,7 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
|
|
|
1421
1432
|
source: interaction.source,
|
|
1422
1433
|
appearanceIcon: interaction.appearanceIcon,
|
|
1423
1434
|
appearanceColor: interaction.appearanceColor,
|
|
1435
|
+
...restoreInteractionRestoreDetails(interaction),
|
|
1424
1436
|
createdAt: interaction.createdAt,
|
|
1425
1437
|
updatedAt: interaction.updatedAt,
|
|
1426
1438
|
deletedAt: interaction.deletedAt,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { reviveSnapshotDates } from '@open-mercato/shared/lib/commands/undo'
|
|
2
|
+
import type { CustomerInteraction } from '../data/entities'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interaction columns a Person/Company delete snapshot must carry so that undo can recreate each interaction
|
|
6
|
+
* exactly as it was. Every key is optional because delete logs written before these columns were captured
|
|
7
|
+
* do not contain them.
|
|
8
|
+
*/
|
|
9
|
+
export type InteractionRestoreDetails = {
|
|
10
|
+
externalMessageId?: string | null
|
|
11
|
+
channelProviderKey?: string | null
|
|
12
|
+
channelId?: string | null
|
|
13
|
+
durationMinutes?: number | null
|
|
14
|
+
location?: string | null
|
|
15
|
+
allDay?: boolean | null
|
|
16
|
+
recurrenceRule?: string | null
|
|
17
|
+
recurrenceEnd?: Date | string | null
|
|
18
|
+
participants?: CustomerInteraction['participants']
|
|
19
|
+
reminderMinutes?: number | null
|
|
20
|
+
visibility?: string | null
|
|
21
|
+
linkedEntities?: CustomerInteraction['linkedEntities']
|
|
22
|
+
guestPermissions?: CustomerInteraction['guestPermissions']
|
|
23
|
+
pinned?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const INTERACTION_RESTORE_DETAIL_KEYS = [
|
|
27
|
+
'externalMessageId',
|
|
28
|
+
'channelProviderKey',
|
|
29
|
+
'channelId',
|
|
30
|
+
'durationMinutes',
|
|
31
|
+
'location',
|
|
32
|
+
'allDay',
|
|
33
|
+
'recurrenceRule',
|
|
34
|
+
'recurrenceEnd',
|
|
35
|
+
'participants',
|
|
36
|
+
'reminderMinutes',
|
|
37
|
+
'visibility',
|
|
38
|
+
'linkedEntities',
|
|
39
|
+
'guestPermissions',
|
|
40
|
+
'pinned',
|
|
41
|
+
] as const satisfies ReadonlyArray<keyof InteractionRestoreDetails>
|
|
42
|
+
|
|
43
|
+
export function captureInteractionRestoreDetails(interaction: CustomerInteraction): Required<InteractionRestoreDetails> {
|
|
44
|
+
return {
|
|
45
|
+
externalMessageId: interaction.externalMessageId ?? null,
|
|
46
|
+
channelProviderKey: interaction.channelProviderKey ?? null,
|
|
47
|
+
channelId: interaction.channelId ?? null,
|
|
48
|
+
durationMinutes: interaction.durationMinutes ?? null,
|
|
49
|
+
location: interaction.location ?? null,
|
|
50
|
+
allDay: interaction.allDay ?? null,
|
|
51
|
+
recurrenceRule: interaction.recurrenceRule ?? null,
|
|
52
|
+
recurrenceEnd: interaction.recurrenceEnd ?? null,
|
|
53
|
+
participants: interaction.participants ?? null,
|
|
54
|
+
reminderMinutes: interaction.reminderMinutes ?? null,
|
|
55
|
+
visibility: interaction.visibility ?? null,
|
|
56
|
+
linkedEntities: interaction.linkedEntities ?? null,
|
|
57
|
+
guestPermissions: interaction.guestPermissions ?? null,
|
|
58
|
+
pinned: interaction.pinned === true,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A delete log written before visibility was captured cannot tell whether an email was private. An email that
|
|
64
|
+
* has an author is restored private: its author still reads it and can share it again. An email without an
|
|
65
|
+
* author keeps the previous behaviour, because a private row without an author is readable and changeable by
|
|
66
|
+
* nobody.
|
|
67
|
+
*/
|
|
68
|
+
function resolveRestoredVisibility(snapshot: { interactionType: string; authorUserId: string | null } & InteractionRestoreDetails): string | null {
|
|
69
|
+
if (Object.prototype.hasOwnProperty.call(snapshot, 'visibility')) return snapshot.visibility ?? null
|
|
70
|
+
return snapshot.interactionType === 'email' && snapshot.authorUserId ? 'private' : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function restoreInteractionRestoreDetails(
|
|
74
|
+
snapshot: { interactionType: string; authorUserId: string | null } & InteractionRestoreDetails,
|
|
75
|
+
) {
|
|
76
|
+
const { recurrenceEnd } = reviveSnapshotDates({ recurrenceEnd: snapshot.recurrenceEnd ?? null }, ['recurrenceEnd'])
|
|
77
|
+
return {
|
|
78
|
+
externalMessageId: snapshot.externalMessageId ?? null,
|
|
79
|
+
channelProviderKey: snapshot.channelProviderKey ?? null,
|
|
80
|
+
channelId: snapshot.channelId ?? null,
|
|
81
|
+
durationMinutes: snapshot.durationMinutes ?? null,
|
|
82
|
+
location: snapshot.location ?? null,
|
|
83
|
+
allDay: snapshot.allDay ?? null,
|
|
84
|
+
recurrenceRule: snapshot.recurrenceRule ?? null,
|
|
85
|
+
recurrenceEnd: recurrenceEnd as Date | null,
|
|
86
|
+
participants: snapshot.participants ?? null,
|
|
87
|
+
reminderMinutes: snapshot.reminderMinutes ?? null,
|
|
88
|
+
visibility: resolveRestoredVisibility(snapshot),
|
|
89
|
+
linkedEntities: snapshot.linkedEntities ?? null,
|
|
90
|
+
guestPermissions: snapshot.guestPermissions ?? null,
|
|
91
|
+
pinned: snapshot.pinned === true,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The delete log's `snapshotBefore` is what the audit log shows; the restore details stay in the undo payload only.
|
|
97
|
+
*/
|
|
98
|
+
export function omitInteractionRestoreDetails<TSnapshot extends { interactions?: ReadonlyArray<InteractionRestoreDetails> }>(
|
|
99
|
+
snapshot: TSnapshot,
|
|
100
|
+
): TSnapshot {
|
|
101
|
+
if (!Array.isArray(snapshot.interactions)) return snapshot
|
|
102
|
+
return {
|
|
103
|
+
...snapshot,
|
|
104
|
+
interactions: snapshot.interactions.map((interaction) => {
|
|
105
|
+
const visible: Record<string, unknown> = { ...interaction }
|
|
106
|
+
for (const key of INTERACTION_RESTORE_DETAIL_KEYS) delete visible[key]
|
|
107
|
+
return visible
|
|
108
|
+
}),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
CustomerPersonProfile,
|
|
25
25
|
CustomerTagAssignment,
|
|
26
26
|
} from '../data/entities'
|
|
27
|
+
import {
|
|
28
|
+
captureInteractionRestoreDetails,
|
|
29
|
+
omitInteractionRestoreDetails,
|
|
30
|
+
restoreInteractionRestoreDetails,
|
|
31
|
+
type InteractionRestoreDetails,
|
|
32
|
+
} from './interactionRestoreDetails'
|
|
27
33
|
import { resolvePersonCustomFieldRouting, CUSTOMER_ENTITY_ID, PERSON_ENTITY_ID } from '../lib/customFieldRouting'
|
|
28
34
|
import { CustomFieldValue } from '@open-mercato/core/modules/entities/data/entities'
|
|
29
35
|
import {
|
|
@@ -133,7 +139,7 @@ type PersonInteractionSnapshot = {
|
|
|
133
139
|
updatedAt: Date
|
|
134
140
|
deletedAt: Date | null
|
|
135
141
|
custom?: Record<string, unknown>
|
|
136
|
-
}
|
|
142
|
+
} & InteractionRestoreDetails
|
|
137
143
|
|
|
138
144
|
type PersonSnapshot = {
|
|
139
145
|
entity: {
|
|
@@ -380,7 +386,11 @@ function serializePersonSnapshot(
|
|
|
380
386
|
}
|
|
381
387
|
}
|
|
382
388
|
|
|
383
|
-
async function loadPersonSnapshot(
|
|
389
|
+
async function loadPersonSnapshot(
|
|
390
|
+
em: EntityManager,
|
|
391
|
+
entityId: string,
|
|
392
|
+
options: { includeInteractionRestoreDetails?: boolean } = {},
|
|
393
|
+
): Promise<PersonSnapshot | null> {
|
|
384
394
|
const entity = await em.findOne(CustomerEntity, { id: entityId, deletedAt: null })
|
|
385
395
|
if (!entity || entity.kind !== 'person') return null
|
|
386
396
|
const profile = await findOneWithDecryption(
|
|
@@ -455,6 +465,7 @@ async function loadPersonSnapshot(em: EntityManager, entityId: string): Promise<
|
|
|
455
465
|
createdAt: interaction.createdAt,
|
|
456
466
|
updatedAt: interaction.updatedAt,
|
|
457
467
|
deletedAt: interaction.deletedAt ?? null,
|
|
468
|
+
...(options.includeInteractionRestoreDetails ? captureInteractionRestoreDetails(interaction) : {}),
|
|
458
469
|
custom: await loadCustomFieldSnapshot(em, {
|
|
459
470
|
entityId: INTERACTION_ENTITY_ID,
|
|
460
471
|
recordId: interaction.id,
|
|
@@ -1231,7 +1242,7 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
|
|
|
1231
1242
|
async prepare(input, ctx) {
|
|
1232
1243
|
const id = requireId(input, 'Person id required')
|
|
1233
1244
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1234
|
-
const snapshot = await loadPersonSnapshot(em, id)
|
|
1245
|
+
const snapshot = await loadPersonSnapshot(em, id, { includeInteractionRestoreDetails: true })
|
|
1235
1246
|
return snapshot ? { before: snapshot } : {}
|
|
1236
1247
|
},
|
|
1237
1248
|
async execute(input, ctx) {
|
|
@@ -1355,7 +1366,7 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
|
|
|
1355
1366
|
resourceId: before.entity.id,
|
|
1356
1367
|
tenantId: before.entity.tenantId,
|
|
1357
1368
|
organizationId: before.entity.organizationId,
|
|
1358
|
-
snapshotBefore: before,
|
|
1369
|
+
snapshotBefore: omitInteractionRestoreDetails(before),
|
|
1359
1370
|
payload: {
|
|
1360
1371
|
undo: {
|
|
1361
1372
|
before,
|
|
@@ -1646,6 +1657,7 @@ const deletePersonCommand: CommandHandler<{ body?: Record<string, unknown>; quer
|
|
|
1646
1657
|
source: interaction.source,
|
|
1647
1658
|
appearanceIcon: interaction.appearanceIcon,
|
|
1648
1659
|
appearanceColor: interaction.appearanceColor,
|
|
1660
|
+
...restoreInteractionRestoreDetails(interaction),
|
|
1649
1661
|
createdAt: interaction.createdAt,
|
|
1650
1662
|
updatedAt: interaction.updatedAt,
|
|
1651
1663
|
deletedAt: interaction.deletedAt,
|
package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts
CHANGED
|
@@ -84,7 +84,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
84
84
|
DELETE: {
|
|
85
85
|
summary: 'Revoke invitation',
|
|
86
86
|
description:
|
|
87
|
-
'Soft-revokes an invitation — the row is retained and its status becomes revoked so a revoked token resolves to 410 (not 404).',
|
|
87
|
+
'Soft-revokes an invitation — the row is retained and its status becomes revoked so a revoked token resolves to 410 (not 404); access tokens already issued for the invitation stop working (401).',
|
|
88
88
|
responses: [{ status: 200, description: 'Invitation revoked', schema: deleteResponseSchema }],
|
|
89
89
|
errors: [{ status: 404, description: 'Invitation not found', schema: errorSchema }],
|
|
90
90
|
},
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Final submit. Body: `{ base_revision_id, submit_metadata? }`. Sets
|
|
5
5
|
* status = submitted, captures `submit_metadata` (locale, IP, UA), emits
|
|
6
|
-
* `forms.submission.submitted`.
|
|
7
|
-
*
|
|
6
|
+
* `forms.submission.submitted`. A submission the caller started themselves
|
|
7
|
+
* must carry their own subject (`lib/portal-subject-policy.ts`), so a draft
|
|
8
|
+
* started for someone else before that check existed cannot be finalized.
|
|
9
|
+
* Idempotency hardening lands in 1d once the client renderer is in place;
|
|
10
|
+
* phase 1c rejects double-submits with 422.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
@@ -14,7 +17,14 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
14
17
|
import { getCustomerAuthFromRequest } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
|
|
15
18
|
import { SubmissionService } from '../../../../services/submission-service'
|
|
16
19
|
import { submissionSubmitInputSchema } from '../../../../data/validators'
|
|
17
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
buildSubmitMetadata,
|
|
22
|
+
isPortalSubjectForbiddenError,
|
|
23
|
+
mapSubmissionError,
|
|
24
|
+
portalSubjectForbiddenResponse,
|
|
25
|
+
readJsonBody,
|
|
26
|
+
serializeSubmission,
|
|
27
|
+
} from '../../../runtime-helpers'
|
|
18
28
|
|
|
19
29
|
export const metadata = {
|
|
20
30
|
POST: { requireAuth: false },
|
|
@@ -56,9 +66,11 @@ export async function POST(
|
|
|
56
66
|
baseRevisionId: parsed.data.base_revision_id,
|
|
57
67
|
submittedBy: auth.sub,
|
|
58
68
|
submitMetadata: buildSubmitMetadata(req, parsed.data.submit_metadata),
|
|
69
|
+
submittedByPortalCustomer: true,
|
|
59
70
|
})
|
|
60
71
|
return NextResponse.json({ submission: serializeSubmission(submission) })
|
|
61
72
|
} catch (error) {
|
|
73
|
+
if (isPortalSubjectForbiddenError(error)) return portalSubjectForbiddenResponse()
|
|
62
74
|
return mapSubmissionError(error)
|
|
63
75
|
}
|
|
64
76
|
}
|
|
@@ -79,6 +91,7 @@ const postMethodDoc: OpenApiMethodDoc = {
|
|
|
79
91
|
responses: [{ status: 200, description: 'Submission submitted', schema: responseSchema }],
|
|
80
92
|
errors: [
|
|
81
93
|
{ status: 401, description: 'Not authenticated', schema: errorSchema },
|
|
94
|
+
{ status: 403, description: 'SUBJECT_FORBIDDEN: caller-started submission is not on the caller\'s own customer subject', schema: errorSchema },
|
|
82
95
|
{ status: 404, description: 'Submission not found', schema: errorSchema },
|
|
83
96
|
{ status: 409, description: 'Stale base_revision_id', schema: errorSchema },
|
|
84
97
|
{ status: 422, description: 'Submission already in terminal state', schema: errorSchema },
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Runtime API — POST /api/form-submissions
|
|
3
3
|
*
|
|
4
4
|
* Starts a new submission for the active version of a form. Customer auth is
|
|
5
|
-
* required
|
|
5
|
+
* required, and the subject must be the caller's own `customer` subject
|
|
6
|
+
* (`lib/portal-subject-policy.ts`); any other pair is refused with 403 before
|
|
7
|
+
* anything is persisted. The starter is auto-assigned the form version's
|
|
6
8
|
* `x-om-default-actor-role` (or first declared role) — admin assignment of
|
|
7
9
|
* additional actors is the admin route.
|
|
8
10
|
*/
|
|
@@ -18,7 +20,15 @@ import {
|
|
|
18
20
|
type PrefillResolver,
|
|
19
21
|
} from '../../services/prefill-resolver'
|
|
20
22
|
import { submissionStartInputSchema } from '../../data/validators'
|
|
21
|
-
import {
|
|
23
|
+
import { isPortalSubjectAllowed } from '../../lib/portal-subject-policy'
|
|
24
|
+
import {
|
|
25
|
+
mapSubmissionError,
|
|
26
|
+
portalSubjectForbiddenResponse,
|
|
27
|
+
readJsonBody,
|
|
28
|
+
serializeActor,
|
|
29
|
+
serializeRevision,
|
|
30
|
+
serializeSubmission,
|
|
31
|
+
} from '../runtime-helpers'
|
|
22
32
|
|
|
23
33
|
export const metadata = {
|
|
24
34
|
POST: { requireAuth: false },
|
|
@@ -43,6 +53,14 @@ export async function POST(req: NextRequest) {
|
|
|
43
53
|
{ status: 422 },
|
|
44
54
|
)
|
|
45
55
|
}
|
|
56
|
+
if (
|
|
57
|
+
!isPortalSubjectAllowed(auth.sub, {
|
|
58
|
+
subjectType: parsed.data.subject_type,
|
|
59
|
+
subjectId: parsed.data.subject_id,
|
|
60
|
+
})
|
|
61
|
+
) {
|
|
62
|
+
return portalSubjectForbiddenResponse()
|
|
63
|
+
}
|
|
46
64
|
|
|
47
65
|
const container = await createRequestContainer()
|
|
48
66
|
const service = container.resolve('formsSubmissionService') as SubmissionService
|
|
@@ -122,6 +140,7 @@ const postMethodDoc: OpenApiMethodDoc = {
|
|
|
122
140
|
responses: [{ status: 201, description: 'Submission started', schema: submissionResponseSchema }],
|
|
123
141
|
errors: [
|
|
124
142
|
{ status: 401, description: 'Not authenticated', schema: errorSchema },
|
|
143
|
+
{ status: 403, description: 'SUBJECT_FORBIDDEN: subject is not the caller\'s own customer subject', schema: errorSchema },
|
|
125
144
|
{ status: 404, description: 'Form not found', schema: errorSchema },
|
|
126
145
|
{ status: 422, description: 'Validation or form-state error', schema: errorSchema },
|
|
127
146
|
],
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* then mark the invitation submitted. A capped distribution returns 410 BEFORE
|
|
10
10
|
* any submission state changes. Customer-session submits skip the
|
|
11
11
|
* reserve/mark-invitation steps — those are anonymous-distribution concerns.
|
|
12
|
+
* A customer-session submit of a submission the caller started themselves
|
|
13
|
+
* must carry their own subject (`lib/portal-subject-policy.ts`).
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
@@ -23,7 +25,9 @@ import { submissionSubmitInputSchema } from '../../../../../data/validators'
|
|
|
23
25
|
import { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'
|
|
24
26
|
import {
|
|
25
27
|
buildSubmitMetadata,
|
|
28
|
+
isPortalSubjectForbiddenError,
|
|
26
29
|
mapDistributionError,
|
|
30
|
+
portalSubjectForbiddenResponse,
|
|
27
31
|
readJsonBody,
|
|
28
32
|
serializeSubmission,
|
|
29
33
|
} from '../../../../runtime-helpers'
|
|
@@ -104,6 +108,7 @@ export async function POST(
|
|
|
104
108
|
baseRevisionId: parsed.data.base_revision_id,
|
|
105
109
|
submittedBy: principal.principal,
|
|
106
110
|
submitMetadata: buildSubmitMetadata(req, parsed.data.submit_metadata),
|
|
111
|
+
submittedByPortalCustomer: principal.source === 'customer',
|
|
107
112
|
})
|
|
108
113
|
|
|
109
114
|
if (principal.source === 'token') {
|
|
@@ -121,6 +126,7 @@ export async function POST(
|
|
|
121
126
|
redirect_url: distribution?.redirectUrl ?? null,
|
|
122
127
|
})
|
|
123
128
|
} catch (error) {
|
|
129
|
+
if (isPortalSubjectForbiddenError(error)) return portalSubjectForbiddenResponse()
|
|
124
130
|
return mapDistributionError(error)
|
|
125
131
|
}
|
|
126
132
|
}
|
|
@@ -144,6 +150,7 @@ const postMethodDoc: OpenApiMethodDoc = {
|
|
|
144
150
|
responses: [{ status: 200, description: 'Submission submitted', schema: responseSchema }],
|
|
145
151
|
errors: [
|
|
146
152
|
{ status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },
|
|
153
|
+
{ status: 403, description: 'SUBJECT_FORBIDDEN: customer-started submission is not on the caller\'s own customer subject', schema: errorSchema },
|
|
147
154
|
{ status: 404, description: 'Submission or invitation not found', schema: errorSchema },
|
|
148
155
|
{ status: 409, description: 'Stale base_revision_id', schema: errorSchema },
|
|
149
156
|
{ status: 410, description: 'Distribution response cap reached or unavailable', schema: errorSchema },
|