@open-mercato/shared 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.
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
1
|
+
[build:shared] found 265 entry points
|
|
2
2
|
[build:shared] built successfully
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { authorizeFeatures } from "../../security/featurePolicy.js";
|
|
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
|
+
};
|
|
44
|
+
//# sourceMappingURL=entityAccess.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/search/entityAccess.ts"],
|
|
4
|
+
"sourcesContent": ["import type { SearchEntityConfig } from '../../modules/search'\nimport { authorizeFeatures } from '../../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;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.7051.1.2e20648fd5';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7051.1.2e20648fd5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"@mikro-orm/core": "^7.1.8",
|
|
110
110
|
"@mikro-orm/decorators": "^7.1.8",
|
|
111
111
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
112
|
-
"@open-mercato/cache": "0.6.8-develop.
|
|
112
|
+
"@open-mercato/cache": "0.6.8-develop.7051.1.2e20648fd5",
|
|
113
113
|
"@types/html-to-text": "^9.0.4",
|
|
114
114
|
"@types/sanitize-html": "^2.16.1",
|
|
115
115
|
"dotenv": "^17.4.2",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { SearchEntityConfig } from '../../modules/search'
|
|
2
|
+
import { authorizeFeatures } from '../../security/featurePolicy'
|
|
3
|
+
|
|
4
|
+
/**
|
|
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`.
|
|
8
|
+
*/
|
|
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
|
+
}
|