@open-mercato/shared 0.6.7-develop.6785.1.1dd7cfac55 → 0.6.7-develop.6795.1.8a3f27921c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/build.mjs +2 -4
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/query/engine.js +25 -42
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/jest.config.cjs +2 -1
- package/package.json +9 -8
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/engine.ts +36 -60
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/availability.ts +232 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
1
|
+
[build:shared] found 253 entry points
|
|
2
2
|
[build:shared] built successfully
|
package/AGENTS.md
CHANGED
|
@@ -34,6 +34,7 @@ yarn workspace @open-mercato/shared build
|
|
|
34
34
|
|-----------|-------------|-------------|
|
|
35
35
|
| `api/` | When building scoped API payloads | `@open-mercato/shared/lib/api/scoped` |
|
|
36
36
|
| `auth/` | When you need wildcard-aware feature matching or shared auth helpers | `@open-mercato/shared/lib/auth/featureMatch` |
|
|
37
|
+
| `auth/organizationScope` | When an organization-scoped API route must resolve the caller's organization — falls back to `actorOrgId` for an "all organizations" selection, but only while the effective tenant is still the actor's tenant. On `null` for an authenticated caller answer with `organizationScopeRequiredResponse()` (400, code `organization_scope_required`) — never 401 | `@open-mercato/shared/lib/auth/organizationScope` — `resolveActiveOrganizationId(auth)`, `organizationScopeRequiredResponse()` |
|
|
37
38
|
| `boolean/` | When parsing boolean strings from env/query params | `@open-mercato/shared/lib/boolean` |
|
|
38
39
|
| `browser/` | When persisting client UI state to `localStorage` — use the safe wrappers and the versioned-envelope helper instead of raw `localStorage` reads/writes | `@open-mercato/shared/lib/browser/safeLocalStorage`, `@open-mercato/shared/lib/browser/versionedPreference` |
|
|
39
40
|
| `commands/` | When implementing undo/redo command pattern | `@open-mercato/shared/lib/commands` |
|
package/build.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { buildPackage } from '../../scripts/build-package.mjs'
|
|
5
|
+
import { buildVersionSource } from './scripts/versionSource.cjs'
|
|
5
6
|
|
|
6
7
|
const packageDir = dirname(fileURLToPath(import.meta.url))
|
|
7
8
|
const packageJson = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8'))
|
|
@@ -12,10 +13,7 @@ const injectVersion = {
|
|
|
12
13
|
name: 'inject-version',
|
|
13
14
|
setup(build) {
|
|
14
15
|
build.onLoad({ filter: /lib\/version\.ts$/ }, async () => ({
|
|
15
|
-
contents:
|
|
16
|
-
export const APP_VERSION = '${packageVersion}'
|
|
17
|
-
export const appVersion = APP_VERSION
|
|
18
|
-
`,
|
|
16
|
+
contents: buildVersionSource(packageVersion),
|
|
19
17
|
loader: 'ts',
|
|
20
18
|
}))
|
|
21
19
|
},
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function normalizeId(value) {
|
|
2
|
+
if (typeof value !== "string") return null;
|
|
3
|
+
const trimmed = value.trim();
|
|
4
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
5
|
+
}
|
|
6
|
+
function resolveActiveOrganizationId(auth) {
|
|
7
|
+
if (!auth) return null;
|
|
8
|
+
const selected = normalizeId(auth.orgId);
|
|
9
|
+
if (selected) return selected;
|
|
10
|
+
const actorOrgId = normalizeId(auth.actorOrgId);
|
|
11
|
+
if (!actorOrgId) return null;
|
|
12
|
+
if ("actorTenantId" in auth) {
|
|
13
|
+
const actorTenantId = normalizeId(auth.actorTenantId);
|
|
14
|
+
const effectiveTenantId = normalizeId(auth.tenantId);
|
|
15
|
+
if (!actorTenantId || actorTenantId !== effectiveTenantId) return null;
|
|
16
|
+
}
|
|
17
|
+
return actorOrgId;
|
|
18
|
+
}
|
|
19
|
+
const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = "organization_scope_required";
|
|
20
|
+
function organizationScopeRequiredResponse() {
|
|
21
|
+
return Response.json(
|
|
22
|
+
{
|
|
23
|
+
error: "Select an organization to access this resource",
|
|
24
|
+
code: ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE
|
|
25
|
+
},
|
|
26
|
+
{ status: 400 }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,
|
|
31
|
+
organizationScopeRequiredResponse,
|
|
32
|
+
resolveActiveOrganizationId
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=organizationScope.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/auth/organizationScope.ts"],
|
|
4
|
+
"sourcesContent": ["type OrganizationScopedAuth = {\n orgId?: string | null\n actorOrgId?: unknown\n tenantId?: string | null\n actorTenantId?: unknown\n} | null | undefined\n\nfunction normalizeId(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * Resolves the organization a request is scoped to when the caller may be viewing\n * \"all organizations\".\n *\n * Organization-scoped configuration modules (integrations credentials/state, data sync\n * mappings/schedules/runs) all require a non-null `organization_id`, so there is no\n * meaningful \"all organizations\" view of them. When an operator selects that option the\n * super-admin cookie override clears `auth.orgId` and preserves the actor's own\n * organization in `actorOrgId`; fall back to it so those modules keep showing the\n * operator's own configuration instead of failing.\n *\n * The fallback is only valid while the effective tenant is still the actor's own tenant.\n * When the super-admin cookie override also switched tenants (`actorTenantId` is present\n * and differs from `auth.tenantId`), the actor's organization belongs to another tenant \u2014\n * scoping to it would persist a cross-tenant `{ organizationId, tenantId }` pair. Return\n * `null` instead and let the route answer with `organizationScopeRequiredResponse()`.\n *\n * Answering 401 for an unresolvable scope is not merely wrong but self-perpetuating:\n * `apiFetch` reads 401 as an expired session and redirects through\n * `/api/auth/session/refresh`, which succeeds and returns to the same page, reloading\n * forever. That is why the missing-scope answer is a 400, never a 401.\n */\nexport function resolveActiveOrganizationId(auth: OrganizationScopedAuth): string | null {\n if (!auth) return null\n const selected = normalizeId(auth.orgId)\n if (selected) return selected\n const actorOrgId = normalizeId(auth.actorOrgId)\n if (!actorOrgId) return null\n if ('actorTenantId' in auth) {\n const actorTenantId = normalizeId(auth.actorTenantId)\n const effectiveTenantId = normalizeId(auth.tenantId)\n if (!actorTenantId || actorTenantId !== effectiveTenantId) return null\n }\n return actorOrgId\n}\n\nexport const ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE = 'organization_scope_required'\n\n/**\n * 400 response for an authenticated caller whose organization scope cannot be resolved\n * (e.g. a super-admin viewing a foreign tenant with \"all organizations\" selected).\n * Deliberately not a 401: the session is valid, so refreshing it would loop.\n */\nexport function organizationScopeRequiredResponse(): Response {\n return Response.json(\n {\n error: 'Select an organization to access this resource',\n code: ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE,\n },\n { status: 400 },\n )\n}\n"],
|
|
5
|
+
"mappings": "AAOA,SAAS,YAAY,OAA+B;AAClD,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAwBO,SAAS,4BAA4B,MAA6C;AACvF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,WAAW,YAAY,KAAK,KAAK;AACvC,MAAI,SAAU,QAAO;AACrB,QAAM,aAAa,YAAY,KAAK,UAAU;AAC9C,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,mBAAmB,MAAM;AAC3B,UAAM,gBAAgB,YAAY,KAAK,aAAa;AACpD,UAAM,oBAAoB,YAAY,KAAK,QAAQ;AACnD,QAAI,CAAC,iBAAiB,kBAAkB,kBAAmB,QAAO;AAAA,EACpE;AACA,SAAO;AACT;AAEO,MAAM,yCAAyC;AAO/C,SAAS,oCAA8C;AAC5D,SAAO,SAAS;AAAA,IACd;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,IACR;AAAA,IACA,EAAE,QAAQ,IAAI;AAAA,EAChB;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/lib/query/engine.js
CHANGED
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
resolveJoins
|
|
7
7
|
} from "./join-utils.js";
|
|
8
8
|
import { resolveSearchConfig } from "../search/config.js";
|
|
9
|
+
import {
|
|
10
|
+
createSearchTokenAvailability,
|
|
11
|
+
isSearchFilterOp
|
|
12
|
+
} from "../search/availability.js";
|
|
9
13
|
import { tokenizeText } from "../search/tokenize.js";
|
|
10
14
|
import { runBeforeQueryPipeline, runAfterQueryPipeline } from "./query-extension-runner.js";
|
|
11
15
|
import {
|
|
@@ -134,8 +138,8 @@ class BasicQueryEngine {
|
|
|
134
138
|
this.getDbFn = getDbFn;
|
|
135
139
|
this.resolveEncryptionService = resolveEncryptionService;
|
|
136
140
|
this.columnCache = /* @__PURE__ */ new Map();
|
|
137
|
-
this.tableCache = /* @__PURE__ */ new Map();
|
|
138
141
|
this.searchAliasSeq = 0;
|
|
142
|
+
this.searchAvailabilityInstance = null;
|
|
139
143
|
}
|
|
140
144
|
getEncryptionService() {
|
|
141
145
|
try {
|
|
@@ -150,6 +154,21 @@ class BasicQueryEngine {
|
|
|
150
154
|
if (typeof emAny?.getKysely === "function") return emAny.getKysely();
|
|
151
155
|
throw new Error("BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)");
|
|
152
156
|
}
|
|
157
|
+
searchAvailability() {
|
|
158
|
+
if (!this.searchAvailabilityInstance) {
|
|
159
|
+
this.searchAvailabilityInstance = createSearchTokenAvailability({
|
|
160
|
+
getDb: () => this.getDb(),
|
|
161
|
+
getConfig: resolveSearchConfig,
|
|
162
|
+
applyOrganizationScope: (query, column, scope) => this.applyOrganizationScope(
|
|
163
|
+
query,
|
|
164
|
+
column,
|
|
165
|
+
scope
|
|
166
|
+
),
|
|
167
|
+
logDebug: (event, payload) => this.logSearchDebug(event, payload)
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return this.searchAvailabilityInstance;
|
|
171
|
+
}
|
|
153
172
|
async query(entity, opts = {}) {
|
|
154
173
|
const ext = opts.extensions;
|
|
155
174
|
let effectiveOpts = opts;
|
|
@@ -205,11 +224,10 @@ class BasicQueryEngine {
|
|
|
205
224
|
const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap);
|
|
206
225
|
const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith("cf:"));
|
|
207
226
|
const searchConfig = resolveSearchConfig();
|
|
208
|
-
const
|
|
209
|
-
const
|
|
227
|
+
const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op));
|
|
228
|
+
const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled();
|
|
229
|
+
const hasSearchTokens = searchEnabled && searchFilters.length ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
|
|
210
230
|
const searchActive = searchEnabled && hasSearchTokens;
|
|
211
|
-
const joinSearchAvailability = /* @__PURE__ */ new Map();
|
|
212
|
-
const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === "like" || filter.op === "ilike");
|
|
213
231
|
if (searchFilters.length) {
|
|
214
232
|
const fields = searchFilters.map((filter) => String(filter.field));
|
|
215
233
|
this.logSearchDebug("search:init", {
|
|
@@ -258,8 +276,7 @@ class BasicQueryEngine {
|
|
|
258
276
|
if (!filters.length) continue;
|
|
259
277
|
const join = joinMap.get(alias);
|
|
260
278
|
if (!join?.entityId) continue;
|
|
261
|
-
const hasJoinedTokens = searchEnabled ? await this.
|
|
262
|
-
joinSearchAvailability.set(join.entityId, hasJoinedTokens);
|
|
279
|
+
const hasJoinedTokens = searchEnabled ? await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope) : false;
|
|
263
280
|
const fallbackFields = filters.filter((filter) => !hasJoinedTokens || typeof filter.value !== "string" || tokenizeText(filter.value, searchConfig).hashes.length === 0).map((filter) => filter.column);
|
|
264
281
|
if (!fallbackFields.length) continue;
|
|
265
282
|
await warnOnCiphertextLikeFallback({
|
|
@@ -309,11 +326,7 @@ class BasicQueryEngine {
|
|
|
309
326
|
if (!searchEnabled || !join.entityId) return { applied: false, builder };
|
|
310
327
|
if (!["like", "ilike"].includes(filter.op)) return { applied: false, builder };
|
|
311
328
|
if (typeof filter.value !== "string" || filter.value.trim().length === 0) return { applied: false, builder };
|
|
312
|
-
|
|
313
|
-
if (searchAvailable === void 0) {
|
|
314
|
-
searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope);
|
|
315
|
-
joinSearchAvailability.set(join.entityId, searchAvailable);
|
|
316
|
-
}
|
|
329
|
+
const searchAvailable = await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope);
|
|
317
330
|
if (!searchAvailable) return { applied: false, builder };
|
|
318
331
|
const tokens = tokenizeText(String(filter.value), searchConfig);
|
|
319
332
|
if (!tokens.hashes.length) return { applied: false, builder };
|
|
@@ -898,36 +911,6 @@ class BasicQueryEngine {
|
|
|
898
911
|
else this.columnCache.delete(key);
|
|
899
912
|
return present;
|
|
900
913
|
}
|
|
901
|
-
async tableExists(table) {
|
|
902
|
-
if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false;
|
|
903
|
-
const db = this.getDb();
|
|
904
|
-
const exists = await db.selectFrom("information_schema.tables").select(sql`1`.as("one")).where("table_name", "=", table).limit(1).executeTakeFirst();
|
|
905
|
-
const present = !!exists;
|
|
906
|
-
this.tableCache.set(table, present);
|
|
907
|
-
return present;
|
|
908
|
-
}
|
|
909
|
-
async hasSearchTokens(entity, tenantId, orgScope) {
|
|
910
|
-
try {
|
|
911
|
-
const db = this.getDb();
|
|
912
|
-
let query = db.selectFrom("search_tokens").select(sql`1`.as("one")).where("entity_type", "=", entity).limit(1);
|
|
913
|
-
if (tenantId !== void 0) {
|
|
914
|
-
query = query.where(sql`tenant_id is not distinct from ${tenantId}`);
|
|
915
|
-
}
|
|
916
|
-
if (orgScope) {
|
|
917
|
-
query = this.applyOrganizationScope(query, "search_tokens.organization_id", orgScope);
|
|
918
|
-
}
|
|
919
|
-
const row = await query.executeTakeFirst();
|
|
920
|
-
return !!row;
|
|
921
|
-
} catch (err) {
|
|
922
|
-
this.logSearchDebug("search:has-tokens-error", {
|
|
923
|
-
entity,
|
|
924
|
-
tenantId,
|
|
925
|
-
organizationScope: orgScope,
|
|
926
|
-
error: err instanceof Error ? err.message : String(err)
|
|
927
|
-
});
|
|
928
|
-
return false;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
914
|
applySearchTokens(q, opts) {
|
|
932
915
|
if (!opts.hashes.length) {
|
|
933
916
|
this.logSearchDebug("search:skip-no-hashes", {
|