@open-mercato/core 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e
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/helpers/integration/dbFixtures.js.map +2 -2
- package/dist/helpers/integration/ui.js +3 -0
- package/dist/helpers/integration/ui.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +3 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/catalog/widgets/injection-table.js +5 -4
- package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
- package/dist/modules/currencies/di.js +2 -0
- package/dist/modules/currencies/di.js.map +2 -2
- package/dist/modules/currencies/services/providers/registry.js +31 -0
- package/dist/modules/currencies/services/providers/registry.js.map +7 -0
- package/dist/modules/directory/api/organization-switcher/route.js +7 -4
- package/dist/modules/directory/api/organization-switcher/route.js.map +2 -2
- package/dist/modules/entities/api/encryption.js +22 -11
- package/dist/modules/entities/api/encryption.js.map +2 -2
- package/dist/modules/notifications/api/openapi.js +5 -0
- package/dist/modules/notifications/api/openapi.js.map +2 -2
- package/dist/modules/notifications/api/route.js +27 -4
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/api/settings/route.js +2 -1
- package/dist/modules/notifications/api/settings/route.js.map +2 -2
- package/dist/modules/notifications/api/unread-count/route.js +4 -2
- package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +25 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/payment_gateways/lib/descriptor-service.js +12 -1
- package/dist/modules/payment_gateways/lib/descriptor-service.js.map +2 -2
- package/dist/modules/progress/data/validators.js +2 -1
- package/dist/modules/progress/data/validators.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +4 -0
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/sales/commands/payments.js +6 -4
- package/dist/modules/sales/commands/payments.js.map +2 -2
- package/dist/modules/sales/widgets/injection-table.js +7 -4
- package/dist/modules/sales/widgets/injection-table.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/dbFixtures.ts +20 -2
- package/src/helpers/integration/ui.ts +12 -0
- package/src/modules/auth/lib/setup-app.ts +8 -2
- package/src/modules/catalog/widgets/injection-table.ts +5 -4
- package/src/modules/currencies/di.ts +2 -0
- package/src/modules/currencies/services/providers/registry.ts +35 -0
- package/src/modules/directory/api/organization-switcher/route.ts +16 -4
- package/src/modules/entities/api/encryption.ts +22 -12
- package/src/modules/notifications/api/openapi.ts +5 -0
- package/src/modules/notifications/api/route.ts +31 -2
- package/src/modules/notifications/api/settings/route.ts +8 -1
- package/src/modules/notifications/api/unread-count/route.ts +4 -2
- package/src/modules/notifications/lib/routeHelpers.ts +60 -0
- package/src/modules/payment_gateways/lib/descriptor-service.ts +12 -0
- package/src/modules/progress/data/validators.ts +1 -0
- package/src/modules/progress/lib/progressServiceImpl.ts +4 -0
- package/src/modules/sales/commands/payments.ts +8 -4
- package/src/modules/sales/widgets/injection-table.ts +7 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RateProvider } from './base'
|
|
2
|
+
|
|
3
|
+
const CURRENCY_RATE_PROVIDER_REGISTRY_KEY = Symbol.for('@open-mercato/currencies/rate-provider-registry')
|
|
4
|
+
|
|
5
|
+
type GlobalWithCurrencyRateProviderRegistry = typeof globalThis & {
|
|
6
|
+
[CURRENCY_RATE_PROVIDER_REGISTRY_KEY]?: Map<string, RateProvider>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getProviderRegistry(): Map<string, RateProvider> {
|
|
10
|
+
const globalScope = globalThis as GlobalWithCurrencyRateProviderRegistry
|
|
11
|
+
if (!globalScope[CURRENCY_RATE_PROVIDER_REGISTRY_KEY]) {
|
|
12
|
+
globalScope[CURRENCY_RATE_PROVIDER_REGISTRY_KEY] = new Map<string, RateProvider>()
|
|
13
|
+
}
|
|
14
|
+
return globalScope[CURRENCY_RATE_PROVIDER_REGISTRY_KEY]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerCurrencyRateProvider(provider: RateProvider): () => void {
|
|
18
|
+
const providerRegistry = getProviderRegistry()
|
|
19
|
+
providerRegistry.set(provider.source, provider)
|
|
20
|
+
return () => {
|
|
21
|
+
if (providerRegistry.get(provider.source) === provider) providerRegistry.delete(provider.source)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getCurrencyRateProvider(source: string): RateProvider | undefined {
|
|
26
|
+
return getProviderRegistry().get(source)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listCurrencyRateProviders(): RateProvider[] {
|
|
30
|
+
return Array.from(getProviderRegistry().values())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearCurrencyRateProviders(): void {
|
|
34
|
+
getProviderRegistry().clear()
|
|
35
|
+
}
|
|
@@ -129,7 +129,19 @@ export async function GET(req: NextRequest) {
|
|
|
129
129
|
|
|
130
130
|
const rawTenantParam = url.searchParams.get('tenantId')
|
|
131
131
|
const cookieTenant = getSelectedTenantFromRequest(req)
|
|
132
|
-
const
|
|
132
|
+
const normalizeTenantId = (value: unknown): string | null =>
|
|
133
|
+
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
134
|
+
|
|
135
|
+
const authTenantId = normalizeTenantId(auth.tenantId)
|
|
136
|
+
// `auth.tenantId` is the tenant the session is currently scoped to, which a super-admin's
|
|
137
|
+
// cookie override replaces. The tenant they actually belong to survives under `actorTenantId`,
|
|
138
|
+
// so the "no selection" fallback below has to read that field — mirroring `organizationScope`.
|
|
139
|
+
// Reading `auth.tenantId` instead landed a scoped-away super-admin on `tenantRecords[0]`, the
|
|
140
|
+
// alphabetically-first tenant in the instance, rather than their own.
|
|
141
|
+
const actorTenantField = (auth as { actorTenantId?: string | null }).actorTenantId
|
|
142
|
+
const actorHomeTenantId = actorTenantField === undefined
|
|
143
|
+
? authTenantId
|
|
144
|
+
: normalizeTenantId(actorTenantField)
|
|
133
145
|
const actorIsSuperAdmin = auth.isSuperAdmin === true
|
|
134
146
|
|
|
135
147
|
let requestedTenantId = rawTenantParam ?? (cookieTenant ?? undefined)
|
|
@@ -144,12 +156,12 @@ export async function GET(req: NextRequest) {
|
|
|
144
156
|
name: typeof tenant.name === 'string' && tenant.name.length > 0 ? tenant.name : String(tenant.id),
|
|
145
157
|
isActive: tenant.isActive !== false,
|
|
146
158
|
}))
|
|
147
|
-
if (!tenantId) tenantId =
|
|
159
|
+
if (!tenantId) tenantId = actorHomeTenantId ?? (tenantRecords[0]?.id ?? null)
|
|
148
160
|
if (tenantId && tenantRecords.length && !tenantRecords.some((record) => record.id === tenantId)) {
|
|
149
161
|
tenantId = tenantRecords[0]?.id ?? tenantId
|
|
150
162
|
}
|
|
151
163
|
} else {
|
|
152
|
-
tenantId =
|
|
164
|
+
tenantId = authTenantId
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
if (!tenantId) {
|
|
@@ -206,7 +218,7 @@ export async function GET(req: NextRequest) {
|
|
|
206
218
|
}
|
|
207
219
|
}
|
|
208
220
|
|
|
209
|
-
const scopedOrgId =
|
|
221
|
+
const scopedOrgId = authTenantId && authTenantId === tenantId ? auth.orgId ?? null : null
|
|
210
222
|
const acl = await rbac.loadAcl(auth.sub, { tenantId, organizationId: scopedOrgId })
|
|
211
223
|
const aclIsSuperAdmin = acl?.isSuperAdmin === true
|
|
212
224
|
const effectiveIsSuperAdmin = actorIsSuperAdmin || aclIsSuperAdmin
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
validateCrudMutationGuard,
|
|
12
12
|
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
13
13
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
14
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
14
15
|
|
|
15
16
|
const ENCRYPTION_MAP_RESOURCE_KIND = 'entities.encryption_map'
|
|
16
17
|
|
|
@@ -19,13 +20,6 @@ export const metadata = {
|
|
|
19
20
|
POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
function resolveScope(auth: { tenantId?: string | null; orgId?: string | null }) {
|
|
23
|
-
return {
|
|
24
|
-
tenantId: auth.tenantId ?? null,
|
|
25
|
-
organizationId: auth.orgId ?? null,
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
23
|
function toIsoOrNull(value: Date | string | null | undefined): string | null {
|
|
30
24
|
if (value == null) return null
|
|
31
25
|
if (value instanceof Date) {
|
|
@@ -42,9 +36,11 @@ export async function GET(req: Request) {
|
|
|
42
36
|
if (!entityId) return NextResponse.json({ error: 'entityId is required' }, { status: 400 })
|
|
43
37
|
const auth = await getAuthFromRequest(req)
|
|
44
38
|
if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
45
|
-
const { tenantId, organizationId } = resolveScope(auth)
|
|
46
39
|
|
|
47
40
|
const container = await createRequestContainer()
|
|
41
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
42
|
+
const tenantId = scope.tenantId ?? auth.tenantId
|
|
43
|
+
const organizationId = scope.selectedId
|
|
48
44
|
const em = container.resolve('em') as any
|
|
49
45
|
const repo = em.getRepository(EncryptionMap)
|
|
50
46
|
// Prefer tenant+org, then tenant-global, then global
|
|
@@ -55,7 +51,6 @@ export async function GET(req: Request) {
|
|
|
55
51
|
]
|
|
56
52
|
let record: any = null
|
|
57
53
|
for (const where of candidates) {
|
|
58
|
-
// eslint-disable-next-line no-await-in-loop
|
|
59
54
|
const found = await repo.findOne({ ...where, deletedAt: null })
|
|
60
55
|
if (found) {
|
|
61
56
|
record = found
|
|
@@ -82,12 +77,21 @@ export async function POST(req: Request) {
|
|
|
82
77
|
}
|
|
83
78
|
const auth = await getAuthFromRequest(req)
|
|
84
79
|
if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
85
|
-
const scope = resolveScope(auth)
|
|
86
80
|
const payload = parsed.data
|
|
87
|
-
const tenantId: string = auth.tenantId
|
|
88
|
-
const organizationId = scope.organizationId
|
|
89
81
|
|
|
90
82
|
const container = await createRequestContainer()
|
|
83
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
84
|
+
if (scope.selectionRejected) {
|
|
85
|
+
return NextResponse.json(
|
|
86
|
+
{
|
|
87
|
+
error: 'Your selected organization is no longer available. Please re-select an organization and try again.',
|
|
88
|
+
code: 'organization_selection_invalid',
|
|
89
|
+
},
|
|
90
|
+
{ status: 422 },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
const tenantId = scope.tenantId ?? auth.tenantId
|
|
94
|
+
const organizationId = scope.selectedId
|
|
91
95
|
const em = container.resolve('em') as any
|
|
92
96
|
const repo = em.getRepository(EncryptionMap)
|
|
93
97
|
const existing = await repo.findOne({ entityId: payload.entityId, tenantId, organizationId, deletedAt: null })
|
|
@@ -177,6 +181,11 @@ const conflictResponseSchema = z.object({
|
|
|
177
181
|
expectedUpdatedAt: z.string(),
|
|
178
182
|
})
|
|
179
183
|
|
|
184
|
+
const organizationSelectionInvalidResponseSchema = z.object({
|
|
185
|
+
error: z.string(),
|
|
186
|
+
code: z.literal('organization_selection_invalid'),
|
|
187
|
+
})
|
|
188
|
+
|
|
180
189
|
export const openApi: OpenApiRouteDoc = {
|
|
181
190
|
tag: 'Entities',
|
|
182
191
|
summary: 'Manage encryption maps',
|
|
@@ -194,6 +203,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
194
203
|
responses: [
|
|
195
204
|
{ status: 200, description: 'Saved', schema: z.object({ ok: z.boolean(), updatedAt: z.string().nullable().optional() }) },
|
|
196
205
|
{ status: 409, description: 'Optimistic-lock conflict (stale write)', schema: conflictResponseSchema },
|
|
206
|
+
{ status: 422, description: 'Selected organization is unavailable', schema: organizationSelectionInvalidResponseSchema },
|
|
197
207
|
],
|
|
198
208
|
},
|
|
199
209
|
},
|
|
@@ -48,6 +48,11 @@ export const errorResponseSchema = z.object({
|
|
|
48
48
|
error: z.string(),
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
+
export const scopeErrorResponseSchema = z.object({
|
|
52
|
+
error: z.string(),
|
|
53
|
+
code: z.string(),
|
|
54
|
+
})
|
|
55
|
+
|
|
51
56
|
export const unreadCountResponseSchema = z.object({
|
|
52
57
|
unreadCount: z.number(),
|
|
53
58
|
})
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isCrudCacheEnabled,
|
|
9
9
|
resolveCrudCache,
|
|
10
10
|
} from '@open-mercato/shared/lib/crud/cache'
|
|
11
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
|
|
11
12
|
import { Notification } from '../data/entities'
|
|
12
13
|
import { listNotificationsSchema, createNotificationSchema } from '../data/validators'
|
|
13
14
|
import { toNotificationDto } from '../lib/notificationMapper'
|
|
@@ -19,13 +20,16 @@ import {
|
|
|
19
20
|
NOTIFICATION_RESOURCE_KIND,
|
|
20
21
|
notificationCrudErrorResponse,
|
|
21
22
|
notificationValidationErrorResponse,
|
|
23
|
+
resolveGuardedNotificationContext,
|
|
22
24
|
resolveNotificationContext,
|
|
23
25
|
runGuardedNotificationWrite,
|
|
26
|
+
TENANT_SCOPE_REQUIRED_ERROR_CODE,
|
|
24
27
|
} from '../lib/routeHelpers'
|
|
25
28
|
import {
|
|
26
29
|
buildNotificationsCrudOpenApi,
|
|
27
30
|
createPagedListResponseSchema,
|
|
28
31
|
notificationItemSchema,
|
|
32
|
+
scopeErrorResponseSchema,
|
|
29
33
|
} from './openapi'
|
|
30
34
|
|
|
31
35
|
export const metadata = {
|
|
@@ -89,7 +93,9 @@ function isNotificationsListPayload(value: unknown): value is NotificationsListP
|
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
export async function GET(req: Request) {
|
|
92
|
-
const
|
|
96
|
+
const resolved = await resolveGuardedNotificationContext(req)
|
|
97
|
+
if (!resolved.ok) return resolved.response
|
|
98
|
+
const { ctx, scope } = resolved
|
|
93
99
|
const em = ctx.container.resolve('em') as EntityManager
|
|
94
100
|
|
|
95
101
|
const url = new URL(req.url)
|
|
@@ -226,7 +232,7 @@ export async function POST(req: Request) {
|
|
|
226
232
|
}
|
|
227
233
|
}
|
|
228
234
|
|
|
229
|
-
|
|
235
|
+
const notificationsCrudOpenApi = buildNotificationsCrudOpenApi({
|
|
230
236
|
resourceName: 'Notification',
|
|
231
237
|
querySchema: listNotificationsSchema,
|
|
232
238
|
listResponseSchema: createPagedListResponseSchema(notificationItemSchema),
|
|
@@ -236,3 +242,26 @@ export const openApi = buildNotificationsCrudOpenApi({
|
|
|
236
242
|
description: 'Creates a notification for a user.',
|
|
237
243
|
},
|
|
238
244
|
})
|
|
245
|
+
|
|
246
|
+
const notificationsCrudGet = notificationsCrudOpenApi.methods?.GET ?? {}
|
|
247
|
+
|
|
248
|
+
// The CRUD factory documents errors only for DELETE, and POST already gets an auto-generated 403
|
|
249
|
+
// from its `requireFeatures` metadata. GET is authenticated-only, so its tenant-scope rejection has
|
|
250
|
+
// to be declared here to reach the generated spec.
|
|
251
|
+
export const openApi: OpenApiRouteDoc = {
|
|
252
|
+
...notificationsCrudOpenApi,
|
|
253
|
+
methods: {
|
|
254
|
+
...notificationsCrudOpenApi.methods,
|
|
255
|
+
GET: {
|
|
256
|
+
...notificationsCrudGet,
|
|
257
|
+
errors: [
|
|
258
|
+
...(notificationsCrudGet.errors ?? []),
|
|
259
|
+
{
|
|
260
|
+
status: 403,
|
|
261
|
+
description: `Request could not be resolved to a tenant scope (code: ${TENANT_SCOPE_REQUIRED_ERROR_CODE})`,
|
|
262
|
+
schema: scopeErrorResponseSchema,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
}
|
|
@@ -69,12 +69,19 @@ export async function POST(req: Request) {
|
|
|
69
69
|
)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Unlike the other write routes this one does not go through `resolveNotificationContext`, so it
|
|
73
|
+
// never sees the organization scope's `actorTenantId` fallback. A super-admin scoped away from
|
|
74
|
+
// their own tenant has `auth.tenantId === null` with the real tenant preserved in `actorTenantId`,
|
|
75
|
+
// and delivery settings are instance-global anyway — reading it here keeps that caller working
|
|
76
|
+
// while a genuinely tenant-less principal still fails the guard below.
|
|
77
|
+
const actorTenantId = (auth as { actorTenantId?: string | null }).actorTenantId ?? null
|
|
78
|
+
|
|
72
79
|
const container = await createRequestContainer()
|
|
73
80
|
try {
|
|
74
81
|
const guarded = await runGuardedNotificationWrite(
|
|
75
82
|
container,
|
|
76
83
|
{
|
|
77
|
-
tenantId: auth.tenantId ?? '',
|
|
84
|
+
tenantId: auth.tenantId ?? actorTenantId ?? '',
|
|
78
85
|
organizationId: auth.orgId ?? null,
|
|
79
86
|
userId: auth.sub ?? null,
|
|
80
87
|
},
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from '@open-mercato/shared/lib/crud/cache'
|
|
9
9
|
import { Notification } from '../../data/entities'
|
|
10
10
|
import { unreadCountResponseSchema } from '../openapi'
|
|
11
|
-
import {
|
|
11
|
+
import { resolveGuardedNotificationContext } from '../../lib/routeHelpers'
|
|
12
12
|
import {
|
|
13
13
|
buildNotificationReadScopeWhere,
|
|
14
14
|
getNotificationReadScopeTagOrganizationIds,
|
|
@@ -39,7 +39,9 @@ function buildUnreadCountCacheKey(params: {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export async function GET(req: Request) {
|
|
42
|
-
const
|
|
42
|
+
const resolved = await resolveGuardedNotificationContext(req)
|
|
43
|
+
if (!resolved.ok) return resolved.response
|
|
44
|
+
const { scope, ctx } = resolved
|
|
43
45
|
const em = ctx.container.resolve('em') as EntityManager
|
|
44
46
|
|
|
45
47
|
const userId = scope.userId
|
|
@@ -60,6 +60,42 @@ export function notificationCrudErrorResponse(error: unknown): Response | null {
|
|
|
60
60
|
return Response.json(error.body ?? { error: 'Notification request failed' }, { status: error.status })
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Machine-readable discriminator for the tenant-scope rejection, mirroring
|
|
65
|
+
* `ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE`. Without it a client cannot tell an unresolved scope
|
|
66
|
+
* apart from an ordinary permission denial, since both are a 403 carrying only a message.
|
|
67
|
+
*/
|
|
68
|
+
export const TENANT_SCOPE_REQUIRED_ERROR_CODE = 'tenant_scope_required'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fail closed when a request cannot be resolved to a tenant.
|
|
72
|
+
*
|
|
73
|
+
* `resolveNotificationContext` falls back to `''` when neither the organization scope nor the auth
|
|
74
|
+
* context yields a tenant — reachable for a genuinely tenant-less principal such as an unscoped
|
|
75
|
+
* super-admin API key. `notifications.tenant_id` is a NOT NULL uuid, so that `''` makes the driver
|
|
76
|
+
* reject every read and write built from the scope; dropping the tenant predicate instead would
|
|
77
|
+
* leave `recipientUserId` as the only thing keeping a read inside one tenant.
|
|
78
|
+
*
|
|
79
|
+
* Unlike the audit-log read guard there is no `isSuperAdmin` escape hatch: notification rows are
|
|
80
|
+
* per-recipient and per-tenant, so there is no cross-tenant read mode to preserve.
|
|
81
|
+
*
|
|
82
|
+
* Plain truthiness is deliberate — it matches the `?? ''` sentinel and also rejects a null or
|
|
83
|
+
* omitted tenant reaching this helper from a caller that builds its own scope.
|
|
84
|
+
*/
|
|
85
|
+
export async function requireResolvedNotificationTenantScope(
|
|
86
|
+
scope: { tenantId?: string | null },
|
|
87
|
+
): Promise<Response | null> {
|
|
88
|
+
if (scope.tenantId) return null
|
|
89
|
+
const { t } = await resolveTranslations()
|
|
90
|
+
return Response.json(
|
|
91
|
+
{
|
|
92
|
+
error: t('api.errors.forbidden', 'Forbidden'),
|
|
93
|
+
code: TENANT_SCOPE_REQUIRED_ERROR_CODE,
|
|
94
|
+
},
|
|
95
|
+
{ status: 403 },
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
63
99
|
/**
|
|
64
100
|
* Resolve notification service and scope from a request.
|
|
65
101
|
* Centralizes the common pattern used across all notification API routes.
|
|
@@ -92,6 +128,25 @@ export async function resolveNotificationContext(req: Request): Promise<Notifica
|
|
|
92
128
|
}
|
|
93
129
|
}
|
|
94
130
|
|
|
131
|
+
export type GuardedNotificationContext =
|
|
132
|
+
| ({ ok: true } & NotificationRequestContext)
|
|
133
|
+
| { ok: false; response: Response }
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the notification context and reject the request when it has no tenant.
|
|
137
|
+
*
|
|
138
|
+
* Writes are structurally safe because every one of them funnels through
|
|
139
|
+
* `runGuardedNotificationWrite`. Reads have no such choke point, so they resolve their context
|
|
140
|
+
* through this wrapper instead: a route that forgets to check cannot compile past the discriminated
|
|
141
|
+
* result, which makes the guard impossible to skip by omission rather than by convention.
|
|
142
|
+
*/
|
|
143
|
+
export async function resolveGuardedNotificationContext(req: Request): Promise<GuardedNotificationContext> {
|
|
144
|
+
const resolved = await resolveNotificationContext(req)
|
|
145
|
+
const tenantScopeGuard = await requireResolvedNotificationTenantScope(resolved.scope)
|
|
146
|
+
if (tenantScopeGuard) return { ok: false, response: tenantScopeGuard }
|
|
147
|
+
return { ok: true, ...resolved }
|
|
148
|
+
}
|
|
149
|
+
|
|
95
150
|
/**
|
|
96
151
|
* Mutation-guard options for a notification write.
|
|
97
152
|
*/
|
|
@@ -120,6 +175,11 @@ export async function runGuardedNotificationWrite<T>(
|
|
|
120
175
|
options: NotificationMutationGuardOptions,
|
|
121
176
|
write: () => Promise<T>,
|
|
122
177
|
): Promise<GuardedNotificationWriteResult<T>> {
|
|
178
|
+
const tenantScopeGuard = await requireResolvedNotificationTenantScope(scope)
|
|
179
|
+
if (tenantScopeGuard) {
|
|
180
|
+
return { ok: false, response: tenantScopeGuard }
|
|
181
|
+
}
|
|
182
|
+
|
|
123
183
|
const guarded = await runRouteMutationGuards({
|
|
124
184
|
container,
|
|
125
185
|
req,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getAllIntegrations,
|
|
3
|
+
resolveIntegrationCredentialsSchema,
|
|
3
4
|
type IntegrationScope,
|
|
4
5
|
} from '@open-mercato/shared/modules/integrations/types'
|
|
5
6
|
import {
|
|
@@ -52,6 +53,17 @@ async function resolveDescriptor(
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
const credentialsSchema = resolveIntegrationCredentialsSchema(integration.id)
|
|
57
|
+
if (credentialsSchema?.fields.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
...descriptor,
|
|
60
|
+
integrationId: integration.id,
|
|
61
|
+
requiresConfiguration: false,
|
|
62
|
+
isConfigured: true,
|
|
63
|
+
configurationStatus: 'unmanaged',
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
const [credentials, state] = await Promise.all([
|
|
56
68
|
deps.integrationCredentialsService.resolve(integration.id, scope),
|
|
57
69
|
deps.integrationStateService.resolveState(integration.id, scope),
|
|
@@ -29,6 +29,7 @@ export const completeJobSchema = z.object({
|
|
|
29
29
|
export const failJobSchema = z.object({
|
|
30
30
|
errorMessage: z.string().max(2000),
|
|
31
31
|
errorStack: z.string().max(10000).optional(),
|
|
32
|
+
resultSummary: z.record(z.string(), z.unknown()).optional(),
|
|
32
33
|
})
|
|
33
34
|
|
|
34
35
|
export const listProgressJobsSchema = z.object({
|
|
@@ -387,6 +387,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
387
387
|
finishedAt: now,
|
|
388
388
|
errorMessage: input.errorMessage,
|
|
389
389
|
errorStack: input.errorStack,
|
|
390
|
+
...(input.resultSummary ? { resultSummary: input.resultSummary } : {}),
|
|
390
391
|
updatedAt: now,
|
|
391
392
|
...(entry
|
|
392
393
|
? {
|
|
@@ -412,6 +413,9 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
|
|
|
412
413
|
snapshot.finishedAt = now
|
|
413
414
|
snapshot.errorMessage = input.errorMessage
|
|
414
415
|
snapshot.errorStack = input.errorStack
|
|
416
|
+
if (input.resultSummary) {
|
|
417
|
+
snapshot.resultSummary = input.resultSummary
|
|
418
|
+
}
|
|
415
419
|
|
|
416
420
|
const persistedJob = (await loadFreshJob(jobId, ctx)) ?? snapshot
|
|
417
421
|
|
|
@@ -539,10 +539,14 @@ const createPaymentCommand: CommandHandler<
|
|
|
539
539
|
linkHref: `/backend/sales/orders/${order.id}`,
|
|
540
540
|
})
|
|
541
541
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
542
|
+
// Bulk-import backfills opt out of the per-record notification fan-out (and its inline
|
|
543
|
+
// e-mail delivery); interactive creates are unaffected.
|
|
544
|
+
if (!ctx.bulkImport?.skipNotifications) {
|
|
545
|
+
await notificationService.createForFeature(notificationInput, {
|
|
546
|
+
tenantId: payment.tenantId,
|
|
547
|
+
organizationId: payment.organizationId ?? null,
|
|
548
|
+
})
|
|
549
|
+
}
|
|
546
550
|
}
|
|
547
551
|
} catch (err) {
|
|
548
552
|
// Notification creation is non-critical, don't fail the command
|
|
@@ -17,10 +17,13 @@ export const injectionTable: ModuleInjectionTable = {
|
|
|
17
17
|
priority: 50,
|
|
18
18
|
},
|
|
19
19
|
],
|
|
20
|
-
'data-table:sales.payments:columns'
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// NOTE: the 'data-table:sales.payments:columns' binding for
|
|
21
|
+
// 'sales.injection.payment-gateway-status-column' was removed here — it could never
|
|
22
|
+
// resolve. PaymentsSection.tsx renders its DataTable with no perspective/injectionSpotId/
|
|
23
|
+
// extensionTableId, so extensionTableId is null and the columns spot degrades to
|
|
24
|
+
// '__disabled__:columns'. The widget itself is still registered and is now UNBOUND: giving
|
|
25
|
+
// the payments table a real tableId so the gateway-status column finally renders is a sales
|
|
26
|
+
// feature gap, tracked in #5142 rather than silently reintroduced here.
|
|
24
27
|
'crud-form:sales.payment_method:fields': {
|
|
25
28
|
widgetId: 'sales.injection.payment-gateway-config-field',
|
|
26
29
|
priority: 40,
|