@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.
@@ -1,2 +1,2 @@
1
- [build:shared] found 251 entry points
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: `// Build-time generated version
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
+ }
@@ -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 searchEnabled = !skipAutoScope && searchConfig.enabled && await this.tableExists("search_tokens");
209
- const hasSearchTokens = searchEnabled ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
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.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope) : false;
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
- let searchAvailable = joinSearchAvailability.get(join.entityId);
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", {