@open-mercato/core 0.9.1-develop.7519.1.38f51f8dbb → 0.9.1-develop.7532.1.8eac7e198f
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 +6 -1
- 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/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/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 +6 -1
- 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/customer_accounts/api/login.ts +15 -7
- package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
- 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
|
@@ -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> {
|
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 },
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
7
|
import { SubmissionServiceError, isSubmissionServiceError } from '../services/submission-service'
|
|
7
8
|
import { DistributionServiceError, isDistributionServiceError } from '../services/distribution-service'
|
|
8
9
|
import { readEmbedSettings } from '../lib/embed-frame-policy'
|
|
@@ -27,6 +28,21 @@ export function mapSubmissionError(error: unknown): NextResponse {
|
|
|
27
28
|
return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
export function isPortalSubjectForbiddenError(error: unknown): boolean {
|
|
32
|
+
return isSubmissionServiceError(error) && error.code === 'SUBJECT_FORBIDDEN'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function portalSubjectForbiddenResponse(): Promise<NextResponse> {
|
|
36
|
+
const { translate } = await resolveTranslations()
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{
|
|
39
|
+
error: 'SUBJECT_FORBIDDEN',
|
|
40
|
+
message: translate('forms.errors.subject_forbidden', 'You can only start or submit this form for yourself.'),
|
|
41
|
+
},
|
|
42
|
+
{ status: 403 },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
export function mapDistributionError(error: unknown): NextResponse {
|
|
31
47
|
if (isDistributionServiceError(error)) {
|
|
32
48
|
return NextResponse.json(
|
|
@@ -57,7 +57,8 @@ export type RoleIdentifier = z.infer<typeof roleIdentifierSchema>
|
|
|
57
57
|
* Submission subject identifier. Polymorphic: subject_type is a free-form
|
|
58
58
|
* string tag (`patient`, `customer`, `case`, etc.); subject_id MUST be a
|
|
59
59
|
* UUID. Validation of the (subject_type, subject_id) pair against actual
|
|
60
|
-
* domain entities is the responsibility of the calling module
|
|
60
|
+
* domain entities is the responsibility of the calling module; the portal
|
|
61
|
+
* runtime enforces `lib/portal-subject-policy.ts`.
|
|
61
62
|
*/
|
|
62
63
|
export const submissionSubjectSchema = z.object({
|
|
63
64
|
subject_type: z.string().min(1).max(64),
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
"forms.errors.snapshot_missing": "Snapshot-Eintrag nicht gefunden.",
|
|
194
194
|
"forms.errors.snapshot_pending": "PDF-Snapshot wird erstellt.",
|
|
195
195
|
"forms.errors.snapshot_storage_unconfigured": "Snapshot-Speicher ist noch nicht konfiguriert.",
|
|
196
|
+
"forms.errors.subject_forbidden": "Sie können dieses Formular nur für sich selbst starten oder absenden.",
|
|
196
197
|
"forms.errors.submission_not_found": "Einreichung nicht gefunden.",
|
|
197
198
|
"forms.errors.tenant_required": "Ein Mandantenkontext ist erforderlich.",
|
|
198
199
|
"forms.errors.unauthorized": "Nicht autorisiert.",
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
"forms.errors.snapshot_missing": "Snapshot record not found.",
|
|
194
194
|
"forms.errors.snapshot_pending": "PDF snapshot is being generated.",
|
|
195
195
|
"forms.errors.snapshot_storage_unconfigured": "Snapshot storage is not yet configured.",
|
|
196
|
+
"forms.errors.subject_forbidden": "You can only start or submit this form for yourself.",
|
|
196
197
|
"forms.errors.submission_not_found": "Submission not found.",
|
|
197
198
|
"forms.errors.tenant_required": "Tenant context is required.",
|
|
198
199
|
"forms.errors.unauthorized": "Unauthorized.",
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
"forms.errors.snapshot_missing": "Registro de instantánea no encontrado.",
|
|
194
194
|
"forms.errors.snapshot_pending": "Se está generando la instantánea en PDF.",
|
|
195
195
|
"forms.errors.snapshot_storage_unconfigured": "El almacenamiento de instantáneas aún no está configurado.",
|
|
196
|
+
"forms.errors.subject_forbidden": "Solo puede iniciar o enviar este formulario en su propio nombre.",
|
|
196
197
|
"forms.errors.submission_not_found": "Envío no encontrado.",
|
|
197
198
|
"forms.errors.tenant_required": "Se requiere el contexto del inquilino.",
|
|
198
199
|
"forms.errors.unauthorized": "No autorizado.",
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
"forms.errors.snapshot_missing": "Snapshot record not found.",
|
|
194
194
|
"forms.errors.snapshot_pending": "PDF snapshot is being generated.",
|
|
195
195
|
"forms.errors.snapshot_storage_unconfigured": "Snapshot storage is not yet configured.",
|
|
196
|
+
"forms.errors.subject_forbidden": "이 양식은 본인에 대해서만 시작하거나 제출할 수 있습니다.",
|
|
196
197
|
"forms.errors.submission_not_found": "Submission not found.",
|
|
197
198
|
"forms.errors.tenant_required": "Tenant context is required.",
|
|
198
199
|
"forms.errors.unauthorized": "Unauthorized.",
|
|
@@ -193,6 +193,7 @@
|
|
|
193
193
|
"forms.errors.snapshot_missing": "Nie znaleziono rekordu migawki.",
|
|
194
194
|
"forms.errors.snapshot_pending": "Migawka PDF jest generowana.",
|
|
195
195
|
"forms.errors.snapshot_storage_unconfigured": "Magazyn migawek nie został jeszcze skonfigurowany.",
|
|
196
|
+
"forms.errors.subject_forbidden": "Możesz rozpocząć lub wysłać ten formularz tylko we własnym imieniu.",
|
|
196
197
|
"forms.errors.submission_not_found": "Nie znaleziono zgłoszenia.",
|
|
197
198
|
"forms.errors.tenant_required": "Wymagany jest kontekst najemcy.",
|
|
198
199
|
"forms.errors.unauthorized": "Brak uprawnień.",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal subject policy (#7224).
|
|
3
|
+
*
|
|
4
|
+
* A portal customer may start a submission, and finalize a submission they
|
|
5
|
+
* started themselves, only for the subject that identifies them: the
|
|
6
|
+
* `customer` subject whose id is their own customer user id (`auth.sub`).
|
|
7
|
+
* Any other pair is refused before anything is persisted, because the
|
|
8
|
+
* subject is what the consent projector attributes signatures to.
|
|
9
|
+
*
|
|
10
|
+
* A submission started by someone else reached the caller through a staff
|
|
11
|
+
* actor assignment (`forms.submissions.manage`), so staff already vouched for
|
|
12
|
+
* that pairing and the submit is not re-checked here. Token (invitation)
|
|
13
|
+
* principals carry a server-derived subject and are out of scope.
|
|
14
|
+
*
|
|
15
|
+
* The start route applies `isPortalSubjectAllowed` before anything is
|
|
16
|
+
* persisted; `SubmissionService.submit()` applies `isPortalSubmitAllowed`
|
|
17
|
+
* inside its locked transaction when the caller is a portal customer.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const PORTAL_SUBJECT_TYPE = 'customer'
|
|
21
|
+
|
|
22
|
+
export type PortalSubject = {
|
|
23
|
+
subjectType: string
|
|
24
|
+
subjectId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type PortalSubmissionSubject = PortalSubject & {
|
|
28
|
+
startedBy: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isSameId(left: string, right: string): boolean {
|
|
32
|
+
return left.toLowerCase() === right.toLowerCase()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isPortalSubjectAllowed(customerUserId: string, subject: PortalSubject): boolean {
|
|
36
|
+
return subject.subjectType === PORTAL_SUBJECT_TYPE && isSameId(subject.subjectId, customerUserId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isPortalSubmitAllowed(customerUserId: string, submission: PortalSubmissionSubject): boolean {
|
|
40
|
+
if (!isSameId(submission.startedBy, customerUserId)) return true
|
|
41
|
+
return isPortalSubjectAllowed(customerUserId, submission)
|
|
42
|
+
}
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
* the org/tenant are NEVER trusted from the token body — they are re-derived
|
|
10
10
|
* from the persisted `forms_form_submission` row. The token only authorizes a
|
|
11
11
|
* single `(submissionId, invitationId, role)` triple; it asserts the token's
|
|
12
|
-
* submission matches the route's submission before trusting anything.
|
|
12
|
+
* submission matches the route's submission before trusting anything. The
|
|
13
|
+
* token is also only honoured while its invitation is live (not revoked or
|
|
14
|
+
* expired) and its actor row on the submission is active; the role comes from
|
|
15
|
+
* that actor row, never from the token.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
16
19
|
import { getCustomerAuthFromRequest } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
|
|
17
|
-
import { FormSubmission, FormSubmissionActor } from '../data/entities'
|
|
20
|
+
import { FormInvitation, FormSubmission, FormSubmissionActor } from '../data/entities'
|
|
21
|
+
import { INVITATION_ACCESS_CLOSED_STATUSES } from '../services/distribution-service'
|
|
18
22
|
import { verifyAccessToken } from '../services/distribution-token'
|
|
19
23
|
|
|
20
24
|
export type RuntimePrincipal = {
|
|
@@ -54,15 +58,35 @@ export async function resolveRuntimePrincipal(
|
|
|
54
58
|
id: submissionId,
|
|
55
59
|
deletedAt: null,
|
|
56
60
|
})
|
|
57
|
-
if (submission)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
if (!submission) return null
|
|
62
|
+
const invitation = await em.findOne(FormInvitation, {
|
|
63
|
+
id: verified.invitationId,
|
|
64
|
+
organizationId: submission.organizationId,
|
|
65
|
+
tenantId: submission.tenantId,
|
|
66
|
+
deletedAt: null,
|
|
67
|
+
})
|
|
68
|
+
if (
|
|
69
|
+
!invitation ||
|
|
70
|
+
invitation.submissionId !== submissionId ||
|
|
71
|
+
INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)
|
|
72
|
+
) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
const actor = await em.findOne(FormSubmissionActor, {
|
|
76
|
+
submissionId,
|
|
77
|
+
organizationId: submission.organizationId,
|
|
78
|
+
userId: invitation.id,
|
|
79
|
+
revokedAt: null,
|
|
80
|
+
deletedAt: null,
|
|
81
|
+
})
|
|
82
|
+
if (!actor) return null
|
|
83
|
+
return {
|
|
84
|
+
source: 'token',
|
|
85
|
+
principal: verified.invitationId,
|
|
86
|
+
role: actor.role,
|
|
87
|
+
organizationId: submission.organizationId,
|
|
88
|
+
tenantId: submission.tenantId,
|
|
89
|
+
submissionId,
|
|
66
90
|
}
|
|
67
91
|
}
|
|
68
92
|
// A present-but-invalid bearer token does not fall through to customer
|
|
@@ -92,7 +92,9 @@ const noopLogger: StructuredLogger = {
|
|
|
92
92
|
error: () => {},
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
const
|
|
95
|
+
export const INVITATION_ACCESS_CLOSED_STATUSES: ReadonlySet<string> = new Set(['revoked', 'expired'])
|
|
96
|
+
|
|
97
|
+
const INVITATION_TERMINAL_STATUSES: ReadonlySet<string> = new Set(['submitted', ...INVITATION_ACCESS_CLOSED_STATUSES])
|
|
96
98
|
|
|
97
99
|
export type BeginAnonymousArgs = {
|
|
98
100
|
distribution: FormDistribution
|
|
@@ -378,20 +380,40 @@ export class DistributionService {
|
|
|
378
380
|
submissionId: string
|
|
379
381
|
}): Promise<void> {
|
|
380
382
|
const em = this.emFactory()
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
const marked = await em.transactional(async (trx) => {
|
|
384
|
+
const invitation = await trx.findOne(
|
|
385
|
+
FormInvitation,
|
|
386
|
+
{
|
|
387
|
+
id: args.invitationId,
|
|
388
|
+
organizationId: args.organizationId,
|
|
389
|
+
tenantId: args.tenantId,
|
|
390
|
+
deletedAt: null,
|
|
391
|
+
},
|
|
392
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
393
|
+
)
|
|
394
|
+
if (!invitation) {
|
|
395
|
+
throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)
|
|
396
|
+
}
|
|
397
|
+
if (INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)) {
|
|
398
|
+
this.logger.warn(
|
|
399
|
+
{
|
|
400
|
+
event: 'forms.distribution.invitation_submit_skipped',
|
|
401
|
+
invitationId: invitation.id,
|
|
402
|
+
submissionId: args.submissionId,
|
|
403
|
+
status: invitation.status,
|
|
404
|
+
},
|
|
405
|
+
'Invitation access closed before submit; status left unchanged.',
|
|
406
|
+
)
|
|
407
|
+
return false
|
|
408
|
+
}
|
|
409
|
+
invitation.status = 'submitted'
|
|
410
|
+
invitation.submittedAt = this.now()
|
|
411
|
+
await trx.flush()
|
|
412
|
+
return true
|
|
386
413
|
})
|
|
387
|
-
if (!
|
|
388
|
-
throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)
|
|
389
|
-
}
|
|
390
|
-
invitation.status = 'submitted'
|
|
391
|
-
invitation.submittedAt = this.now()
|
|
392
|
-
await em.flush()
|
|
414
|
+
if (!marked) return
|
|
393
415
|
await this.safeEmit('forms.invitation.submitted', {
|
|
394
|
-
invitationId:
|
|
416
|
+
invitationId: args.invitationId,
|
|
395
417
|
submissionId: args.submissionId,
|
|
396
418
|
})
|
|
397
419
|
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type VisibilitySchemaNode,
|
|
40
40
|
} from './visibility-resolver'
|
|
41
41
|
import { buildTamperingMarker, type StructuredLogger } from '../lib/log-redaction'
|
|
42
|
+
import { isPortalSubmitAllowed } from '../lib/portal-subject-policy'
|
|
42
43
|
import { formsEventPayloadSchemas } from '../events-payloads'
|
|
43
44
|
import { validateFieldValue } from './field-validation-service'
|
|
44
45
|
import type { FieldNode } from '../backend/forms/[id]/studio/schema-helpers'
|
|
@@ -74,6 +75,7 @@ export type SubmissionServiceErrorCode =
|
|
|
74
75
|
| 'INVALID_ROLE'
|
|
75
76
|
| 'FORM_INACTIVE'
|
|
76
77
|
| 'FORM_VERSION_NOT_PUBLISHED'
|
|
78
|
+
| 'SUBJECT_FORBIDDEN'
|
|
77
79
|
|
|
78
80
|
/**
|
|
79
81
|
* `Symbol.for` so the brand survives module duplication across bundle
|
|
@@ -144,6 +146,13 @@ export type SubmitArgs = Scope & {
|
|
|
144
146
|
baseRevisionId: string
|
|
145
147
|
submittedBy: string
|
|
146
148
|
submitMetadata?: Record<string, unknown> | null
|
|
149
|
+
/**
|
|
150
|
+
* Set when `submittedBy` is a signed-in portal customer. A submission that
|
|
151
|
+
* customer started themselves must then carry their own `customer` subject
|
|
152
|
+
* (`lib/portal-subject-policy.ts`), otherwise 403 SUBJECT_FORBIDDEN. Left
|
|
153
|
+
* unset for invitation (token) principals, whose subject is server-derived.
|
|
154
|
+
*/
|
|
155
|
+
submittedByPortalCustomer?: boolean
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
export type ReopenArgs = Scope & {
|
|
@@ -748,6 +757,9 @@ export class SubmissionService {
|
|
|
748
757
|
if (!submission) {
|
|
749
758
|
throw new SubmissionServiceError('NOT_FOUND', 'Submission not found.', 404)
|
|
750
759
|
}
|
|
760
|
+
if (args.submittedByPortalCustomer && !isPortalSubmitAllowed(args.submittedBy, submission)) {
|
|
761
|
+
throw new SubmissionServiceError('SUBJECT_FORBIDDEN', 'Submission subject is not the caller\'s own customer subject.', 403)
|
|
762
|
+
}
|
|
751
763
|
if (submission.status === 'submitted') {
|
|
752
764
|
throw new SubmissionServiceError('INVALID_STATUS', 'Submission already submitted.', 422)
|
|
753
765
|
}
|