@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.
Files changed (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/logout.js +6 -1
  3. package/dist/modules/auth/api/logout.js.map +2 -2
  4. package/dist/modules/auth/api/session/refresh.js +6 -1
  5. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  6. package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
  7. package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
  8. package/dist/modules/catalog/ai-tools/types.js.map +2 -2
  9. package/dist/modules/customer_accounts/api/login.js +14 -7
  10. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  11. package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
  12. package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
  13. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
  14. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
  15. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
  16. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
  17. package/dist/modules/forms/api/form-submissions/route.js +16 -1
  18. package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
  19. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
  20. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
  21. package/dist/modules/forms/api/runtime-helpers.js +16 -0
  22. package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
  23. package/dist/modules/forms/data/validators.js.map +2 -2
  24. package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
  25. package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
  26. package/dist/modules/forms/lib/runtime-principal.js +27 -10
  27. package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
  28. package/dist/modules/forms/services/distribution-service.js +35 -13
  29. package/dist/modules/forms/services/distribution-service.js.map +2 -2
  30. package/dist/modules/forms/services/submission-service.js +4 -0
  31. package/dist/modules/forms/services/submission-service.js.map +2 -2
  32. package/dist/modules/staff/commands/leave-requests.js +54 -24
  33. package/dist/modules/staff/commands/leave-requests.js.map +2 -2
  34. package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
  35. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  36. package/dist/modules/warranty_claims/commands/claims.js +1 -1
  37. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  38. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
  39. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
  40. package/dist/modules/wms/commands/inventory-actions.js +19 -3
  41. package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
  42. package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
  43. package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
  44. package/dist/modules/workflows/lib/parallel-handler.js +20 -14
  45. package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
  46. package/dist/modules/workflows/lib/workflow-executor.js +21 -23
  47. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  48. package/package.json +7 -7
  49. package/src/modules/auth/api/logout.ts +6 -1
  50. package/src/modules/auth/api/session/refresh.ts +6 -1
  51. package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
  52. package/src/modules/catalog/ai-tools/types.ts +1 -0
  53. package/src/modules/customer_accounts/api/login.ts +15 -7
  54. package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
  55. package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
  56. package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
  57. package/src/modules/forms/api/form-submissions/route.ts +21 -2
  58. package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
  59. package/src/modules/forms/api/runtime-helpers.ts +16 -0
  60. package/src/modules/forms/data/validators.ts +2 -1
  61. package/src/modules/forms/i18n/de.json +1 -0
  62. package/src/modules/forms/i18n/en.json +1 -0
  63. package/src/modules/forms/i18n/es.json +1 -0
  64. package/src/modules/forms/i18n/ko.json +1 -0
  65. package/src/modules/forms/i18n/pl.json +1 -0
  66. package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
  67. package/src/modules/forms/lib/runtime-principal.ts +35 -11
  68. package/src/modules/forms/services/distribution-service.ts +35 -13
  69. package/src/modules/forms/services/submission-service.ts +12 -0
  70. package/src/modules/staff/commands/leave-requests.ts +54 -23
  71. package/src/modules/staff/i18n/de.json +2 -0
  72. package/src/modules/staff/i18n/en.json +2 -0
  73. package/src/modules/staff/i18n/es.json +2 -0
  74. package/src/modules/staff/i18n/ko.json +2 -0
  75. package/src/modules/staff/i18n/pl.json +2 -0
  76. package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
  77. package/src/modules/warranty_claims/commands/claims.ts +1 -1
  78. package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
  79. package/src/modules/wms/commands/inventory-actions.ts +30 -10
  80. package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
  81. package/src/modules/workflows/lib/parallel-handler.ts +24 -15
  82. 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(() => undefined)
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(() => undefined)
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(() => undefined)
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(() => undefined)
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(() => undefined)
113
+ }).catch(reportLoginEventError)
109
114
  return NextResponse.json({ ok: false, error: 'Invalid email or password' }, { status: 401 })
110
115
  }
111
116
 
112
- await customerUserService.resetFailedAttempts(user)
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(() => undefined)
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 newCount = (user.failedLoginAttempts || 0) + 1
85
- const updates: Record<string, unknown> = { failedLoginAttempts: newCount }
86
- if (newCount >= MAX_FAILED_ATTEMPTS) {
87
- updates.lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS)
88
- }
89
- await this.em.nativeUpdate(CustomerUser, { id: user.id }, updates)
90
- user.failedLoginAttempts = newCount
91
- if (updates.lockedUntil) user.lockedUntil = updates.lockedUntil as Date
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> {
@@ -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`. Idempotency hardening lands in 1d once the
7
- * client renderer is in place; phase 1c rejects double-submits with 422.
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 { buildSubmitMetadata, mapSubmissionError, readJsonBody, serializeSubmission } from '../../../runtime-helpers'
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. The starter is auto-assigned the form version's
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 { mapSubmissionError, readJsonBody, serializeActor, serializeRevision, serializeSubmission } from '../runtime-helpers'
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
- return {
59
- source: 'token',
60
- principal: verified.invitationId,
61
- role: verified.role ?? null,
62
- organizationId: submission.organizationId,
63
- tenantId: submission.tenantId,
64
- submissionId,
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 INVITATION_TERMINAL_STATUSES: ReadonlySet<string> = new Set(['submitted', 'revoked', 'expired'])
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 invitation = await em.findOne(FormInvitation, {
382
- id: args.invitationId,
383
- organizationId: args.organizationId,
384
- tenantId: args.tenantId,
385
- deletedAt: null,
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 (!invitation) {
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: invitation.id,
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
  }