@open-mercato/core 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55

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 (162) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +3 -3
  3. package/agentic/standalone-guide.md +13 -0
  4. package/dist/generated/entities/organization/index.js +2 -0
  5. package/dist/generated/entities/organization/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +1 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/auth/cli.js +1 -1
  9. package/dist/modules/auth/cli.js.map +2 -2
  10. package/dist/modules/auth/lib/backendChrome.js +4 -6
  11. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  12. package/dist/modules/auth/lib/setup-app.js +1 -0
  13. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  14. package/dist/modules/auth/services/rbacService.js +29 -44
  15. package/dist/modules/auth/services/rbacService.js.map +2 -2
  16. package/dist/modules/catalog/widgets/injection/product-seo/widget.js +1 -1
  17. package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
  18. package/dist/modules/communication_channels/lib/access-control.js +2 -2
  19. package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
  20. package/dist/modules/customer_accounts/api/invitations/accept.js +1 -2
  21. package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
  22. package/dist/modules/customer_accounts/api/login.js +4 -2
  23. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  24. package/dist/modules/customer_accounts/api/magic-link/verify.js +4 -2
  25. package/dist/modules/customer_accounts/api/magic-link/verify.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/portal/feature-check.js +5 -4
  27. package/dist/modules/customer_accounts/api/portal/feature-check.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/portal/nav.js +6 -4
  29. package/dist/modules/customer_accounts/api/portal/nav.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/portal/profile.js +5 -1
  31. package/dist/modules/customer_accounts/api/portal/profile.js.map +2 -2
  32. package/dist/modules/customer_accounts/api/portal/sessions-refresh.js +3 -3
  33. package/dist/modules/customer_accounts/api/portal/sessions-refresh.js.map +2 -2
  34. package/dist/modules/customer_accounts/lib/customerAuth.js +7 -2
  35. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  36. package/dist/modules/customer_accounts/lib/customerAuthServer.js +2 -1
  37. package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
  38. package/dist/modules/customer_accounts/services/customerRbacService.js +12 -3
  39. package/dist/modules/customer_accounts/services/customerRbacService.js.map +2 -2
  40. package/dist/modules/customer_accounts/setup.js +6 -0
  41. package/dist/modules/customer_accounts/setup.js.map +2 -2
  42. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +1 -1
  43. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +2 -2
  44. package/dist/modules/customers/lib/visibilityFilter.js +6 -4
  45. package/dist/modules/customers/lib/visibilityFilter.js.map +2 -2
  46. package/dist/modules/dashboards/api/layout/[itemId]/route.js +6 -3
  47. package/dist/modules/dashboards/api/layout/[itemId]/route.js.map +2 -2
  48. package/dist/modules/dashboards/api/layout/route.js +22 -9
  49. package/dist/modules/dashboards/api/layout/route.js.map +2 -2
  50. package/dist/modules/dashboards/api/roles/widgets/route.js +9 -3
  51. package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
  52. package/dist/modules/dashboards/api/users/widgets/route.js +9 -3
  53. package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
  54. package/dist/modules/dashboards/api/widgets/catalog.js +6 -3
  55. package/dist/modules/dashboards/api/widgets/catalog.js.map +2 -2
  56. package/dist/modules/dashboards/lib/access.js +5 -3
  57. package/dist/modules/dashboards/lib/access.js.map +2 -2
  58. package/dist/modules/directory/api/organization-branding/route.js +10 -3
  59. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  60. package/dist/modules/directory/api/organizations/route.js +7 -0
  61. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  62. package/dist/modules/directory/backend/directory/branding/page.js +31 -8
  63. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  64. package/dist/modules/directory/commands/organizations.js +8 -1
  65. package/dist/modules/directory/commands/organizations.js.map +2 -2
  66. package/dist/modules/directory/data/entities.js +4 -0
  67. package/dist/modules/directory/data/entities.js.map +2 -2
  68. package/dist/modules/directory/data/validators.js +2 -0
  69. package/dist/modules/directory/data/validators.js.map +2 -2
  70. package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js +13 -0
  71. package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js.map +7 -0
  72. package/dist/modules/entities/lib/entityAcl.js +47 -15
  73. package/dist/modules/entities/lib/entityAcl.js.map +3 -3
  74. package/dist/modules/inbox_ops/ai-tools.js +5 -2
  75. package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
  76. package/dist/modules/messages/lib/routeHelpers.js +1 -3
  77. package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
  78. package/dist/modules/notifications/lib/notificationRecipients.js +6 -7
  79. package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
  80. package/dist/modules/query_index/di.js +3 -0
  81. package/dist/modules/query_index/di.js.map +2 -2
  82. package/dist/modules/query_index/lib/coverage.js +2 -2
  83. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  84. package/dist/modules/query_index/lib/indexer.js +83 -17
  85. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  86. package/dist/modules/query_index/lib/search-tokens.js +13 -7
  87. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  88. package/dist/modules/query_index/subscribers/coverage_refresh.js +4 -0
  89. package/dist/modules/query_index/subscribers/coverage_refresh.js.map +2 -2
  90. package/dist/modules/query_index/subscribers/delete_one.js +53 -1
  91. package/dist/modules/query_index/subscribers/delete_one.js.map +3 -3
  92. package/dist/modules/query_index/subscribers/upsert_one.js +60 -0
  93. package/dist/modules/query_index/subscribers/upsert_one.js.map +3 -3
  94. package/dist/modules/sales/commands/documents.js +17 -0
  95. package/dist/modules/sales/commands/documents.js.map +2 -2
  96. package/dist/modules/staff/api/interceptors.js +5 -3
  97. package/dist/modules/staff/api/interceptors.js.map +2 -2
  98. package/dist/modules/sync_excel/api/upload/route.js +15 -2
  99. package/dist/modules/sync_excel/api/upload/route.js.map +2 -2
  100. package/dist/modules/workflows/lib/activity-executor.js +7 -7
  101. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  102. package/generated/entities/organization/index.ts +1 -0
  103. package/generated/entity-fields-registry.ts +1 -0
  104. package/package.json +7 -7
  105. package/src/modules/auth/AGENTS.md +5 -3
  106. package/src/modules/auth/cli.ts +1 -1
  107. package/src/modules/auth/lib/backendChrome.tsx +5 -11
  108. package/src/modules/auth/lib/setup-app.ts +1 -0
  109. package/src/modules/auth/services/rbacService.ts +41 -46
  110. package/src/modules/catalog/widgets/injection/product-seo/widget.ts +1 -1
  111. package/src/modules/communication_channels/lib/access-control.ts +3 -3
  112. package/src/modules/customer_accounts/AGENTS.md +3 -3
  113. package/src/modules/customer_accounts/api/invitations/accept.ts +1 -2
  114. package/src/modules/customer_accounts/api/login.ts +4 -2
  115. package/src/modules/customer_accounts/api/magic-link/verify.ts +4 -2
  116. package/src/modules/customer_accounts/api/portal/feature-check.ts +5 -7
  117. package/src/modules/customer_accounts/api/portal/nav.ts +5 -3
  118. package/src/modules/customer_accounts/api/portal/profile.ts +5 -1
  119. package/src/modules/customer_accounts/api/portal/sessions-refresh.ts +3 -3
  120. package/src/modules/customer_accounts/lib/customerAuth.ts +8 -2
  121. package/src/modules/customer_accounts/lib/customerAuthServer.ts +1 -0
  122. package/src/modules/customer_accounts/services/customerRbacService.ts +16 -3
  123. package/src/modules/customer_accounts/setup.ts +7 -0
  124. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +1 -3
  125. package/src/modules/customers/lib/visibilityFilter.ts +7 -5
  126. package/src/modules/dashboards/api/layout/[itemId]/route.ts +6 -3
  127. package/src/modules/dashboards/api/layout/route.ts +22 -9
  128. package/src/modules/dashboards/api/roles/widgets/route.ts +9 -3
  129. package/src/modules/dashboards/api/users/widgets/route.ts +9 -3
  130. package/src/modules/dashboards/api/widgets/catalog.ts +6 -3
  131. package/src/modules/dashboards/lib/access.ts +5 -3
  132. package/src/modules/directory/api/organization-branding/route.ts +12 -1
  133. package/src/modules/directory/api/organizations/route.ts +7 -0
  134. package/src/modules/directory/backend/directory/branding/page.tsx +34 -9
  135. package/src/modules/directory/commands/organizations.ts +9 -1
  136. package/src/modules/directory/data/entities.ts +3 -0
  137. package/src/modules/directory/data/validators.ts +2 -0
  138. package/src/modules/directory/i18n/de.json +3 -1
  139. package/src/modules/directory/i18n/en.json +3 -1
  140. package/src/modules/directory/i18n/es.json +3 -1
  141. package/src/modules/directory/i18n/pl.json +3 -1
  142. package/src/modules/directory/migrations/.snapshot-open-mercato.json +18 -1
  143. package/src/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.ts +13 -0
  144. package/src/modules/entities/lib/entityAcl.ts +47 -18
  145. package/src/modules/inbox_ops/ai-tools.ts +5 -3
  146. package/src/modules/messages/lib/routeHelpers.ts +4 -6
  147. package/src/modules/notifications/lib/notificationRecipients.ts +6 -8
  148. package/src/modules/query_index/di.ts +6 -0
  149. package/src/modules/query_index/lib/coverage.ts +9 -6
  150. package/src/modules/query_index/lib/indexer.ts +105 -19
  151. package/src/modules/query_index/lib/search-tokens.ts +19 -8
  152. package/src/modules/query_index/subscribers/coverage_refresh.ts +4 -0
  153. package/src/modules/query_index/subscribers/delete_one.ts +65 -1
  154. package/src/modules/query_index/subscribers/upsert_one.ts +68 -1
  155. package/src/modules/sales/commands/documents.ts +27 -0
  156. package/src/modules/sales/i18n/de.json +1 -0
  157. package/src/modules/sales/i18n/en.json +1 -0
  158. package/src/modules/sales/i18n/es.json +1 -0
  159. package/src/modules/sales/i18n/pl.json +1 -0
  160. package/src/modules/staff/api/interceptors.ts +6 -4
  161. package/src/modules/sync_excel/api/upload/route.ts +15 -2
  162. package/src/modules/workflows/lib/activity-executor.ts +12 -10
@@ -21,7 +21,6 @@ import {
21
21
  import { resolveRegisteredLucideIconNode } from '@open-mercato/ui/backend/icons/lucideRegistry'
22
22
  import { profilePathPrefixes, profileSections } from './profile-sections'
23
23
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
24
- import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
25
24
  import { getNavGroupOrderOverride } from '@open-mercato/shared/modules/overrides'
26
25
  import {
27
26
  getSelectedOrganizationFromRequest,
@@ -283,10 +282,7 @@ export async function resolveBackendChromePayload({
283
282
  const container = await loadScopedContainer()
284
283
  const em = container.resolve('em') as EntityManager
285
284
  const rbac = container.resolve('rbacService') as {
286
- loadAcl: (userId: string, scope: { tenantId: string | null; organizationId: string | null }) => Promise<{
287
- isSuperAdmin: boolean
288
- features: string[]
289
- }>
285
+ getEffectiveFeatures: (userId: string, scope: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
290
286
  userHasAllFeatures: (userId: string, required: string[], scope: { tenantId: string | null; organizationId: string | null }) => Promise<boolean>
291
287
  }
292
288
 
@@ -319,15 +315,12 @@ export async function resolveBackendChromePayload({
319
315
  concretelySelectedOrganizationId = null
320
316
  }
321
317
 
322
- const acl = allowNavigation
323
- ? await rbac.loadAcl(auth.sub, {
318
+ const grantedFeatures = allowNavigation
319
+ ? await rbac.getEffectiveFeatures(auth.sub, {
324
320
  tenantId: scopedTenantId,
325
321
  organizationId: scopedOrganizationId,
326
322
  })
327
- : { isSuperAdmin: false, features: [] }
328
-
329
- const rawGrantedFeatures = acl.isSuperAdmin ? ['*'] : acl.features
330
- const grantedFeatures = filterGrantsByEnabledModules(rawGrantedFeatures)
323
+ : []
331
324
  const featureChecker = async (features: string[]): Promise<string[]> => {
332
325
  if (!allowNavigation || !features.length) return []
333
326
  const context = {
@@ -461,6 +454,7 @@ export async function resolveBackendChromePayload({
461
454
  logo: {
462
455
  src: organization.logoUrl,
463
456
  alt: `${organization.name} logo`,
457
+ preserveAspectRatio: !!organization.logoPreserveAspectRatio,
464
458
  },
465
459
  }
466
460
  }
@@ -320,6 +320,7 @@ export async function setupInitialTenant(
320
320
  ancestorIds: [],
321
321
  childIds: [],
322
322
  descendantIds: [],
323
+ logoPreserveAspectRatio: false,
323
324
  createdAt: new Date(),
324
325
  updatedAt: new Date(),
325
326
  })
@@ -5,8 +5,11 @@ import { UserAcl, RoleAcl, User, UserRole } from '@open-mercato/core/modules/aut
5
5
  import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
6
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
7
  import { buildOrgScopeUserCacheTag, buildOrgScopeTenantCacheTag } from '@open-mercato/core/modules/directory/utils/organizationScope'
8
- import { matchFeature as sharedMatchFeature, hasAllFeatures as sharedHasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
9
- import { filterGrantsByEnabledModules, getOwningModuleId, getEnabledModuleIds } from '@open-mercato/shared/security/enabledModulesRegistry'
8
+ import {
9
+ authorizeFeatures,
10
+ resolveEffectiveFeatures,
11
+ } from '@open-mercato/shared/security/featurePolicy'
12
+ import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
10
13
 
11
14
  interface AclData {
12
15
  isSuperAdmin: boolean
@@ -43,32 +46,9 @@ export class RbacService {
43
46
  this.cacheTtlMs = ttlMs
44
47
  }
45
48
 
46
- /**
47
- * Checks if a required feature is satisfied by a granted feature permission.
48
- *
49
- * Wildcard patterns:
50
- * - `*` (global wildcard): Grants access to all features
51
- * - `prefix.*` (module wildcard): Grants access to all features starting with `prefix.`
52
- * and also the exact prefix itself (e.g., `entities.*` matches both `entities` and `entities.records.view`)
53
- * - Exact match: Feature must match exactly (e.g., `users.view` only matches `users.view`)
54
- *
55
- * @param required - The feature being requested (e.g., 'entities.records.view')
56
- * @param granted - The feature permission granted (e.g., 'entities.*' or '*')
57
- * @returns true if the granted permission satisfies the required feature
58
- *
59
- * @example
60
- * matchFeature('users.view', '*') // true - global wildcard
61
- * matchFeature('entities.records.view', 'entities.*') // true - module wildcard
62
- * matchFeature('entities', 'entities.*') // true - exact prefix match
63
- * matchFeature('users.view', 'entities.*') // false - different module
64
- * matchFeature('users.view', 'users.view') // true - exact match
65
- */
66
- private matchFeature(required: string, granted: string): boolean {
67
- return sharedMatchFeature(required, granted)
68
- }
69
-
49
+ /** Compatibility wrapper for callers that already have both feature lists. */
70
50
  public hasAllFeatures(required: string[], granted: string[]): boolean {
71
- return sharedHasAllFeatures(required, granted)
51
+ return authorizeFeatures(required, { grantedFeatures: granted })
72
52
  }
73
53
 
74
54
  private roleAclAllowsOrganization(acl: RoleAcl, organizationId: string | null | undefined): boolean {
@@ -380,9 +360,6 @@ export class RbacService {
380
360
  ): Promise<boolean> {
381
361
  if (!tenantId || !feature) return false
382
362
 
383
- const enabledIds = getEnabledModuleIds()
384
- if (enabledIds.length && !enabledIds.includes(getOwningModuleId(feature))) return false
385
-
386
363
  const em = this.em.fork()
387
364
  const roleAcls = await em.find(RoleAcl, { tenantId, deletedAt: null } as any, {})
388
365
  const list = Array.isArray(roleAcls) ? roleAcls : []
@@ -390,9 +367,13 @@ export class RbacService {
390
367
 
391
368
  for (const acl of list) {
392
369
  if (!this.roleAclAllowsOrganization(acl, organizationId)) continue
393
- if (acl.isSuperAdmin) return true
394
370
  const grants = Array.isArray(acl.featuresJson) ? acl.featuresJson : []
395
- if (this.hasAllFeatures([feature], filterGrantsByEnabledModules(grants))) return true
371
+ if (authorizeFeatures([feature], {
372
+ grantedFeatures: grants,
373
+ unrestricted: acl.isSuperAdmin,
374
+ })) {
375
+ return true
376
+ }
396
377
  }
397
378
 
398
379
  return false
@@ -436,23 +417,22 @@ export class RbacService {
436
417
  async userHasAllFeatures(userId: string, required: string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean> {
437
418
  if (!required.length) return true
438
419
  const acl = await this.loadAcl(userId, scope)
439
- if (acl.isSuperAdmin) {
440
- const enabledIds = getEnabledModuleIds()
441
- if (!enabledIds.length) return true
442
- const enabledSet = new Set(enabledIds)
443
- return required.every((feature) => enabledSet.has(getOwningModuleId(feature)))
444
- }
445
- if (acl.organizations && scope.organizationId && !acl.organizations.includes(scope.organizationId) && !acl.organizations.includes('__all__')) return false
446
- return this.hasAllFeatures(required, filterGrantsByEnabledModules(acl.features))
420
+ const organizationAllowed = acl.isSuperAdmin
421
+ || !acl.organizations
422
+ || !scope.organizationId
423
+ || acl.organizations.includes(scope.organizationId)
424
+ || acl.organizations.includes('__all__')
425
+ return authorizeFeatures(required, {
426
+ grantedFeatures: acl.features,
427
+ unrestricted: acl.isSuperAdmin,
428
+ scopeAllowed: organizationAllowed,
429
+ })
447
430
  }
448
431
 
449
432
  /**
450
- * Returns the effective feature grant list for a user within a scope,
451
- * filtered to enabled modules only. Super admins receive `['*']` expanded
452
- * to per-module wildcards via `filterGrantsByEnabledModules` — consistent
453
- * with `userHasAllFeatures` which also enforces disabled-module boundaries
454
- * for super admins. Returns `[]` when the requested organization is outside
455
- * the user's visibility list, or when the user does not exist.
433
+ * Returns the user's infrastructure grant list, filtered to enabled modules.
434
+ * The result may contain wildcards; authoritative checks must still go through
435
+ * `authorizeFeatures` or `userHasAllFeatures` so null overrides are enforced.
456
436
  */
457
437
  async getGrantedFeatures(
458
438
  userId: string,
@@ -470,4 +450,19 @@ export class RbacService {
470
450
  }
471
451
  return filterGrantsByEnabledModules(acl.features)
472
452
  }
453
+
454
+ /** Returns concrete active feature IDs for browser capability payloads. */
455
+ async getEffectiveFeatures(
456
+ userId: string,
457
+ scope: { tenantId: string | null; organizationId: string | null },
458
+ ): Promise<string[]> {
459
+ const acl = await this.loadAcl(userId, scope)
460
+ const organizationAllowed = acl.isSuperAdmin
461
+ || !acl.organizations
462
+ || !scope.organizationId
463
+ || acl.organizations.includes(scope.organizationId)
464
+ || acl.organizations.includes('__all__')
465
+ if (!organizationAllowed) return []
466
+ return resolveEffectiveFeatures(acl.isSuperAdmin ? ['*'] : acl.features)
467
+ }
473
468
  }
@@ -8,7 +8,7 @@ const widget: InjectionWidgetModule<any, any> = {
8
8
  id: 'catalog.injection.product-seo',
9
9
  title: 'Product SEO Helper',
10
10
  description: 'Helps optimize product metadata for search engines',
11
- features: ['catalog.products.edit'],
11
+ features: ['catalog.products.manage'],
12
12
  priority: 90,
13
13
  enabled: true,
14
14
  requiredFields: ['description'],
@@ -1,4 +1,4 @@
1
- import { hasFeature } from '@open-mercato/shared/security/features'
1
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
2
2
 
3
3
  /**
4
4
  * Per-user channel access control — service-layer authorization helpers.
@@ -67,7 +67,7 @@ export function assertCanAccessChannel(
67
67
  * - **Tenant-wide / shared channel** (`userId == null`, e.g. WhatsApp
68
68
  * Business / Slack workspaces): requires the route's `elevatedFeature`
69
69
  * (`communication_channels.manage`, `…channel.push.manage`, or
70
- * `…channel.import_history`). Wildcard-aware via {@link hasFeature}.
70
+ * `…channel.import_history`). Evaluated by the shared feature policy.
71
71
  *
72
72
  * Throws `ChannelAccessDeniedError` (route handlers map it to 404, masking
73
73
  * existence) when the caller may not manage the channel.
@@ -94,7 +94,7 @@ export function assertCanManageChannel(
94
94
  }
95
95
  // Tenant-wide / shared channel — requires the elevated management feature.
96
96
  const grantedFeatures = Array.isArray(userFeatures) ? userFeatures : []
97
- if (!hasFeature(grantedFeatures, elevatedFeature)) {
97
+ if (!authorizeFeatures([elevatedFeature], { grantedFeatures })) {
98
98
  throw new ChannelAccessDeniedError(`Managing a shared channel requires '${elevatedFeature}'`)
99
99
  }
100
100
  }
@@ -136,7 +136,7 @@ Effective permissions = User ACL (if exists) OR aggregated Role ACLs.
136
136
 
137
137
  ### Portal Admin Flag
138
138
 
139
- `isPortalAdmin: true` on a role/user ACL bypasses all feature checks (equivalent to staff `isSuperAdmin`).
139
+ `isPortalAdmin: true` on a role/user ACL bypasses stored-grant matching (equivalent to staff `isSuperAdmin`). Nulled ACL features and disabled modules remain denied.
140
140
 
141
141
  ### Default Roles (seeded on tenant creation)
142
142
 
@@ -150,7 +150,7 @@ Effective permissions = User ACL (if exists) OR aggregated Role ACLs.
150
150
 
151
151
  Customer portal features use the `portal.<area>.<action>` naming convention (e.g., `portal.orders.view`, `portal.catalog.view`).
152
152
 
153
- Treat `portal.*` and `*` as first-class ACL grants. When portal code reads raw feature arrays directly (for example menu filtering, injected portal navigation, or local runtime guards), use shared wildcard-aware matching instead of exact `includes(...)` checks.
153
+ Treat `portal.*` and `*` as first-class stored ACL grants. Server authorization calls `CustomerRbacService.userHasAllFeatures`; already-loaded snapshots call shared `authorizeFeatures`. Portal UI and navigation consume `getEffectiveFeatures`, which returns concrete active IDs without wildcards.
154
154
 
155
155
  ### Cross-Module Feature Merging
156
156
 
@@ -172,7 +172,7 @@ const customerRbacService = container.resolve('customerRbacService')
172
172
  const hasAccess = await customerRbacService.userHasAllFeatures(userId, ['portal.orders.view'], { tenantId, organizationId })
173
173
  ```
174
174
 
175
- When a portal UI/client helper needs batch checks, prefer `/api/customer_accounts/portal/feature-check`. If it evaluates raw granted features directly, it must preserve wildcard semantics.
175
+ When a portal UI/client helper needs batch checks, prefer `/api/customer_accounts/portal/feature-check`. Client helpers receive concrete effective features and may use the pure shared matcher without implementing portal-admin or removal bypasses.
176
176
 
177
177
  ## Services and DI
178
178
 
@@ -39,11 +39,10 @@ export async function POST(req: Request) {
39
39
  }
40
40
 
41
41
  const { user, invitation } = result
42
- const acl = await customerRbacService.loadAcl(user.id, {
42
+ const resolvedFeatures = await customerRbacService.getEffectiveFeatures(user.id, {
43
43
  tenantId: user.tenantId,
44
44
  organizationId: user.organizationId,
45
45
  })
46
- const resolvedFeatures = acl.features
47
46
 
48
47
  const ip = getClientIp(req, 0)
49
48
  const userAgent = req.headers.get('user-agent') || null
@@ -116,8 +116,10 @@ export async function POST(req: Request) {
116
116
  await resetAuthRateLimit(compoundKey, customerLoginRateLimitConfig)
117
117
  }
118
118
 
119
- const acl = await customerRbacService.loadAcl(user.id, { tenantId, organizationId: user.organizationId })
120
- const resolvedFeatures = acl.features
119
+ const resolvedFeatures = await customerRbacService.getEffectiveFeatures(user.id, {
120
+ tenantId,
121
+ organizationId: user.organizationId,
122
+ })
121
123
 
122
124
  const ip = getClientIp(req, 0)
123
125
  const userAgent = req.headers.get('user-agent') || null
@@ -52,8 +52,10 @@ export async function POST(req: Request) {
52
52
  await customerUserService.resetFailedAttempts(user)
53
53
  await customerUserService.updateLastLoginAt(user)
54
54
 
55
- const acl = await customerRbacService.loadAcl(user.id, { tenantId: user.tenantId, organizationId: user.organizationId })
56
- const resolvedFeatures = acl.features
55
+ const resolvedFeatures = await customerRbacService.getEffectiveFeatures(user.id, {
56
+ tenantId: user.tenantId,
57
+ organizationId: user.organizationId,
58
+ })
57
59
 
58
60
  const ip = getClientIp(req, 0)
59
61
  const userAgent = req.headers.get('user-agent') || null
@@ -4,7 +4,7 @@ import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib
4
4
  import { getCustomerAuthFromRequest } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
7
- import { matchFeature } from '@open-mercato/shared/lib/auth/featureMatch'
7
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
8
8
 
9
9
  export const metadata: { path?: string; requireAuth?: boolean } = { requireAuth: false }
10
10
 
@@ -32,12 +32,10 @@ export async function POST(req: Request) {
32
32
  tenantId: auth.tenantId,
33
33
  organizationId: auth.orgId,
34
34
  })
35
-
36
- const granted = acl.isPortalAdmin
37
- ? [...body.features]
38
- : body.features.filter((feature) =>
39
- acl.features.some((g) => matchFeature(feature, g)),
40
- )
35
+ const granted = body.features.filter((feature) => authorizeFeatures([feature], {
36
+ grantedFeatures: acl.features,
37
+ unrestricted: acl.isPortalAdmin,
38
+ }))
41
39
 
42
40
  return NextResponse.json({ ok: true, granted })
43
41
  }
@@ -51,13 +51,15 @@ export async function GET(req: Request) {
51
51
  }
52
52
 
53
53
  const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId, organizationId: auth.orgId })
54
- const grantedFeatures = acl.isPortalAdmin ? ['*'] : acl.features
54
+ const grantedFeatures = await rbac.getEffectiveFeatures(auth.sub, {
55
+ tenantId: auth.tenantId,
56
+ organizationId: auth.orgId,
57
+ })
55
58
 
56
59
  const groups = buildPortalNav({
57
60
  routes: getFrontendRouteManifests(),
58
61
  orgSlug,
59
62
  grantedFeatures,
60
- isPortalAdmin: acl.isPortalAdmin,
61
63
  })
62
64
 
63
65
  return NextResponse.json({
@@ -72,7 +74,7 @@ export async function GET(req: Request) {
72
74
  const getMethodDoc: OpenApiMethodDoc = {
73
75
  summary: 'Portal sidebar navigation',
74
76
  description:
75
- 'Returns the portal sidebar for the authenticated customer. Items are derived from each portal page\'s `nav` metadata and filtered by `requireCustomerFeatures` against the customer\'s grants (wildcards honored).',
77
+ 'Returns the portal sidebar for the authenticated customer. Items are derived from each portal page\'s `nav` metadata and filtered by `requireCustomerFeatures` against concrete effective features.',
76
78
  tags: ['Customer Portal'],
77
79
  responses: [{ status: 200, description: 'Portal sidebar groups', schema: navResponseSchema }],
78
80
  errors: [
@@ -27,6 +27,10 @@ export async function GET(req: Request) {
27
27
  }
28
28
 
29
29
  const acl = await customerRbacService.loadAcl(user.id, { tenantId: user.tenantId, organizationId: user.organizationId })
30
+ const resolvedFeatures = await customerRbacService.getEffectiveFeatures(user.id, {
31
+ tenantId: user.tenantId,
32
+ organizationId: user.organizationId,
33
+ })
30
34
 
31
35
  const userRoles = await em.find(CustomerUserRole, {
32
36
  user: user.id as any,
@@ -52,7 +56,7 @@ export async function GET(req: Request) {
52
56
  createdAt: user.createdAt,
53
57
  },
54
58
  roles,
55
- resolvedFeatures: acl.features,
59
+ resolvedFeatures,
56
60
  isPortalAdmin: acl.isPortalAdmin,
57
61
  })
58
62
  }
@@ -36,17 +36,17 @@ export async function POST(req: Request) {
36
36
  return NextResponse.json({ ok: false, error: 'Account not active' }, { status: 401 })
37
37
  }
38
38
 
39
- const acl = await customerRbacService.loadAcl(user.id, {
39
+ const resolvedFeatures = await customerRbacService.getEffectiveFeatures(user.id, {
40
40
  tenantId: user.tenantId,
41
41
  organizationId: user.organizationId,
42
42
  })
43
43
 
44
- const result = await customerSessionService.refreshSession(decodedToken, acl.features)
44
+ const result = await customerSessionService.refreshSession(decodedToken, resolvedFeatures)
45
45
  if (!result) {
46
46
  return NextResponse.json({ ok: false, error: 'Session refresh failed' }, { status: 401 })
47
47
  }
48
48
 
49
- const res = NextResponse.json({ ok: true, resolvedFeatures: acl.features })
49
+ const res = NextResponse.json({ ok: true, resolvedFeatures })
50
50
 
51
51
  res.cookies.set('customer_auth_token', result.jwt, {
52
52
  httpOnly: true,
@@ -15,6 +15,7 @@ export interface CustomerAuthContext {
15
15
  customerEntityId?: string | null
16
16
  personEntityId?: string | null
17
17
  resolvedFeatures: string[]
18
+ isPortalAdmin?: boolean
18
19
  }
19
20
 
20
21
  async function assertSessionStillActive(sessionId: string): Promise<boolean> {
@@ -48,7 +49,7 @@ export function readCookieFromHeader(header: string | null | undefined, name: st
48
49
 
49
50
  export type UserValidationResult =
50
51
  | { valid: false }
51
- | { valid: true; resolvedFeatures: string[] }
52
+ | { valid: true; resolvedFeatures: string[]; isPortalAdmin: boolean }
52
53
 
53
54
  export async function validateUserState(
54
55
  sub: string,
@@ -77,7 +78,11 @@ export async function validateUserState(
77
78
  )
78
79
  const rbac = container.resolve('customerRbacService') as InstanceType<typeof CustomerRbacService>
79
80
  const acl = await rbac.loadAcl(sub, { tenantId, organizationId: orgId })
80
- return { valid: true, resolvedFeatures: acl.isPortalAdmin ? ['*'] : acl.features }
81
+ const resolvedFeatures = await rbac.getEffectiveFeatures(sub, {
82
+ tenantId,
83
+ organizationId: orgId,
84
+ })
85
+ return { valid: true, resolvedFeatures, isPortalAdmin: acl.isPortalAdmin }
81
86
  }
82
87
 
83
88
  export async function getCustomerAuthFromRequest(req: Request): Promise<CustomerAuthContext | null> {
@@ -134,6 +139,7 @@ export async function getCustomerAuthFromRequest(req: Request): Promise<Customer
134
139
  customerEntityId: payload.customerEntityId ? String(payload.customerEntityId) : null,
135
140
  personEntityId: payload.personEntityId ? String(payload.personEntityId) : null,
136
141
  resolvedFeatures: userState.resolvedFeatures,
142
+ isPortalAdmin: userState.isPortalAdmin,
137
143
  }
138
144
  } catch {
139
145
  // Invalid or expired JWT — treat as unauthenticated
@@ -88,6 +88,7 @@ export async function getCustomerAuthFromCookies(
88
88
  customerEntityId: payload.customerEntityId ? String(payload.customerEntityId) : null,
89
89
  personEntityId: payload.personEntityId ? String(payload.personEntityId) : null,
90
90
  resolvedFeatures: userState.resolvedFeatures,
91
+ isPortalAdmin: userState.isPortalAdmin,
91
92
  }
92
93
  } catch {
93
94
  // Invalid or expired JWT — treat as unauthenticated
@@ -5,7 +5,10 @@ import {
5
5
  CustomerRoleAcl,
6
6
  CustomerUserRole,
7
7
  } from '@open-mercato/core/modules/customer_accounts/data/entities'
8
- import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
8
+ import {
9
+ authorizeFeatures,
10
+ resolveEffectiveFeatures,
11
+ } from '@open-mercato/shared/security/featurePolicy'
9
12
 
10
13
  interface CustomerAclData {
11
14
  isPortalAdmin: boolean
@@ -129,8 +132,18 @@ export class CustomerRbacService {
129
132
  ): Promise<boolean> {
130
133
  if (!required.length) return true
131
134
  const acl = await this.loadAcl(userId, scope)
132
- if (acl.isPortalAdmin) return true
133
- return hasAllFeatures(required, acl.features)
135
+ return authorizeFeatures(required, {
136
+ grantedFeatures: acl.features,
137
+ unrestricted: acl.isPortalAdmin,
138
+ })
139
+ }
140
+
141
+ async getEffectiveFeatures(
142
+ userId: string,
143
+ scope: { tenantId: string; organizationId: string },
144
+ ): Promise<string[]> {
145
+ const acl = await this.loadAcl(userId, scope)
146
+ return resolveEffectiveFeatures(acl.isPortalAdmin ? ['portal.*'] : acl.features)
134
147
  }
135
148
 
136
149
  async invalidateUserCache(userId: string): Promise<void> {
@@ -137,6 +137,10 @@ const DEFAULT_ROLES = [
137
137
  },
138
138
  ]
139
139
 
140
+ const DEFAULT_CUSTOMER_ROLE_FEATURES = Object.fromEntries(
141
+ DEFAULT_ROLES.map((role) => [role.slug, [...role.acl.features]]),
142
+ )
143
+
140
144
  /**
141
145
  * Collect defaultCustomerRoleFeatures from all enabled modules and merge
142
146
  * them into the corresponding CustomerRoleAcl records.
@@ -220,6 +224,9 @@ export const setup: ModuleSetupConfig = {
220
224
  superadmin: ['customer_accounts.*'],
221
225
  admin: ['customer_accounts.*'],
222
226
  },
227
+ // Keep the portal feature catalog declarative so the shared feature policy
228
+ // can project portal.* into concrete capabilities without importing core.
229
+ defaultCustomerRoleFeatures: DEFAULT_CUSTOMER_ROLE_FEATURES,
223
230
 
224
231
  async onTenantCreated({ em, tenantId, organizationId }) {
225
232
  await seedDefaultRoles(em, { tenantId, organizationId })
@@ -69,9 +69,7 @@ export default function PortalAiAssistantTriggerWidget({ context }: PortalAiAssi
69
69
  const resolvedFeatures = Array.isArray(context?.resolvedFeatures)
70
70
  ? (context?.resolvedFeatures as string[])
71
71
  : []
72
- const featureAllowed =
73
- context?.isPortalAdmin === true ||
74
- hasFeature(resolvedFeatures, PORTAL_AI_INJECT_REQUIRED_FEATURE)
72
+ const featureAllowed = hasFeature(resolvedFeatures, PORTAL_AI_INJECT_REQUIRED_FEATURE)
75
73
 
76
74
  const pageContext = React.useMemo<PortalAiInjectPageContext>(() => ({
77
75
  view: 'portal.profile',
@@ -1,5 +1,5 @@
1
1
  import type { FilterQuery } from '@mikro-orm/postgresql'
2
- import { hasFeature } from '@open-mercato/shared/security/features'
2
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
3
  import { CustomerInteraction } from '../data/entities'
4
4
 
5
5
  /**
@@ -22,7 +22,9 @@ export const EMAIL_VIEW_PRIVATE_FEATURE = 'customers.email.view_private'
22
22
  */
23
23
  export function callerHasEmailViewPrivate(userFeatures: string[] | null | undefined): boolean {
24
24
  if (!Array.isArray(userFeatures) || userFeatures.length === 0) return false
25
- return hasFeature(userFeatures, EMAIL_VIEW_PRIVATE_FEATURE)
25
+ return authorizeFeatures([EMAIL_VIEW_PRIVATE_FEATURE], {
26
+ grantedFeatures: userFeatures,
27
+ })
26
28
  }
27
29
 
28
30
  /**
@@ -92,7 +94,7 @@ export function applyEmailVisibilityFilter<T extends { where: (...args: any[]) =
92
94
  }
93
95
 
94
96
  type RbacServiceLike = {
95
- getGrantedFeatures?: (
97
+ getEffectiveFeatures?: (
96
98
  userId: string,
97
99
  scope: { tenantId: string | null; organizationId: string | null },
98
100
  ) => Promise<string[] | undefined>
@@ -118,8 +120,8 @@ export async function resolveCallerEmailFeatures(
118
120
  if (!userId) return undefined
119
121
  try {
120
122
  const rbac = container.resolve('rbacService') as RbacServiceLike | undefined
121
- if (!rbac?.getGrantedFeatures) return undefined
122
- return await rbac.getGrantedFeatures(userId, { tenantId, organizationId })
123
+ if (!rbac?.getEffectiveFeatures) return undefined
124
+ return await rbac.getEffectiveFeatures(userId, { tenantId, organizationId })
123
125
  } catch {
124
126
  return undefined
125
127
  }
@@ -4,7 +4,6 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { DashboardLayout } from '@open-mercato/core/modules/dashboards/data/entities'
6
6
  import { dashboardLayoutItemPatchSchema } from '@open-mercato/core/modules/dashboards/data/validators'
7
- import { hasFeature } from '@open-mercato/shared/security/features'
8
7
  import {
9
8
  runCrudMutationGuardAfterSuccess,
10
9
  validateCrudMutationGuard,
@@ -55,8 +54,12 @@ export async function PATCH(req: Request, ctx: { params?: { itemId?: string } })
55
54
  organizationId: auth.orgId ?? null,
56
55
  }
57
56
 
58
- const acl = await rbac.loadAcl(scope.userId, { tenantId: scope.tenantId, organizationId: scope.organizationId })
59
- if (!acl.isSuperAdmin && !hasFeature(acl.features, 'dashboards.configure')) {
57
+ const canConfigure = await rbac.userHasAllFeatures(
58
+ scope.userId,
59
+ ['dashboards.configure'],
60
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
61
+ )
62
+ if (!canConfigure) {
60
63
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
61
64
  }
62
65
 
@@ -6,7 +6,7 @@ import { DashboardLayout } from '@open-mercato/core/modules/dashboards/data/enti
6
6
  import { dashboardLayoutSchema } from '@open-mercato/core/modules/dashboards/data/validators'
7
7
  import { loadAllWidgets } from '@open-mercato/core/modules/dashboards/lib/widgets'
8
8
  import { resolveAllowedWidgetIds } from '@open-mercato/core/modules/dashboards/lib/access'
9
- import { hasFeature } from '@open-mercato/shared/security/features'
9
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
10
10
  import { User } from '@open-mercato/core/modules/auth/data/entities'
11
11
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
12
  import {
@@ -88,7 +88,10 @@ export async function GET(req: Request) {
88
88
  organizationId: auth.orgId ?? null,
89
89
  }
90
90
 
91
- const acl = await rbac.loadAcl(scope.userId, { tenantId: scope.tenantId, organizationId: scope.organizationId })
91
+ const effectiveFeatures = await rbac.getEffectiveFeatures(scope.userId, {
92
+ tenantId: scope.tenantId,
93
+ organizationId: scope.organizationId,
94
+ })
92
95
  const widgets = await loadAllWidgets()
93
96
  const allowedIds = await resolveAllowedWidgetIds(
94
97
  em,
@@ -96,8 +99,8 @@ export async function GET(req: Request) {
96
99
  userId: scope.userId,
97
100
  tenantId: scope.tenantId,
98
101
  organizationId: scope.organizationId,
99
- features: acl.features ?? [],
100
- isSuperAdmin: !!acl.isSuperAdmin,
102
+ features: effectiveFeatures,
103
+ isSuperAdmin: false,
101
104
  },
102
105
  widgets,
103
106
  )
@@ -147,7 +150,9 @@ export async function GET(req: Request) {
147
150
  await em.flush()
148
151
  }
149
152
 
150
- const canConfigure = acl.isSuperAdmin || hasFeature(acl.features, 'dashboards.configure')
153
+ const canConfigure = authorizeFeatures(['dashboards.configure'], {
154
+ grantedFeatures: effectiveFeatures,
155
+ })
151
156
 
152
157
  let userEmail: string | null = null
153
158
  let userName: string | null = null
@@ -222,8 +227,12 @@ export async function PUT(req: Request) {
222
227
  organizationId: auth.orgId ?? null,
223
228
  }
224
229
 
225
- const acl = await rbac.loadAcl(scope.userId, { tenantId: scope.tenantId, organizationId: scope.organizationId })
226
- if (!acl.isSuperAdmin && !hasFeature(acl.features, 'dashboards.configure')) {
230
+ const canConfigure = await rbac.userHasAllFeatures(
231
+ scope.userId,
232
+ ['dashboards.configure'],
233
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
234
+ )
235
+ if (!canConfigure) {
227
236
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
228
237
  }
229
238
 
@@ -243,14 +252,18 @@ export async function PUT(req: Request) {
243
252
  }
244
253
 
245
254
  const widgets = await loadAllWidgets()
255
+ const effectiveFeatures = await rbac.getEffectiveFeatures(scope.userId, {
256
+ tenantId: scope.tenantId,
257
+ organizationId: scope.organizationId,
258
+ })
246
259
  const allowedIds = await resolveAllowedWidgetIds(
247
260
  em,
248
261
  {
249
262
  userId: scope.userId,
250
263
  tenantId: scope.tenantId,
251
264
  organizationId: scope.organizationId,
252
- features: acl.features ?? [],
253
- isSuperAdmin: !!acl.isSuperAdmin,
265
+ features: effectiveFeatures,
266
+ isSuperAdmin: false,
254
267
  },
255
268
  widgets,
256
269
  )