@open-mercato/core 0.6.7-develop.6593.1.0ecf630f1e → 0.6.7-develop.6594.1.5581f765f4

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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/custom_entity/index.js +2 -0
  3. package/dist/generated/entities/custom_entity/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/auth/api/features.js +14 -0
  7. package/dist/modules/auth/api/features.js.map +2 -2
  8. package/dist/modules/entities/api/entities.js +20 -0
  9. package/dist/modules/entities/api/entities.js.map +2 -2
  10. package/dist/modules/entities/api/entity-settings.js +89 -0
  11. package/dist/modules/entities/api/entity-settings.js.map +7 -0
  12. package/dist/modules/entities/api/records.js +46 -25
  13. package/dist/modules/entities/api/records.js.map +2 -2
  14. package/dist/modules/entities/backend/entities/user/[entityId]/page.js +13 -4
  15. package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
  16. package/dist/modules/entities/backend/entities/user/create/page.js +27 -2
  17. package/dist/modules/entities/backend/entities/user/create/page.js.map +2 -2
  18. package/dist/modules/entities/data/entities.js +4 -0
  19. package/dist/modules/entities/data/entities.js.map +2 -2
  20. package/dist/modules/entities/lib/entityAcl.js +17 -4
  21. package/dist/modules/entities/lib/entityAcl.js.map +3 -3
  22. package/dist/modules/entities/lib/install-from-ce.js +2 -0
  23. package/dist/modules/entities/lib/install-from-ce.js.map +2 -2
  24. package/dist/modules/entities/lib/recordFeatures.js +24 -0
  25. package/dist/modules/entities/lib/recordFeatures.js.map +7 -0
  26. package/dist/modules/entities/lib/register.js +3 -0
  27. package/dist/modules/entities/lib/register.js.map +2 -2
  28. package/dist/modules/entities/lib/restrictedEntityFeatures.js +62 -0
  29. package/dist/modules/entities/lib/restrictedEntityFeatures.js.map +7 -0
  30. package/dist/modules/entities/migrations/Migration20260716120000.js +13 -0
  31. package/dist/modules/entities/migrations/Migration20260716120000.js.map +7 -0
  32. package/generated/entities/custom_entity/index.ts +1 -0
  33. package/generated/entity-fields-registry.ts +1 -0
  34. package/package.json +7 -7
  35. package/src/modules/auth/api/features.ts +17 -0
  36. package/src/modules/entities/api/entities.ts +28 -0
  37. package/src/modules/entities/api/entity-settings.ts +103 -0
  38. package/src/modules/entities/api/records.ts +70 -29
  39. package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +12 -3
  40. package/src/modules/entities/backend/entities/user/create/page.tsx +33 -1
  41. package/src/modules/entities/data/entities.ts +6 -0
  42. package/src/modules/entities/i18n/de.json +3 -0
  43. package/src/modules/entities/i18n/en.json +3 -0
  44. package/src/modules/entities/i18n/es.json +3 -0
  45. package/src/modules/entities/i18n/pl.json +3 -0
  46. package/src/modules/entities/lib/entityAcl.ts +27 -5
  47. package/src/modules/entities/lib/install-from-ce.ts +2 -0
  48. package/src/modules/entities/lib/recordFeatures.ts +48 -0
  49. package/src/modules/entities/lib/register.ts +4 -0
  50. package/src/modules/entities/lib/restrictedEntityFeatures.ts +98 -0
  51. package/src/modules/entities/migrations/.snapshot-open-mercato.json +10 -0
  52. package/src/modules/entities/migrations/Migration20260716120000.ts +13 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/entities/migrations/Migration20260716120000.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260716120000 extends Migration {\n\n override async up(): Promise<void> {\n this.addSql(`alter table \"custom_entities\" add column \"access_restricted\" boolean not null default false;`);\n }\n\n override async down(): Promise<void> {\n this.addSql(`alter table \"custom_entities\" drop column \"access_restricted\";`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,gCAAgC,UAAU;AAAA,EAErD,MAAe,KAAoB;AACjC,SAAK,OAAO,8FAA8F;AAAA,EAC5G;AAAA,EAEA,MAAe,OAAsB;AACnC,SAAK,OAAO,gEAAgE;AAAA,EAC9E;AAEF;",
6
+ "names": []
7
+ }
@@ -5,6 +5,7 @@ export const description = "description";
5
5
  export const label_field = "label_field";
6
6
  export const default_editor = "default_editor";
7
7
  export const show_in_sidebar = "show_in_sidebar";
8
+ export const access_restricted = "access_restricted";
8
9
  export const organization_id = "organization_id";
9
10
  export const tenant_id = "tenant_id";
10
11
  export const is_active = "is_active";
@@ -503,6 +503,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
503
503
  "label_field": "label_field",
504
504
  "default_editor": "default_editor",
505
505
  "show_in_sidebar": "show_in_sidebar",
506
+ "access_restricted": "access_restricted",
506
507
  "organization_id": "organization_id",
507
508
  "tenant_id": "tenant_id",
508
509
  "is_active": "is_active",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.7-develop.6593.1.0ecf630f1e",
3
+ "version": "0.6.7-develop.6594.1.5581f765f4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.7-develop.6593.1.0ecf630f1e",
258
- "@open-mercato/shared": "0.6.7-develop.6593.1.0ecf630f1e",
259
- "@open-mercato/ui": "0.6.7-develop.6593.1.0ecf630f1e",
257
+ "@open-mercato/ai-assistant": "0.6.7-develop.6594.1.5581f765f4",
258
+ "@open-mercato/shared": "0.6.7-develop.6594.1.5581f765f4",
259
+ "@open-mercato/ui": "0.6.7-develop.6594.1.5581f765f4",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.7-develop.6593.1.0ecf630f1e",
265
- "@open-mercato/shared": "0.6.7-develop.6593.1.0ecf630f1e",
266
- "@open-mercato/ui": "0.6.7-develop.6593.1.0ecf630f1e",
264
+ "@open-mercato/ai-assistant": "0.6.7-develop.6594.1.5581f765f4",
265
+ "@open-mercato/shared": "0.6.7-develop.6594.1.5581f765f4",
266
+ "@open-mercato/ui": "0.6.7-develop.6594.1.5581f765f4",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^6.9.1",
269
269
  "@testing-library/react": "^16.3.1",
@@ -3,6 +3,8 @@ import { z } from 'zod'
3
3
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { getModules } from '@open-mercato/shared/lib/i18n/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import { synthesizeRestrictedEntityFeatures } from '@open-mercato/core/modules/entities/lib/restrictedEntityFeatures'
6
8
 
7
9
  export const metadata = {
8
10
  GET: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },
@@ -44,6 +46,21 @@ export async function GET(req: Request) {
44
46
  return base
45
47
  })
46
48
  )
49
+ // Append synthesized per-entity features for the tenant's restricted custom
50
+ // entities so they can be granted in the ACL editor. Tenant-scoped; never
51
+ // throws (falls back to the static catalog on any failure).
52
+ try {
53
+ const { resolve } = await createRequestContainer()
54
+ const em = resolve('em') as any
55
+ const synthesized = await synthesizeRestrictedEntityFeatures(em, auth.tenantId ?? null)
56
+ for (const item of synthesized) {
57
+ const deps = normalizeDependsOn(item.dependsOn)
58
+ const base: FeatureItem = { id: item.id, title: item.title, module: item.module }
59
+ if (deps) base.dependsOn = deps
60
+ items.push(base)
61
+ }
62
+ } catch {}
63
+
47
64
  // Deduplicate by id (keep first occurrence)
48
65
  const byId = new Map<string, FeatureItem>()
49
66
  for (const it of items) if (!byId.has(it.id)) byId.set(it.id, it)
@@ -68,6 +68,7 @@ export async function GET(req: Request) {
68
68
  labelField: (c as any).labelField ?? undefined,
69
69
  defaultEditor: (c as any).defaultEditor ?? undefined,
70
70
  showInSidebar: (c as any).showInSidebar ?? false,
71
+ accessRestricted: (c as any).accessRestricted ?? false,
71
72
  updatedAt: c.updatedAt instanceof Date ? c.updatedAt.toISOString() : (c.updatedAt ?? undefined),
72
73
  }))
73
74
 
@@ -113,6 +114,10 @@ export async function POST(req: Request) {
113
114
  return NextResponse.json({ error: 'Validation failed', details: parsed.error.flatten() }, { status: 400 })
114
115
  }
115
116
  const input = parsed.data
117
+ // Distinguish "caller did not send the field" (apply tenant default-restricted
118
+ // policy on create) from "caller explicitly chose false".
119
+ const accessRestrictedExplicit = body != null && typeof body === 'object'
120
+ && Object.prototype.hasOwnProperty.call(body, 'accessRestricted')
116
121
 
117
122
  const container = await createRequestContainer()
118
123
  const { resolve } = container
@@ -157,6 +162,7 @@ export async function POST(req: Request) {
157
162
  })
158
163
  if (guard.blockedResponse) return guard.blockedResponse
159
164
 
165
+ const isCreate = !ent
160
166
  if (!ent) ent = em.create(CustomEntity, { ...where, createdAt: new Date() })
161
167
  ent.label = input.label
162
168
  ent.description = input.description ?? null
@@ -164,6 +170,27 @@ export async function POST(req: Request) {
164
170
  ent.labelField = input.labelField ?? ent.labelField ?? null
165
171
  ent.defaultEditor = input.defaultEditor ?? ent.defaultEditor ?? null
166
172
  ent.showInSidebar = input.showInSidebar ?? ent.showInSidebar ?? false
173
+ // Never silently flip a security control on a partial upsert. `access_restricted`
174
+ // changes ONLY when the caller explicitly sends the field. On create without it,
175
+ // fall back to the tenant default-restricted policy; on update without it, keep
176
+ // the entity's current value (a metadata-only update must not un-restrict).
177
+ if (accessRestrictedExplicit) {
178
+ ent.accessRestricted = input.accessRestricted === true
179
+ } else if (isCreate) {
180
+ let policyDefault = false
181
+ try {
182
+ const moduleConfigService = resolve('moduleConfigService') as {
183
+ getValue: (m: string, n: string, o?: { defaultValue?: unknown; scope?: { tenantId?: string | null } }) => Promise<unknown>
184
+ }
185
+ policyDefault = (await moduleConfigService.getValue('entities', 'newEntitiesRestrictedByDefault', {
186
+ defaultValue: false,
187
+ scope: { tenantId: auth.tenantId ?? null },
188
+ })) === true
189
+ } catch {}
190
+ ent.accessRestricted = policyDefault
191
+ } else {
192
+ ent.accessRestricted = ent.accessRestricted ?? false
193
+ }
167
194
  ent.updatedAt = new Date()
168
195
  em.persist(ent)
169
196
  await em.flush()
@@ -229,6 +256,7 @@ const entitySummarySchema = z.object({
229
256
  labelField: z.string().optional(),
230
257
  defaultEditor: z.string().optional(),
231
258
  showInSidebar: z.boolean().optional(),
259
+ accessRestricted: z.boolean().optional(),
232
260
  updatedAt: z.string().optional(),
233
261
  count: z.number(),
234
262
  })
@@ -0,0 +1,103 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
6
+
7
+ // Tenant-scoped policy: when true, new custom entities are created with
8
+ // `access_restricted = true` unless the create request explicitly sets the flag.
9
+ const CONFIG_MODULE = 'entities'
10
+ const NEW_ENTITIES_RESTRICTED_KEY = 'newEntitiesRestrictedByDefault'
11
+
12
+ export const metadata = {
13
+ GET: { requireAuth: true, requireFeatures: ['entities.definitions.view'] },
14
+ PUT: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
15
+ }
16
+
17
+ type ModuleConfigServiceLike = {
18
+ getValue: (
19
+ moduleId: string,
20
+ name: string,
21
+ options?: { defaultValue?: unknown; scope?: { tenantId?: string | null } },
22
+ ) => Promise<unknown>
23
+ setValue: (
24
+ moduleId: string,
25
+ name: string,
26
+ value: unknown,
27
+ scope?: { tenantId?: string | null },
28
+ ) => Promise<unknown>
29
+ }
30
+
31
+ async function readPolicy(tenantId: string | null): Promise<boolean> {
32
+ try {
33
+ const { resolve } = await createRequestContainer()
34
+ const moduleConfigService = resolve('moduleConfigService') as ModuleConfigServiceLike
35
+ const value = await moduleConfigService.getValue(CONFIG_MODULE, NEW_ENTITIES_RESTRICTED_KEY, {
36
+ defaultValue: false,
37
+ scope: { tenantId },
38
+ })
39
+ return value === true
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ export async function GET(req: Request) {
46
+ const auth = await getAuthFromRequest(req)
47
+ if (!auth || !auth.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
48
+ const newEntitiesRestrictedByDefault = await readPolicy(auth.tenantId ?? null)
49
+ return NextResponse.json({ newEntitiesRestrictedByDefault })
50
+ }
51
+
52
+ const putBodySchema = z.object({
53
+ newEntitiesRestrictedByDefault: z.boolean(),
54
+ })
55
+
56
+ export async function PUT(req: Request) {
57
+ const auth = await getAuthFromRequest(req)
58
+ if (!auth || !auth.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
59
+ let body: unknown
60
+ try { body = await req.json() } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) }
61
+ const parsed = putBodySchema.safeParse(body)
62
+ if (!parsed.success) {
63
+ return NextResponse.json({ error: 'Validation failed', details: parsed.error.flatten() }, { status: 400 })
64
+ }
65
+ const { resolve } = await createRequestContainer()
66
+ const moduleConfigService = resolve('moduleConfigService') as ModuleConfigServiceLike
67
+ await moduleConfigService.setValue(
68
+ CONFIG_MODULE,
69
+ NEW_ENTITIES_RESTRICTED_KEY,
70
+ parsed.data.newEntitiesRestrictedByDefault,
71
+ { tenantId: auth.tenantId ?? null },
72
+ )
73
+ return NextResponse.json({ ok: true, newEntitiesRestrictedByDefault: parsed.data.newEntitiesRestrictedByDefault })
74
+ }
75
+
76
+ const settingsResponseSchema = z.object({
77
+ newEntitiesRestrictedByDefault: z.boolean(),
78
+ })
79
+
80
+ export const openApi: OpenApiRouteDoc = {
81
+ tag: 'Entities',
82
+ summary: 'Custom entity workspace settings',
83
+ methods: {
84
+ GET: {
85
+ summary: 'Get custom entity settings',
86
+ description: 'Returns the tenant-scoped default-restricted policy for new custom entities.',
87
+ responses: [
88
+ { status: 200, description: 'Current settings', schema: settingsResponseSchema },
89
+ { status: 401, description: 'Missing authentication', schema: z.object({ error: z.string() }) },
90
+ ],
91
+ },
92
+ PUT: {
93
+ summary: 'Update custom entity settings',
94
+ description: 'Sets the tenant-scoped default-restricted policy for new custom entities.',
95
+ requestBody: { schema: putBodySchema },
96
+ responses: [
97
+ { status: 200, description: 'Updated settings', schema: z.object({ ok: z.boolean(), newEntitiesRestrictedByDefault: z.boolean() }) },
98
+ { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },
99
+ { status: 401, description: 'Missing authentication', schema: z.object({ error: z.string() }) },
100
+ ],
101
+ },
102
+ },
103
+ }
@@ -20,29 +20,63 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
20
20
 
21
21
  const logger = createLogger('entities').child({ component: 'records' })
22
22
 
23
- let declaredCustomEntityIds: Set<string> | null = null
24
- function isDeclaredCustomEntity(entityId: string): boolean {
25
- if (declaredCustomEntityIds === null) {
23
+ let declaredCustomEntityRestricted: Map<string, boolean> | null = null
24
+ function loadDeclaredCustomEntities(): Map<string, boolean> {
25
+ if (declaredCustomEntityRestricted === null) {
26
26
  try {
27
- const mods = getModules() as Array<{ customEntities?: Array<{ id?: string }> }>
28
- if (Array.isArray(mods) && mods.length) {
29
- const ids = new Set<string>()
30
- for (const mod of mods) {
31
- for (const spec of mod?.customEntities ?? []) {
32
- if (spec?.id) ids.add(spec.id)
33
- }
27
+ const mods = getModules() as Array<{ customEntities?: Array<{ id?: string; accessRestricted?: boolean }> }>
28
+ const map = new Map<string, boolean>()
29
+ for (const mod of mods ?? []) {
30
+ for (const spec of mod?.customEntities ?? []) {
31
+ if (spec?.id) map.set(spec.id, spec.accessRestricted === true)
34
32
  }
35
- declaredCustomEntityIds = ids
36
33
  }
34
+ // Cache even when empty so we don't rebuild on every request (and fall back
35
+ // to the DB lookup unnecessarily). Only a thrown getModules() leaves it null
36
+ // so a genuinely-uninitialized registry is retried.
37
+ declaredCustomEntityRestricted = map
37
38
  } catch {}
38
39
  }
39
- return declaredCustomEntityIds?.has(entityId) ?? false
40
+ return declaredCustomEntityRestricted ?? new Map<string, boolean>()
41
+ }
42
+ function isDeclaredCustomEntity(entityId: string): boolean {
43
+ return loadDeclaredCustomEntities().has(entityId)
44
+ }
45
+
46
+ type RecordsEntityScope = { tenantId: string | null; organizationId: string | null }
47
+
48
+ // Resolve the CustomEntity registration that applies to THIS caller, most-specific
49
+ // first (org+tenant → tenant-global → instance-global), mirroring the overlay
50
+ // precedence used by the entity-definitions list. Scoping matters because the
51
+ // row's `access_restricted` flag is a security control: an unscoped lookup could
52
+ // read another tenant's row for a colliding entityId (e.g. `user:vendors`) and
53
+ // mis-decide the restriction. Returns null when the caller's scope has no row.
54
+ async function findScopedCustomEntity(em: any, CustomEntity: any, entityId: string, scope: RecordsEntityScope) {
55
+ const { tenantId, organizationId } = scope
56
+ const candidates: Array<Record<string, unknown>> = [
57
+ { entityId, organizationId, tenantId },
58
+ { entityId, organizationId: null, tenantId },
59
+ { entityId, organizationId: null, tenantId: null },
60
+ ]
61
+ const seen = new Set<string>()
62
+ for (const where of candidates) {
63
+ const key = JSON.stringify(where)
64
+ if (seen.has(key)) continue
65
+ seen.add(key)
66
+ const row = await em.findOne(CustomEntity as any, where)
67
+ if (row) return row
68
+ }
69
+ return null
40
70
  }
41
71
 
42
72
  const CUSTOM_ENTITY_RECORD_RESOURCE_KIND = 'entities.record'
43
73
 
44
74
  type RecordsEntityKind = 'system' | 'custom' | 'unknown'
45
75
 
76
+ // `restricted` is meaningful only when `kind === 'custom'`; it drives the
77
+ // per-entity ACL gate in `assertEntityAclForRequest`.
78
+ type RecordsEntityClassification = { kind: RecordsEntityKind; restricted: boolean }
79
+
46
80
  // This surface manages doc-storage records, which exist for CUSTOM entities only.
47
81
  // Module-declared ids backed by a registered ORM table are system entities — their
48
82
  // records live in their own module tables/APIs, and stray doc rows for them poisoned
@@ -50,18 +84,25 @@ type RecordsEntityKind = 'system' | 'custom' | 'unknown'
50
84
  // previous fallback that classified an entity by the mere presence of
51
85
  // `custom_entities_storage` rows is gone: within the allowed set, declaration (ce.ts)
52
86
  // or an active `custom_entities` registration is authoritative.
53
- async function classifyRecordsEntity(em: any, entityId: string): Promise<RecordsEntityKind> {
54
- if (isOrmBackedSystemEntityId(em, entityId)) return 'system'
55
- if (isDeclaredCustomEntity(entityId)) return 'custom'
87
+ async function classifyRecordsEntity(em: any, entityId: string, scope: RecordsEntityScope): Promise<RecordsEntityClassification> {
88
+ if (isOrmBackedSystemEntityId(em, entityId)) return { kind: 'system', restricted: false }
89
+ const declared = loadDeclaredCustomEntities()
90
+ if (declared.has(entityId)) return { kind: 'custom', restricted: declared.get(entityId) === true }
56
91
  try {
57
92
  const { CustomEntity } = await import('../data/entities')
58
- // Any registration row active or soft-deleted proves the id is a custom
59
- // entity. Records persist beyond the definition's soft delete (TC-ENTITIES-006)
60
- // and must stay readable/deletable, e.g. for the restore flow and cleanup.
61
- const found = await em.findOne(CustomEntity as any, { entityId })
62
- if (found) return 'custom'
93
+ // Restriction is decided from the row that applies to THIS caller's scope so
94
+ // a colliding entityId in another tenant can't flip the flag.
95
+ const scoped = await findScopedCustomEntity(em, CustomEntity, entityId, scope)
96
+ if (scoped) return { kind: 'custom', restricted: (scoped as any).accessRestricted === true }
97
+ // No in-scope registration: preserve the historical custom-vs-unknown
98
+ // classification (any registration row — active or soft-deleted — proves the
99
+ // id is custom; records persist beyond soft delete, TC-ENTITIES-006). A row
100
+ // outside the caller's scope never marks the entity restricted for them, and
101
+ // the record query is itself tenant/org-scoped, so this cannot leak data.
102
+ const anyRow = await em.findOne(CustomEntity as any, { entityId })
103
+ if (anyRow) return { kind: 'custom', restricted: false }
63
104
  } catch {}
64
- return 'unknown'
105
+ return { kind: 'unknown', restricted: false }
65
106
  }
66
107
 
67
108
  function systemEntityRecordsRejection(entityId: string) {
@@ -171,10 +212,10 @@ export async function GET(req: Request) {
171
212
  // registered in `custom_entities`, so classification checks the declared registry plus
172
213
  // active registrations. System (table-backed) ids are rejected above; for the allowed
173
214
  // set `isCustomEntity` drives mapRow's cf_ stripping so the edit form reads back values.
174
- const entityKind = await classifyRecordsEntity(em, entityId)
215
+ const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
175
216
  if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
176
217
  const isCustomEntity = entityKind === 'custom'
177
- await assertEntityAclForRequest({ auth, entityId, action: 'view', isCustomEntity, rbac })
218
+ await assertEntityAclForRequest({ auth, entityId, action: 'view', isCustomEntity, isRestricted, rbac })
178
219
  if (organizationIds && organizationIds.length === 0) {
179
220
  return NextResponse.json({ items: [], total: 0, page, pageSize, totalPages: 0 })
180
221
  }
@@ -395,10 +436,10 @@ export async function POST(req: Request) {
395
436
  const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
396
437
  const targetOrgId = scope.selectedId ?? auth.orgId
397
438
  if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
398
- const entityKind = await classifyRecordsEntity(em, entityId)
439
+ const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
399
440
  if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
400
441
  const isCustomEntity = entityKind === 'custom'
401
- await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
442
+ await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, isRestricted, rbac })
402
443
  // Strip reserved record/system columns the edit form echoes back from the loaded record
403
444
  // (`id`, plus `updated_at`/`updatedAt` used for optimistic locking). They are not custom
404
445
  // fields; without this they validate as cf_id / cf_updated_at / cf_updatedAt and are
@@ -469,10 +510,10 @@ export async function PUT(req: Request) {
469
510
  const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
470
511
  const targetOrgId = scope.selectedId ?? auth.orgId
471
512
  if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
472
- const entityKind = await classifyRecordsEntity(em, entityId)
513
+ const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
473
514
  if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
474
515
  const isCustomEntity = entityKind === 'custom'
475
- await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
516
+ await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, isRestricted, rbac })
476
517
  // Strip reserved record/system columns the edit form echoes back from the loaded record
477
518
  // (`id`, plus `updated_at`/`updatedAt` used for optimistic locking). They are not custom
478
519
  // fields; without this they validate as cf_id / cf_updated_at / cf_updatedAt and are
@@ -568,10 +609,10 @@ export async function DELETE(req: Request) {
568
609
  const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
569
610
  const targetOrgId = scope.selectedId ?? auth.orgId
570
611
  if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
571
- const entityKind = await classifyRecordsEntity(em, entityId)
612
+ const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
572
613
  if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
573
614
  const isCustomEntity = entityKind === 'custom'
574
- await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
615
+ await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, isRestricted, rbac })
575
616
 
576
617
  try {
577
618
  const currentUpdatedAt = await readCustomEntityRecordUpdatedAt(em, {
@@ -53,7 +53,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
53
53
  const [label, setLabel] = useState('')
54
54
  const [entitySource, setEntitySource] = useState<EntitySource>('custom')
55
55
  const [entityFormLoading, setEntityFormLoading] = useState(true)
56
- const [entityInitial, setEntityInitial] = useState<{ label?: string; description?: string; labelField?: string; defaultEditor?: string; showInSidebar?: boolean; updatedAt?: string }>({})
56
+ const [entityInitial, setEntityInitial] = useState<{ label?: string; description?: string; labelField?: string; defaultEditor?: string; showInSidebar?: boolean; accessRestricted?: boolean; updatedAt?: string }>({})
57
57
  const [defs, setDefs] = useState<Def[]>([])
58
58
  const [defsVersion, setDefsVersion] = useState<string | null>(null)
59
59
  const [orderDirty, setOrderDirty] = useState(false)
@@ -180,6 +180,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
180
180
  const defaultEditorValue =
181
181
  typeof record?.defaultEditor === 'string' ? record.defaultEditor : ''
182
182
  const showInSidebarValue = record?.showInSidebar === true
183
+ const accessRestrictedValue = record?.accessRestricted === true
183
184
  const updatedAtValue =
184
185
  typeof record?.updatedAt === 'string' && record.updatedAt.length > 0 ? record.updatedAt : undefined
185
186
  setLabel(labelValue)
@@ -190,6 +191,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
190
191
  labelField: labelFieldValue,
191
192
  defaultEditor: defaultEditorValue,
192
193
  showInSidebar: showInSidebarValue,
194
+ accessRestricted: accessRestrictedValue,
193
195
  updatedAt: updatedAtValue,
194
196
  })
195
197
  setEntityFormLoading(false)
@@ -419,6 +421,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
419
421
  defaultEditor: z.union([z.enum(['markdown','simpleMarkdown','htmlRichText']).optional(), z.literal('')]).optional(),
420
422
  // Include showInSidebar so CrudForm doesn't strip it on submit
421
423
  showInSidebar: z.boolean().optional(),
424
+ accessRestricted: z.boolean().optional(),
422
425
  }) as z.ZodType<Record<string, unknown>>
423
426
 
424
427
  const metadataFieldsReadOnly = entitySource === 'code'
@@ -438,6 +441,12 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
438
441
  ],
439
442
  } as any,
440
443
  ...(entitySource === 'custom' ? [{ id: 'showInSidebar', label: t('entities.userEntities.form.showInSidebar.label', 'Show in sidebar'), type: 'checkbox' }] : []),
444
+ ...(entitySource === 'custom' ? [{
445
+ id: 'accessRestricted',
446
+ label: t('entities.userEntities.form.accessRestricted.label', 'Restrict record access'),
447
+ type: 'checkbox',
448
+ description: t('entities.userEntities.form.accessRestricted.help', 'Require an explicit per-entity permission to view or manage this entity’s records. Leave off to allow anyone with the general records permission.'),
449
+ }] : []),
441
450
  ]
442
451
  const renderFieldDefinitions = React.useCallback(() => (
443
452
  <FieldDefinitionsEditor
@@ -496,7 +505,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
496
505
  const definitionsGroup: CrudFormGroup = { id: 'definitions', title: t('entities.userEntities.edit.groups.definitions', 'Field Definitions'), column: 1, component: renderFieldDefinitions }
497
506
 
498
507
  const groups: CrudFormGroup[] = [
499
- { id: 'settings', title: t('entities.userEntities.edit.groups.settings', 'Entity Settings'), column: 1, fields: entitySource === 'custom' ? ['label','description','defaultEditor','showInSidebar'] : ['label','description','defaultEditor'] },
508
+ { id: 'settings', title: t('entities.userEntities.edit.groups.settings', 'Entity Settings'), column: 1, fields: entitySource === 'custom' ? ['label','description','defaultEditor','showInSidebar','accessRestricted'] : ['label','description','defaultEditor'] },
500
509
  definitionsGroup,
501
510
  ]
502
511
 
@@ -673,7 +682,7 @@ export function buildEntityMetadataPayload(
673
682
  const partial = entitySource === 'custom'
674
683
  ? upsertCustomEntitySchema
675
684
  .pick({ label: true, description: true, labelField: true as any, defaultEditor: true as any })
676
- .extend({ showInSidebar: z.boolean().optional() }) as unknown as z.ZodTypeAny
685
+ .extend({ showInSidebar: z.boolean().optional(), accessRestricted: z.boolean().optional() }) as unknown as z.ZodTypeAny
677
686
  : upsertCustomEntitySchema
678
687
  .pick({ label: true, description: true, defaultEditor: true as any }) as unknown as z.ZodTypeAny
679
688
  const normalized = {
@@ -9,6 +9,7 @@ import { upsertCustomEntitySchema } from '@open-mercato/core/modules/entities/da
9
9
  import { useRouter } from 'next/navigation'
10
10
  import { pushWithFlash } from '@open-mercato/ui/backend/utils/flash'
11
11
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { LoadingMessage } from '@open-mercato/ui/backend/detail'
12
13
 
13
14
  const schema = upsertCustomEntitySchema
14
15
 
@@ -17,6 +18,21 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
17
18
  export default function CreateEntityPage() {
18
19
  const t = useT()
19
20
  const router = useRouter()
21
+ // Pre-fill the restriction toggle from the tenant default-restricted policy so
22
+ // the form reflects the workspace posture; null while loading.
23
+ const [defaultRestricted, setDefaultRestricted] = React.useState<boolean | null>(null)
24
+ React.useEffect(() => {
25
+ let active = true
26
+ ;(async () => {
27
+ const data = await readApiResultOrThrow<{ newEntitiesRestrictedByDefault?: boolean }>(
28
+ '/api/entities/entity-settings',
29
+ undefined,
30
+ { errorMessage: '[internal] Failed to load entity settings', fallback: { newEntitiesRestrictedByDefault: false } },
31
+ ).catch(() => ({ newEntitiesRestrictedByDefault: false }))
32
+ if (active) setDefaultRestricted(data?.newEntitiesRestrictedByDefault === true)
33
+ })()
34
+ return () => { active = false }
35
+ }, [])
20
36
  const fields = React.useMemo<CrudField[]>(() => ([
21
37
  {
22
38
  id: 'entityId',
@@ -39,8 +55,24 @@ export default function CreateEntityPage() {
39
55
  ],
40
56
  } as unknown as CrudField,
41
57
  { id: 'showInSidebar', label: t('entities.userEntities.form.showInSidebar.label', 'Show in sidebar'), type: 'checkbox' } as CrudField,
58
+ {
59
+ id: 'accessRestricted',
60
+ label: t('entities.userEntities.form.accessRestricted.label', 'Restrict record access'),
61
+ type: 'checkbox',
62
+ description: t('entities.userEntities.form.accessRestricted.help', 'Require an explicit per-entity permission to view or manage this entity’s records. Leave off to allow anyone with the general records permission.'),
63
+ } as CrudField,
42
64
  ]), [t])
43
65
 
66
+ if (defaultRestricted === null) {
67
+ return (
68
+ <Page>
69
+ <PageBody>
70
+ <LoadingMessage label={t('entities.userEntities.form.loading', 'Loading…')} />
71
+ </PageBody>
72
+ </Page>
73
+ )
74
+ }
75
+
44
76
  return (
45
77
  <Page>
46
78
  <PageBody>
@@ -49,7 +81,7 @@ export default function CreateEntityPage() {
49
81
  backHref="/backend/entities/user"
50
82
  schema={schema}
51
83
  fields={fields}
52
- initialValues={{ entityId: 'user:your_entity', label: '', showInSidebar: false }}
84
+ initialValues={{ entityId: 'user:your_entity', label: '', showInSidebar: false, accessRestricted: defaultRestricted }}
53
85
  submitLabel={t('entities.userEntities.form.submit', 'Create')}
54
86
  cancelHref="/backend/entities/user"
55
87
  onSubmit={async (vals) => {
@@ -136,6 +136,12 @@ export class CustomEntity {
136
136
  @Property({ name: 'show_in_sidebar', type: 'boolean', default: false })
137
137
  showInSidebar: boolean = false
138
138
 
139
+ // When true, records require an explicit per-entity ACL grant
140
+ // (entities.records.<entity_id>.view/.manage) beyond the coarse
141
+ // entities.records.* feature. Defaults to unrestricted for backward compat.
142
+ @Property({ name: 'access_restricted', type: 'boolean', default: false })
143
+ accessRestricted: boolean = false
144
+
139
145
  // Note: Per-field UI preferences (list visibility, filter visibility, form editability)
140
146
  // are stored in CustomFieldDef.configJson, not at entity level.
141
147
 
@@ -221,6 +221,8 @@
221
221
  "entities.userEntities.errors.entityIdRequired": "Entitäts-ID ist erforderlich",
222
222
  "entities.userEntities.errors.loadFailed": "Entitäten konnten nicht geladen werden",
223
223
  "entities.userEntities.flash.created": "Entität erstellt",
224
+ "entities.userEntities.form.accessRestricted.help": "Erfordert eine explizite entitätsspezifische Berechtigung zum Anzeigen oder Verwalten der Datensätze dieser Entität. Deaktiviert lassen, um allen Benutzern mit der allgemeinen Datensatzberechtigung Zugriff zu gewähren.",
225
+ "entities.userEntities.form.accessRestricted.label": "Datensatzzugriff einschränken",
224
226
  "entities.userEntities.form.defaultEditor.label": "Standardeditor (mehrzeilig)",
225
227
  "entities.userEntities.form.defaultEditor.options.default": "Standard (Markdown)",
226
228
  "entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML-Rich-Text",
@@ -230,6 +232,7 @@
230
232
  "entities.userEntities.form.entityId.label": "Entitäts-ID",
231
233
  "entities.userEntities.form.entityId.placeholder": "module_name:entity_id",
232
234
  "entities.userEntities.form.label.label": "Bezeichnung",
235
+ "entities.userEntities.form.loading": "Wird geladen…",
233
236
  "entities.userEntities.form.showInSidebar.label": "In der Seitenleiste anzeigen",
234
237
  "entities.userEntities.form.submit": "Erstellen",
235
238
  "entities.userEntities.form.title": "Entität erstellen",
@@ -221,6 +221,8 @@
221
221
  "entities.userEntities.errors.entityIdRequired": "Entity ID is required",
222
222
  "entities.userEntities.errors.loadFailed": "Failed to load entities",
223
223
  "entities.userEntities.flash.created": "Entity created",
224
+ "entities.userEntities.form.accessRestricted.help": "Require an explicit per-entity permission to view or manage this entity’s records. Leave off to allow anyone with the general records permission.",
225
+ "entities.userEntities.form.accessRestricted.label": "Restrict record access",
224
226
  "entities.userEntities.form.defaultEditor.label": "Default Editor (multiline)",
225
227
  "entities.userEntities.form.defaultEditor.options.default": "Default (Markdown)",
226
228
  "entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML Rich Text",
@@ -230,6 +232,7 @@
230
232
  "entities.userEntities.form.entityId.label": "Entity ID",
231
233
  "entities.userEntities.form.entityId.placeholder": "module_name:entity_id",
232
234
  "entities.userEntities.form.label.label": "Label",
235
+ "entities.userEntities.form.loading": "Loading…",
233
236
  "entities.userEntities.form.showInSidebar.label": "Show in sidebar",
234
237
  "entities.userEntities.form.submit": "Create",
235
238
  "entities.userEntities.form.title": "Create Entity",
@@ -221,6 +221,8 @@
221
221
  "entities.userEntities.errors.entityIdRequired": "El ID de la entidad es obligatorio",
222
222
  "entities.userEntities.errors.loadFailed": "No se pudieron cargar las entidades",
223
223
  "entities.userEntities.flash.created": "Entidad creada",
224
+ "entities.userEntities.form.accessRestricted.help": "Requiere un permiso explícito por entidad para ver o gestionar los registros de esta entidad. Déjalo desactivado para permitir el acceso a cualquiera con el permiso general de registros.",
225
+ "entities.userEntities.form.accessRestricted.label": "Restringir acceso a registros",
224
226
  "entities.userEntities.form.defaultEditor.label": "Editor predeterminado (multilínea)",
225
227
  "entities.userEntities.form.defaultEditor.options.default": "Predeterminado (Markdown)",
226
228
  "entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML enriquecido",
@@ -230,6 +232,7 @@
230
232
  "entities.userEntities.form.entityId.label": "ID de la entidad",
231
233
  "entities.userEntities.form.entityId.placeholder": "nombre_modulo:id_entidad",
232
234
  "entities.userEntities.form.label.label": "Etiqueta",
235
+ "entities.userEntities.form.loading": "Cargando…",
233
236
  "entities.userEntities.form.showInSidebar.label": "Mostrar en la barra lateral",
234
237
  "entities.userEntities.form.submit": "Crear",
235
238
  "entities.userEntities.form.title": "Crear entidad",
@@ -221,6 +221,8 @@
221
221
  "entities.userEntities.errors.entityIdRequired": "Identyfikator encji jest wymagany",
222
222
  "entities.userEntities.errors.loadFailed": "Nie udało się wczytać encji",
223
223
  "entities.userEntities.flash.created": "Encja została utworzona",
224
+ "entities.userEntities.form.accessRestricted.help": "Wymaga jawnego uprawnienia dla tej encji, aby przeglądać lub zarządzać jej rekordami. Pozostaw wyłączone, aby zezwolić na dostęp każdemu z ogólnym uprawnieniem do rekordów.",
225
+ "entities.userEntities.form.accessRestricted.label": "Ogranicz dostęp do rekordów",
224
226
  "entities.userEntities.form.defaultEditor.label": "Domyślny edytor (wiele linii)",
225
227
  "entities.userEntities.form.defaultEditor.options.default": "Domyślne (Markdown)",
226
228
  "entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML Rich Text",
@@ -230,6 +232,7 @@
230
232
  "entities.userEntities.form.entityId.label": "Identyfikator encji",
231
233
  "entities.userEntities.form.entityId.placeholder": "module_name:entity_id",
232
234
  "entities.userEntities.form.label.label": "Etykieta",
235
+ "entities.userEntities.form.loading": "Ładowanie…",
233
236
  "entities.userEntities.form.showInSidebar.label": "Pokaż w panelu bocznym",
234
237
  "entities.userEntities.form.submit": "Utwórz",
235
238
  "entities.userEntities.form.title": "Utwórz encję",