@open-mercato/search 0.6.8-develop.7049.1.b41427429a → 0.6.8-develop.7051.1.2e20648fd5

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.
@@ -1,44 +1,2 @@
1
- import { authorizeFeatures } from "@open-mercato/shared/security/featurePolicy";
2
- function canReadSearchEntity(entityId, lookup, subject, options = {}) {
3
- if (subject.isSuperAdmin) return true;
4
- const config = lookup.getEntityConfig(entityId);
5
- if (!config) {
6
- options.onDeny?.(entityId, "unconfigured");
7
- return false;
8
- }
9
- const required = config.aclFeatures;
10
- if (!required || required.length === 0) {
11
- options.onDeny?.(entityId, "no-acl-features");
12
- return false;
13
- }
14
- const allowed = authorizeFeatures(required, {
15
- grantedFeatures: subject.grantedFeatures,
16
- unrestricted: false
17
- });
18
- if (!allowed) options.onDeny?.(entityId, "insufficient-features");
19
- return allowed;
20
- }
21
- function resolveReadableEntityTypes(lookup, subject, requestedEntityTypes) {
22
- if (subject.isSuperAdmin) return requestedEntityTypes;
23
- const readable = lookup.getAllEntityConfigs().filter((config) => config.enabled !== false).map((config) => config.entityId).filter((entityId) => canReadSearchEntity(entityId, lookup, subject));
24
- if (!requestedEntityTypes) return readable;
25
- const requested = new Set(requestedEntityTypes);
26
- return readable.filter((entityId) => requested.has(entityId));
27
- }
28
- function filterSearchResultsByEntityAccess(results, lookup, subject, options = {}) {
29
- if (subject.isSuperAdmin) return [...results];
30
- const decisions = /* @__PURE__ */ new Map();
31
- return results.filter((result) => {
32
- const cached = decisions.get(result.entityId);
33
- if (cached !== void 0) return cached;
34
- const allowed = canReadSearchEntity(result.entityId, lookup, subject, options);
35
- decisions.set(result.entityId, allowed);
36
- return allowed;
37
- });
38
- }
39
- export {
40
- canReadSearchEntity,
41
- filterSearchResultsByEntityAccess,
42
- resolveReadableEntityTypes
43
- };
1
+ export * from "@open-mercato/shared/lib/search/entityAccess";
44
2
  //# sourceMappingURL=entity-access.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/lib/entity-access.ts"],
4
- "sourcesContent": ["import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'\nimport { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'\n\n/**\n * Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL\n * resolution. Kept structural so callers and tests can pass a plain object\n * instead of constructing a full `SearchIndexer`.\n */\nexport type SearchEntityConfigLookup = {\n getEntityConfig: (entityId: string) => SearchEntityConfig | undefined\n getAllEntityConfigs: () => SearchEntityConfig[]\n}\n\nexport type SearchEntityAccessSubject = {\n grantedFeatures: readonly string[]\n isSuperAdmin?: boolean\n}\n\nexport type SearchEntityDenyReason =\n /** No module declares this entity in a `search.ts` config. */\n | 'unconfigured'\n /** The entity is configured for search but declares no `aclFeatures`. */\n | 'no-acl-features'\n /** The caller does not hold the entity's declared view feature(s). */\n | 'insufficient-features'\n\nexport type SearchEntityAccessOptions = {\n /**\n * Called once per denied entity type. Exists so a silent drop is diagnosable:\n * results disappearing because a module forgot to declare `aclFeatures` looks\n * identical, from the palette, to results that simply did not match.\n */\n onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void\n}\n\n/**\n * Decide whether a caller may see results for one entity type.\n *\n * The single `search.global` gate on the palette only says \"this user may use\n * global search\"; it says nothing about which records they may read. Each entity\n * declares the owning module's view feature(s) in `aclFeatures`, and those are\n * what actually authorize the read \u2014 the same rule the `search_get` /\n * `search_aggregate` AI tools already apply.\n *\n * Fails closed: an entity that is not registered for search, or that declares no\n * `aclFeatures`, is never exposed to a non-superadmin caller.\n */\nexport function canReadSearchEntity(\n entityId: string,\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n options: SearchEntityAccessOptions = {},\n): boolean {\n if (subject.isSuperAdmin) return true\n\n const config = lookup.getEntityConfig(entityId)\n if (!config) {\n options.onDeny?.(entityId, 'unconfigured')\n return false\n }\n\n const required = config.aclFeatures\n if (!required || required.length === 0) {\n options.onDeny?.(entityId, 'no-acl-features')\n return false\n }\n\n const allowed = authorizeFeatures(required, {\n grantedFeatures: subject.grantedFeatures,\n unrestricted: false,\n })\n if (!allowed) options.onDeny?.(entityId, 'insufficient-features')\n return allowed\n}\n\n/**\n * The entity types this caller may read, narrowed to `requestedEntityTypes` when\n * the caller asked for specific ones.\n *\n * Restricting the query up front is what keeps `limit` meaningful. Filtering only\n * after the search would spend the whole result budget on records the caller\n * cannot see: an employee granted just `customers.people.view` would get the top\n * 50 hits across every entity type, then watch most of them be dropped, and the\n * palette would look empty even with hundreds of matching people behind it.\n *\n * Returns `undefined` when no restriction applies (superadmin with no explicit\n * request), and an empty array when nothing is readable \u2014 callers should\n * short-circuit on that rather than pass it down as \"no filter\".\n */\nexport function resolveReadableEntityTypes(\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n requestedEntityTypes?: string[],\n): string[] | undefined {\n if (subject.isSuperAdmin) return requestedEntityTypes\n\n const readable = lookup\n .getAllEntityConfigs()\n .filter((config) => config.enabled !== false)\n .map((config) => config.entityId)\n .filter((entityId) => canReadSearchEntity(entityId, lookup, subject))\n\n if (!requestedEntityTypes) return readable\n const requested = new Set(requestedEntityTypes)\n return readable.filter((entityId) => requested.has(entityId))\n}\n\n/**\n * Drop the results whose entity type the caller is not allowed to read.\n *\n * Filtering happens server-side so an under-privileged caller never receives the\n * presenter title, subtitle or deep link of a record they cannot open. Decisions\n * are memoized per entity type because a single response commonly mixes dozens of\n * results across a handful of types.\n */\nexport function filterSearchResultsByEntityAccess<T extends { entityId: string }>(\n results: readonly T[],\n lookup: SearchEntityConfigLookup,\n subject: SearchEntityAccessSubject,\n options: SearchEntityAccessOptions = {},\n): T[] {\n if (subject.isSuperAdmin) return [...results]\n\n const decisions = new Map<string, boolean>()\n return results.filter((result) => {\n const cached = decisions.get(result.entityId)\n if (cached !== undefined) return cached\n const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)\n decisions.set(result.entityId, allowed)\n return allowed\n })\n}\n"],
5
- "mappings": "AACA,SAAS,yBAAyB;AA8C3B,SAAS,oBACd,UACA,QACA,SACA,UAAqC,CAAC,GAC7B;AACT,MAAI,QAAQ,aAAc,QAAO;AAEjC,QAAM,SAAS,OAAO,gBAAgB,QAAQ;AAC9C,MAAI,CAAC,QAAQ;AACX,YAAQ,SAAS,UAAU,cAAc;AACzC,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,OAAO;AACxB,MAAI,CAAC,YAAY,SAAS,WAAW,GAAG;AACtC,YAAQ,SAAS,UAAU,iBAAiB;AAC5C,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,kBAAkB,UAAU;AAAA,IAC1C,iBAAiB,QAAQ;AAAA,IACzB,cAAc;AAAA,EAChB,CAAC;AACD,MAAI,CAAC,QAAS,SAAQ,SAAS,UAAU,uBAAuB;AAChE,SAAO;AACT;AAgBO,SAAS,2BACd,QACA,SACA,sBACsB;AACtB,MAAI,QAAQ,aAAc,QAAO;AAEjC,QAAM,WAAW,OACd,oBAAoB,EACpB,OAAO,CAAC,WAAW,OAAO,YAAY,KAAK,EAC3C,IAAI,CAAC,WAAW,OAAO,QAAQ,EAC/B,OAAO,CAAC,aAAa,oBAAoB,UAAU,QAAQ,OAAO,CAAC;AAEtE,MAAI,CAAC,qBAAsB,QAAO;AAClC,QAAM,YAAY,IAAI,IAAI,oBAAoB;AAC9C,SAAO,SAAS,OAAO,CAAC,aAAa,UAAU,IAAI,QAAQ,CAAC;AAC9D;AAUO,SAAS,kCACd,SACA,QACA,SACA,UAAqC,CAAC,GACjC;AACL,MAAI,QAAQ,aAAc,QAAO,CAAC,GAAG,OAAO;AAE5C,QAAM,YAAY,oBAAI,IAAqB;AAC3C,SAAO,QAAQ,OAAO,CAAC,WAAW;AAChC,UAAM,SAAS,UAAU,IAAI,OAAO,QAAQ;AAC5C,QAAI,WAAW,OAAW,QAAO;AACjC,UAAM,UAAU,oBAAoB,OAAO,UAAU,QAAQ,SAAS,OAAO;AAC7E,cAAU,IAAI,OAAO,UAAU,OAAO;AACtC,WAAO;AAAA,EACT,CAAC;AACH;",
4
+ "sourcesContent": ["/**\n * Re-export shared entity-access helpers so existing imports keep working.\n * The canonical implementation lives in @open-mercato/shared to be reusable\n * from ai-assistant without introducing a search\u2194ai-assistant dependency cycle.\n */\nexport * from '@open-mercato/shared/lib/search/entityAccess'\n"],
5
+ "mappings": "AAKA,cAAc;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.8-develop.7049.1.b41427429a",
3
+ "version": "0.6.8-develop.7051.1.2e20648fd5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.6.8-develop.7049.1.b41427429a",
131
- "@open-mercato/queue": "0.6.8-develop.7049.1.b41427429a",
132
- "@open-mercato/shared": "0.6.8-develop.7049.1.b41427429a"
130
+ "@open-mercato/core": "0.6.8-develop.7051.1.2e20648fd5",
131
+ "@open-mercato/queue": "0.6.8-develop.7051.1.2e20648fd5",
132
+ "@open-mercato/shared": "0.6.8-develop.7051.1.2e20648fd5"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -1,132 +1,6 @@
1
- import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'
2
- import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
-
4
1
  /**
5
- * Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL
6
- * resolution. Kept structural so callers and tests can pass a plain object
7
- * instead of constructing a full `SearchIndexer`.
2
+ * Re-export shared entity-access helpers so existing imports keep working.
3
+ * The canonical implementation lives in @open-mercato/shared to be reusable
4
+ * from ai-assistant without introducing a search↔ai-assistant dependency cycle.
8
5
  */
9
- export type SearchEntityConfigLookup = {
10
- getEntityConfig: (entityId: string) => SearchEntityConfig | undefined
11
- getAllEntityConfigs: () => SearchEntityConfig[]
12
- }
13
-
14
- export type SearchEntityAccessSubject = {
15
- grantedFeatures: readonly string[]
16
- isSuperAdmin?: boolean
17
- }
18
-
19
- export type SearchEntityDenyReason =
20
- /** No module declares this entity in a `search.ts` config. */
21
- | 'unconfigured'
22
- /** The entity is configured for search but declares no `aclFeatures`. */
23
- | 'no-acl-features'
24
- /** The caller does not hold the entity's declared view feature(s). */
25
- | 'insufficient-features'
26
-
27
- export type SearchEntityAccessOptions = {
28
- /**
29
- * Called once per denied entity type. Exists so a silent drop is diagnosable:
30
- * results disappearing because a module forgot to declare `aclFeatures` looks
31
- * identical, from the palette, to results that simply did not match.
32
- */
33
- onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void
34
- }
35
-
36
- /**
37
- * Decide whether a caller may see results for one entity type.
38
- *
39
- * The single `search.global` gate on the palette only says "this user may use
40
- * global search"; it says nothing about which records they may read. Each entity
41
- * declares the owning module's view feature(s) in `aclFeatures`, and those are
42
- * what actually authorize the read — the same rule the `search_get` /
43
- * `search_aggregate` AI tools already apply.
44
- *
45
- * Fails closed: an entity that is not registered for search, or that declares no
46
- * `aclFeatures`, is never exposed to a non-superadmin caller.
47
- */
48
- export function canReadSearchEntity(
49
- entityId: string,
50
- lookup: SearchEntityConfigLookup,
51
- subject: SearchEntityAccessSubject,
52
- options: SearchEntityAccessOptions = {},
53
- ): boolean {
54
- if (subject.isSuperAdmin) return true
55
-
56
- const config = lookup.getEntityConfig(entityId)
57
- if (!config) {
58
- options.onDeny?.(entityId, 'unconfigured')
59
- return false
60
- }
61
-
62
- const required = config.aclFeatures
63
- if (!required || required.length === 0) {
64
- options.onDeny?.(entityId, 'no-acl-features')
65
- return false
66
- }
67
-
68
- const allowed = authorizeFeatures(required, {
69
- grantedFeatures: subject.grantedFeatures,
70
- unrestricted: false,
71
- })
72
- if (!allowed) options.onDeny?.(entityId, 'insufficient-features')
73
- return allowed
74
- }
75
-
76
- /**
77
- * The entity types this caller may read, narrowed to `requestedEntityTypes` when
78
- * the caller asked for specific ones.
79
- *
80
- * Restricting the query up front is what keeps `limit` meaningful. Filtering only
81
- * after the search would spend the whole result budget on records the caller
82
- * cannot see: an employee granted just `customers.people.view` would get the top
83
- * 50 hits across every entity type, then watch most of them be dropped, and the
84
- * palette would look empty even with hundreds of matching people behind it.
85
- *
86
- * Returns `undefined` when no restriction applies (superadmin with no explicit
87
- * request), and an empty array when nothing is readable — callers should
88
- * short-circuit on that rather than pass it down as "no filter".
89
- */
90
- export function resolveReadableEntityTypes(
91
- lookup: SearchEntityConfigLookup,
92
- subject: SearchEntityAccessSubject,
93
- requestedEntityTypes?: string[],
94
- ): string[] | undefined {
95
- if (subject.isSuperAdmin) return requestedEntityTypes
96
-
97
- const readable = lookup
98
- .getAllEntityConfigs()
99
- .filter((config) => config.enabled !== false)
100
- .map((config) => config.entityId)
101
- .filter((entityId) => canReadSearchEntity(entityId, lookup, subject))
102
-
103
- if (!requestedEntityTypes) return readable
104
- const requested = new Set(requestedEntityTypes)
105
- return readable.filter((entityId) => requested.has(entityId))
106
- }
107
-
108
- /**
109
- * Drop the results whose entity type the caller is not allowed to read.
110
- *
111
- * Filtering happens server-side so an under-privileged caller never receives the
112
- * presenter title, subtitle or deep link of a record they cannot open. Decisions
113
- * are memoized per entity type because a single response commonly mixes dozens of
114
- * results across a handful of types.
115
- */
116
- export function filterSearchResultsByEntityAccess<T extends { entityId: string }>(
117
- results: readonly T[],
118
- lookup: SearchEntityConfigLookup,
119
- subject: SearchEntityAccessSubject,
120
- options: SearchEntityAccessOptions = {},
121
- ): T[] {
122
- if (subject.isSuperAdmin) return [...results]
123
-
124
- const decisions = new Map<string, boolean>()
125
- return results.filter((result) => {
126
- const cached = decisions.get(result.entityId)
127
- if (cached !== undefined) return cached
128
- const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)
129
- decisions.set(result.entityId, allowed)
130
- return allowed
131
- })
132
- }
6
+ export * from '@open-mercato/shared/lib/search/entityAccess'