@open-mercato/shared 0.6.7-develop.6788.1.c2a1520a30 → 0.6.7-develop.6814.1.0627c7e9f1
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/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/di/container.js +33 -3
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/query/engine.js +25 -42
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +24 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/widgets/extension-points.js.map +1 -1
- package/package.json +8 -8
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +139 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/container.ts +45 -4
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -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/query/types.ts +4 -5
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +59 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +28 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/index.ts +7 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +15 -0
- package/src/modules/widgets/extension-points.ts +104 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
|
|
3
|
+
function isSearchFilterOp(op) {
|
|
4
|
+
return op === "like" || op === "ilike";
|
|
5
|
+
}
|
|
6
|
+
function hasSearchFilter(filters) {
|
|
7
|
+
return filters.some((filter) => isSearchFilterOp(filter.op));
|
|
8
|
+
}
|
|
9
|
+
function orgScopeKey(scope) {
|
|
10
|
+
if (!scope) return "none";
|
|
11
|
+
return `${scope.includeNull ? "1" : "0"}:${[...scope.ids].sort((left, right) => left.localeCompare(right)).join(",")}`;
|
|
12
|
+
}
|
|
13
|
+
const PRESENCE_CACHE_DEFAULT_TTL_MS = 3e4;
|
|
14
|
+
const PRESENCE_CACHE_MAX_ENTRIES = 1e4;
|
|
15
|
+
const presenceCache = /* @__PURE__ */ new Map();
|
|
16
|
+
function resolvePresenceCacheTtlMs() {
|
|
17
|
+
return parseNumberWithDefault(process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS, PRESENCE_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 });
|
|
18
|
+
}
|
|
19
|
+
function storePresence(key, value, ttlMs) {
|
|
20
|
+
if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
for (const [entryKey, entry] of presenceCache) {
|
|
23
|
+
if (entry.expiresAt <= now) presenceCache.delete(entryKey);
|
|
24
|
+
}
|
|
25
|
+
if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) presenceCache.clear();
|
|
26
|
+
}
|
|
27
|
+
presenceCache.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
28
|
+
}
|
|
29
|
+
function clearSearchTokenPresenceCache() {
|
|
30
|
+
presenceCache.clear();
|
|
31
|
+
}
|
|
32
|
+
function createSearchTokenAvailability(deps) {
|
|
33
|
+
const tablePresence = /* @__PURE__ */ new Map();
|
|
34
|
+
const tokenPresence = /* @__PURE__ */ new Map();
|
|
35
|
+
const tableExists = (table) => {
|
|
36
|
+
const cached = tablePresence.get(table);
|
|
37
|
+
if (cached) return cached;
|
|
38
|
+
const probe = (async () => {
|
|
39
|
+
const row = await deps.getDb().selectFrom("information_schema.tables").select(sql`1`.as("one")).where("table_name", "=", table).limit(1).executeTakeFirst();
|
|
40
|
+
return !!row;
|
|
41
|
+
})();
|
|
42
|
+
tablePresence.set(table, probe);
|
|
43
|
+
probe.catch(() => tablePresence.delete(table));
|
|
44
|
+
return probe;
|
|
45
|
+
};
|
|
46
|
+
const probeTokens = async (entity, tenantId, orgScope) => {
|
|
47
|
+
let query = deps.getDb().selectFrom("search_tokens").select(sql`1`.as("one")).where("entity_type", "=", entity);
|
|
48
|
+
query = tenantId == null ? query.where("tenant_id", "is", null) : query.where("tenant_id", "=", tenantId);
|
|
49
|
+
if (orgScope) {
|
|
50
|
+
query = deps.applyOrganizationScope(query, "search_tokens.organization_id", orgScope);
|
|
51
|
+
}
|
|
52
|
+
const row = await query.limit(1).executeTakeFirst();
|
|
53
|
+
return !!row;
|
|
54
|
+
};
|
|
55
|
+
const hasTokens = (entity, tenantId, orgScope) => {
|
|
56
|
+
const key = `${entity}|${tenantId ?? "__null__"}|${orgScopeKey(orgScope)}`;
|
|
57
|
+
const ttlMs = resolvePresenceCacheTtlMs();
|
|
58
|
+
if (ttlMs > 0) {
|
|
59
|
+
const entry = presenceCache.get(key);
|
|
60
|
+
if (entry && entry.expiresAt > Date.now()) return Promise.resolve(entry.value);
|
|
61
|
+
}
|
|
62
|
+
const cached = tokenPresence.get(key);
|
|
63
|
+
if (cached) return cached;
|
|
64
|
+
const probe = (async () => {
|
|
65
|
+
try {
|
|
66
|
+
const value = await probeTokens(entity, tenantId, orgScope);
|
|
67
|
+
if (ttlMs > 0) storePresence(key, value, ttlMs);
|
|
68
|
+
return value;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
deps.logDebug("search:has-tokens-error", {
|
|
71
|
+
entity,
|
|
72
|
+
tenantId,
|
|
73
|
+
organizationScope: orgScope,
|
|
74
|
+
error: err instanceof Error ? err.message : String(err)
|
|
75
|
+
});
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
tokenPresence.set(key, probe);
|
|
80
|
+
return probe;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
staticEnabled: async () => deps.getConfig().enabled && await tableExists("search_tokens"),
|
|
84
|
+
hasTokens,
|
|
85
|
+
anySourceHasTokens: async (sources, tenantId, orgScope) => {
|
|
86
|
+
for (const source of sources) {
|
|
87
|
+
const ok = await hasTokens(source.entity, tenantId, orgScope);
|
|
88
|
+
deps.logDebug("search:source-has-tokens", {
|
|
89
|
+
entity: source.entity,
|
|
90
|
+
recordIdColumn: source.recordIdColumn,
|
|
91
|
+
tenantId,
|
|
92
|
+
organizationScope: orgScope,
|
|
93
|
+
hasTokens: ok
|
|
94
|
+
});
|
|
95
|
+
if (ok) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export {
|
|
102
|
+
clearSearchTokenPresenceCache,
|
|
103
|
+
createSearchTokenAvailability,
|
|
104
|
+
hasSearchFilter,
|
|
105
|
+
isSearchFilterOp
|
|
106
|
+
};
|
|
107
|
+
//# sourceMappingURL=availability.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/search/availability.ts"],
|
|
4
|
+
"sourcesContent": ["import { sql } from 'kysely'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\n\nexport type OrganizationScope = { ids: string[]; includeNull: boolean }\n\nexport type SearchTokenSourceRef = { entity: string; recordIdColumn?: string }\n\ntype ProbeExpression = object | string | readonly string[] | null\n\nexport type SearchTokenProbeQueryBuilder = {\n select: (selection: ProbeExpression) => SearchTokenProbeQueryBuilder\n where: (column: ProbeExpression, operator?: string, value?: ProbeExpression) => SearchTokenProbeQueryBuilder\n limit: (count: number) => SearchTokenProbeQueryBuilder\n executeTakeFirst: () => Promise<object | undefined>\n}\n\nexport type SearchTokenProbeDb = { selectFrom: (table: string) => SearchTokenProbeQueryBuilder }\n\nexport type SearchTokenAvailabilityDebugPayload = {\n entity: string\n tenantId: string | null\n organizationScope?: OrganizationScope | null\n recordIdColumn?: string\n hasTokens?: boolean\n error?: string\n}\n\nexport type SearchTokenAvailabilityDeps = {\n getDb: () => SearchTokenProbeDb\n getConfig: () => { enabled: boolean }\n applyOrganizationScope: (\n query: SearchTokenProbeQueryBuilder,\n column: string,\n scope: OrganizationScope,\n ) => SearchTokenProbeQueryBuilder\n logDebug: (event: string, payload: SearchTokenAvailabilityDebugPayload) => void\n}\n\nexport type SearchTokenAvailability = {\n /**\n * The cheap, statically-known half of the decision: search is configured on\n * AND the `search_tokens` table exists. Safe to resolve eagerly (consumers\n * like custom-field source attachment need it before any filter is\n * inspected); the table probe is memoized per instance.\n */\n staticEnabled: () => Promise<boolean>\n /**\n * The expensive half: does `search_tokens` hold any row for this\n * (entity, tenant, organization scope)? Historically the `LIMIT 1` probe\n * could degenerate into a seq scan on a large table (#4723), so callers MUST\n * only ask when the query actually carries a like/ilike filter \u2014 use\n * `hasSearchFilter` for the gate. Three layers keep it cheap for the end\n * user: index-usable predicates (no `IS NOT DISTINCT FROM`) served by the\n * dedicated `search_tokens_presence_idx (entity_type, tenant_id,\n * organization_id)` prefix \u2014 which makes the MISS as cheap as the hit \u2014 a\n * per-request instance memo, and a process-level TTL cache\n * (`OM_SEARCH_TOKEN_PRESENCE_CACHE_MS`, default 30s) that amortizes the\n * probe across requests. Probe errors log `search:has-tokens-error`,\n * resolve to `false`, and are never TTL-cached.\n */\n hasTokens: (entity: string, tenantId: string | null, orgScope?: OrganizationScope | null) => Promise<boolean>\n /** First-hit sweep over token sources; logs `search:source-has-tokens` per probed source. */\n anySourceHasTokens: (\n sources: SearchTokenSourceRef[],\n tenantId: string | null,\n orgScope?: OrganizationScope | null,\n ) => Promise<boolean>\n}\n\nexport function isSearchFilterOp(op: string | null | undefined): boolean {\n return op === 'like' || op === 'ilike'\n}\n\n/**\n * The single definition of \"this query actually searches\". Every consumer of\n * the token-availability answer sits behind a like/ilike guard, so when this\n * returns `false` the `hasTokens` probe's answer would never be read \u2014 gate\n * the probe on it.\n */\nexport function hasSearchFilter(filters: ReadonlyArray<{ op?: string | null }>): boolean {\n return filters.some((filter) => isSearchFilterOp(filter.op))\n}\n\nfunction orgScopeKey(scope: OrganizationScope | null | undefined): string {\n if (!scope) return 'none'\n return `${scope.includeNull ? '1' : '0'}:${[...scope.ids].sort((left, right) => left.localeCompare(right)).join(',')}`\n}\n\nconst PRESENCE_CACHE_DEFAULT_TTL_MS = 30_000\nconst PRESENCE_CACHE_MAX_ENTRIES = 10_000\n\n// Process-level TTL cache for the token-presence answer. Module-level (process-global) on\n// purpose: `createRequestContainer` builds fresh engines \u2014 and with them fresh resolver\n// instances \u2014 per request, so an instance-scoped memo alone re-pays the probe on every\n// request. On a large `search_tokens` one probe can be pathologically expensive (#4723),\n// so the answer is amortized across requests here and only re-checked once per TTL.\n// Staleness contract: a stale `false` keeps like/ilike on the plain-column fallback for up\n// to the TTL after an entity's first tokens are written; a stale `true` routes search\n// through an emptied token set for up to the TTL after a purge. Both converge within the\n// TTL; set OM_SEARCH_TOKEN_PRESENCE_CACHE_MS=0 to disable and probe per request again.\nconst presenceCache = new Map<string, { value: boolean; expiresAt: number }>()\n\nfunction resolvePresenceCacheTtlMs(): number {\n return parseNumberWithDefault(process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS, PRESENCE_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 })\n}\n\nfunction storePresence(key: string, value: boolean, ttlMs: number): void {\n if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) {\n const now = Date.now()\n for (const [entryKey, entry] of presenceCache) {\n if (entry.expiresAt <= now) presenceCache.delete(entryKey)\n }\n if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) presenceCache.clear()\n }\n presenceCache.set(key, { value, expiresAt: Date.now() + ttlMs })\n}\n\nexport function clearSearchTokenPresenceCache(): void {\n presenceCache.clear()\n}\n\n/**\n * One place that answers \"is token search usable here?\" for both query\n * engines, instead of each hand-assembling config + table-existence +\n * token-presence probes with private duplicate helpers and ad-hoc memo maps.\n *\n * Memoization is per instance; engines are constructed per request\n * (`createRequestContainer`), so entries never outlive a request \u2014 the same\n * staleness contract the engines' previous per-query join maps had. A\n * rejected table probe is evicted so the next call retries instead of\n * observing a poisoned cache entry.\n */\nexport function createSearchTokenAvailability(deps: SearchTokenAvailabilityDeps): SearchTokenAvailability {\n const tablePresence = new Map<string, Promise<boolean>>()\n const tokenPresence = new Map<string, Promise<boolean>>()\n\n const tableExists = (table: string): Promise<boolean> => {\n const cached = tablePresence.get(table)\n if (cached) return cached\n const probe = (async () => {\n const row = await deps.getDb()\n .selectFrom('information_schema.tables')\n .select(sql<number>`1`.as('one'))\n .where('table_name', '=', table)\n .limit(1)\n .executeTakeFirst()\n return !!row\n })()\n tablePresence.set(table, probe)\n probe.catch(() => tablePresence.delete(table))\n return probe\n }\n\n const probeTokens = async (\n entity: string,\n tenantId: string | null,\n orgScope?: OrganizationScope | null,\n ): Promise<boolean> => {\n let query = deps.getDb()\n .selectFrom('search_tokens')\n .select(sql<number>`1`.as('one'))\n .where('entity_type', '=', entity)\n // Deliberately `= / IS NULL` instead of `IS NOT DISTINCT FROM` (identical semantics\n // for a string|null tenant): the latter cannot serve as an index condition, which is\n // part of why the planner degraded this probe to a seq scan on large tables (#4723).\n // With plain predicates the probe is a pure prefix seek on\n // `search_tokens_presence_idx (entity_type, tenant_id, organization_id)`, making the\n // miss as cheap as the hit.\n query = tenantId == null\n ? query.where('tenant_id', 'is', null)\n : query.where('tenant_id', '=', tenantId)\n if (orgScope) {\n query = deps.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)\n }\n const row = await query.limit(1).executeTakeFirst()\n return !!row\n }\n\n const hasTokens = (\n entity: string,\n tenantId: string | null,\n orgScope?: OrganizationScope | null,\n ): Promise<boolean> => {\n const key = `${entity}|${tenantId ?? '__null__'}|${orgScopeKey(orgScope)}`\n const ttlMs = resolvePresenceCacheTtlMs()\n if (ttlMs > 0) {\n const entry = presenceCache.get(key)\n if (entry && entry.expiresAt > Date.now()) return Promise.resolve(entry.value)\n }\n const cached = tokenPresence.get(key)\n if (cached) return cached\n const probe = (async () => {\n try {\n const value = await probeTokens(entity, tenantId, orgScope)\n // Only genuine probe results enter the process-level cache \u2014 caching an\n // error-driven `false` would pin degraded search for a full TTL after a\n // transient DB failure.\n if (ttlMs > 0) storePresence(key, value, ttlMs)\n return value\n } catch (err) {\n deps.logDebug('search:has-tokens-error', {\n entity,\n tenantId,\n organizationScope: orgScope,\n error: err instanceof Error ? err.message : String(err),\n })\n return false\n }\n })()\n tokenPresence.set(key, probe)\n return probe\n }\n\n return {\n staticEnabled: async () => deps.getConfig().enabled && await tableExists('search_tokens'),\n hasTokens,\n anySourceHasTokens: async (sources, tenantId, orgScope) => {\n for (const source of sources) {\n const ok = await hasTokens(source.entity, tenantId, orgScope)\n deps.logDebug('search:source-has-tokens', {\n entity: source.entity,\n recordIdColumn: source.recordIdColumn,\n tenantId,\n organizationScope: orgScope,\n hasTokens: ok,\n })\n if (ok) return true\n }\n return false\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -2,6 +2,9 @@ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
|
2
2
|
import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
|
|
3
3
|
import { parseCommaSeparatedList } from "@open-mercato/shared/lib/string";
|
|
4
4
|
const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3;
|
|
5
|
+
const DEFAULT_SEARCH_MAX_FIELD_CHARS = 2e4;
|
|
6
|
+
const DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD = 5e3;
|
|
7
|
+
const DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD = 2e4;
|
|
5
8
|
const DEFAULT_BLOCKLIST = ["password", "token", "secret", "hash"];
|
|
6
9
|
const ENTITY_BLOCKLIST_SEPARATOR = "@";
|
|
7
10
|
function parseBoolean(raw, fallback) {
|
|
@@ -10,6 +13,18 @@ function parseBoolean(raw, fallback) {
|
|
|
10
13
|
function parseNumber(raw, fallback, min = 1) {
|
|
11
14
|
return parseNumberWithDefault(raw, fallback, { integer: true, min });
|
|
12
15
|
}
|
|
16
|
+
function resolveSearchTokenLimits(config) {
|
|
17
|
+
const resolveLimit = (value, fallback) => {
|
|
18
|
+
if (value === void 0) return fallback;
|
|
19
|
+
if (!Number.isFinite(value) || value < 0) return fallback;
|
|
20
|
+
return Math.trunc(value);
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
maxFieldChars: resolveLimit(config.maxFieldChars, DEFAULT_SEARCH_MAX_FIELD_CHARS),
|
|
24
|
+
maxTokensPerField: resolveLimit(config.maxTokensPerField, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD),
|
|
25
|
+
maxTokensPerRecord: resolveLimit(config.maxTokensPerRecord, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD)
|
|
26
|
+
};
|
|
27
|
+
}
|
|
13
28
|
function parseHashAlgorithm(raw) {
|
|
14
29
|
const value = (raw ?? "").trim().toLowerCase();
|
|
15
30
|
if (value === "sha1") return "sha1";
|
|
@@ -49,7 +64,10 @@ function resolveSearchConfig() {
|
|
|
49
64
|
hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),
|
|
50
65
|
storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),
|
|
51
66
|
blocklistedFields: blocklist.global,
|
|
52
|
-
entityBlocklistedFields: blocklist.byEntity
|
|
67
|
+
entityBlocklistedFields: blocklist.byEntity,
|
|
68
|
+
maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),
|
|
69
|
+
maxTokensPerField: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_FIELD, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD, 0),
|
|
70
|
+
maxTokensPerRecord: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_RECORD, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD, 0)
|
|
53
71
|
};
|
|
54
72
|
}
|
|
55
73
|
function isSearchFieldBlocklisted(field, entityType, config) {
|
|
@@ -64,9 +82,13 @@ function resolveSearchMinTokenLength() {
|
|
|
64
82
|
return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1);
|
|
65
83
|
}
|
|
66
84
|
export {
|
|
85
|
+
DEFAULT_SEARCH_MAX_FIELD_CHARS,
|
|
86
|
+
DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD,
|
|
87
|
+
DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD,
|
|
67
88
|
DEFAULT_SEARCH_MIN_TOKEN_LENGTH,
|
|
68
89
|
isSearchFieldBlocklisted,
|
|
69
90
|
resolveSearchConfig,
|
|
70
|
-
resolveSearchMinTokenLength
|
|
91
|
+
resolveSearchMinTokenLength,
|
|
92
|
+
resolveSearchTokenLimits
|
|
71
93
|
};
|
|
72
94
|
//# sourceMappingURL=config.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/search/config.ts"],
|
|
4
|
-
"sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'\n\nexport type SearchConfig = {\n enabled: boolean\n minTokenLength: number\n enablePartials: boolean\n hashAlgorithm: 'sha256' | 'sha1' | 'md5'\n storeRawTokens: boolean\n blocklistedFields: string[]\n entityBlocklistedFields?: Record<string, string[]>\n}\n\nexport const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3\n\nconst DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']\n\nconst ENTITY_BLOCKLIST_SEPARATOR = '@'\n\nfunction parseBoolean(raw: string | undefined, fallback: boolean): boolean {\n return parseBooleanWithDefault(raw, fallback)\n}\n\nfunction parseNumber(raw: string | undefined, fallback: number, min = 1): number {\n return parseNumberWithDefault(raw, fallback, { integer: true, min })\n}\n\nfunction parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {\n const value = (raw ?? '').trim().toLowerCase()\n if (value === 'sha1') return 'sha1'\n if (value === 'md5') return 'md5'\n return 'sha256'\n}\n\n/**\n * Parses `OM_SEARCH_FIELD_BLOCKLIST` into a global list plus per-entity-type lists.\n *\n * Why: a deployment often needs to keep one large free-text column out of the token\n * index (e-mail bodies on `customers:customer_interaction`) while still indexing the\n * same-named column elsewhere. A flat global list cannot express that.\n *\n * How to apply: entries are comma-separated; an entry may carry an optional\n * `entityType@` prefix \u2014 `body` blocks the field everywhere, while\n * `customers:customer_interaction@body` blocks it only for that entity type. Entries\n * whose field part is empty are ignored so malformed env input cannot break indexing.\n */\nfunction parseFieldBlocklist(raw: string | undefined): {\n global: string[]\n byEntity: Record<string, string[]>\n} {\n const global: string[] = []\n const byEntity = new Map<string, string[]>()\n\n for (const rawEntry of parseCommaSeparatedList(raw)) {\n const entry = rawEntry.toLowerCase()\n const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR)\n const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : ''\n const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry\n if (!field.length) continue\n\n if (!entityType.length) {\n if (!global.includes(field)) global.push(field)\n continue\n }\n\n const scoped = byEntity.get(entityType) ?? []\n if (!scoped.includes(field)) scoped.push(field)\n byEntity.set(entityType, scoped)\n }\n\n for (const fallback of DEFAULT_BLOCKLIST) {\n if (!global.includes(fallback)) global.push(fallback)\n }\n\n const scopedBlocklist = Object.create(null) as Record<string, string[]>\n for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields\n\n return { global, byEntity: scopedBlocklist }\n}\n\nexport function resolveSearchConfig(): SearchConfig {\n const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST)\n return {\n enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),\n minTokenLength: resolveSearchMinTokenLength(),\n enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),\n hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),\n storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),\n blocklistedFields: blocklist.global,\n entityBlocklistedFields: blocklist.byEntity,\n }\n}\n\n/**\n * Single matcher for \"should this field be kept out of the search index?\".\n *\n * Why: the per-field token path and the `search_text` aggregate previously each\n * decided this on their own, and the aggregate simply never consulted the config \u2014\n * so a blocklisted column's text came back into the index under the aggregate's\n * field name (#4624). Both paths now share this function so they cannot drift.\n *\n * How to apply: pass the document's field name and the entity type being indexed;\n * `entityType` may be omitted when unknown, in which case only global entries apply.\n * Matching keeps the historical substring semantics (`fieldName.includes(pattern)`).\n */\nexport function isSearchFieldBlocklisted(\n field: string,\n entityType: string | null | undefined,\n config: SearchConfig,\n): boolean {\n const lower = field.toLowerCase()\n if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true\n if (!entityType) return false\n const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()]\n if (!Array.isArray(scoped) || !scoped.length) return false\n return scoped.some((blocked) => lower.includes(blocked))\n}\n\n/**\n * Browser-safe accessor for the minimum search token length.\n *\n * Why: client components (e.g. global search dialog) must mirror the server-side\n * tokenizer's `minTokenLength` so the UI gates the request before hitting an\n * empty result set. Pulling the value through this single helper keeps the env\n * contract (`OM_SEARCH_MIN_LEN`) authoritative on both sides.\n *\n * How to apply: call from anywhere \u2014 server, client (when the host app exposes\n * `OM_SEARCH_MIN_LEN` through `next.config.ts`'s `env` block), or tests.\n */\nexport function resolveSearchMinTokenLength(): number {\n return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1)\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AACvC,SAAS,+BAA+B;
|
|
4
|
+
"sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'\n\nexport type SearchConfig = {\n enabled: boolean\n minTokenLength: number\n enablePartials: boolean\n hashAlgorithm: 'sha256' | 'sha1' | 'md5'\n storeRawTokens: boolean\n blocklistedFields: string[]\n entityBlocklistedFields?: Record<string, string[]>\n maxFieldChars?: number\n maxTokensPerField?: number\n maxTokensPerRecord?: number\n}\n\nexport const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3\nexport const DEFAULT_SEARCH_MAX_FIELD_CHARS = 20_000\nexport const DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD = 5_000\nexport const DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD = 20_000\n\nexport type SearchTokenLimits = {\n maxFieldChars: number\n maxTokensPerField: number\n maxTokensPerRecord: number\n}\n\nconst DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']\n\nconst ENTITY_BLOCKLIST_SEPARATOR = '@'\n\nfunction parseBoolean(raw: string | undefined, fallback: boolean): boolean {\n return parseBooleanWithDefault(raw, fallback)\n}\n\nfunction parseNumber(raw: string | undefined, fallback: number, min = 1): number {\n return parseNumberWithDefault(raw, fallback, { integer: true, min })\n}\n\nexport function resolveSearchTokenLimits(config: SearchConfig): SearchTokenLimits {\n const resolveLimit = (value: number | undefined, fallback: number): number => {\n if (value === undefined) return fallback\n if (!Number.isFinite(value) || value < 0) return fallback\n return Math.trunc(value)\n }\n return {\n maxFieldChars: resolveLimit(config.maxFieldChars, DEFAULT_SEARCH_MAX_FIELD_CHARS),\n maxTokensPerField: resolveLimit(config.maxTokensPerField, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD),\n maxTokensPerRecord: resolveLimit(config.maxTokensPerRecord, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD),\n }\n}\n\nfunction parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {\n const value = (raw ?? '').trim().toLowerCase()\n if (value === 'sha1') return 'sha1'\n if (value === 'md5') return 'md5'\n return 'sha256'\n}\n\n/**\n * Parses `OM_SEARCH_FIELD_BLOCKLIST` into a global list plus per-entity-type lists.\n *\n * Why: a deployment often needs to keep one large free-text column out of the token\n * index (e-mail bodies on `customers:customer_interaction`) while still indexing the\n * same-named column elsewhere. A flat global list cannot express that.\n *\n * How to apply: entries are comma-separated; an entry may carry an optional\n * `entityType@` prefix \u2014 `body` blocks the field everywhere, while\n * `customers:customer_interaction@body` blocks it only for that entity type. Entries\n * whose field part is empty are ignored so malformed env input cannot break indexing.\n */\nfunction parseFieldBlocklist(raw: string | undefined): {\n global: string[]\n byEntity: Record<string, string[]>\n} {\n const global: string[] = []\n const byEntity = new Map<string, string[]>()\n\n for (const rawEntry of parseCommaSeparatedList(raw)) {\n const entry = rawEntry.toLowerCase()\n const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR)\n const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : ''\n const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry\n if (!field.length) continue\n\n if (!entityType.length) {\n if (!global.includes(field)) global.push(field)\n continue\n }\n\n const scoped = byEntity.get(entityType) ?? []\n if (!scoped.includes(field)) scoped.push(field)\n byEntity.set(entityType, scoped)\n }\n\n for (const fallback of DEFAULT_BLOCKLIST) {\n if (!global.includes(fallback)) global.push(fallback)\n }\n\n const scopedBlocklist = Object.create(null) as Record<string, string[]>\n for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields\n\n return { global, byEntity: scopedBlocklist }\n}\n\nexport function resolveSearchConfig(): SearchConfig {\n const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST)\n return {\n enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),\n minTokenLength: resolveSearchMinTokenLength(),\n enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),\n hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),\n storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),\n blocklistedFields: blocklist.global,\n entityBlocklistedFields: blocklist.byEntity,\n maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),\n maxTokensPerField: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_FIELD, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD, 0),\n maxTokensPerRecord: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_RECORD, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD, 0),\n }\n}\n\n/**\n * Single matcher for \"should this field be kept out of the search index?\".\n *\n * Why: the per-field token path and the `search_text` aggregate previously each\n * decided this on their own, and the aggregate simply never consulted the config \u2014\n * so a blocklisted column's text came back into the index under the aggregate's\n * field name (#4624). Both paths now share this function so they cannot drift.\n *\n * How to apply: pass the document's field name and the entity type being indexed;\n * `entityType` may be omitted when unknown, in which case only global entries apply.\n * Matching keeps the historical substring semantics (`fieldName.includes(pattern)`).\n */\nexport function isSearchFieldBlocklisted(\n field: string,\n entityType: string | null | undefined,\n config: SearchConfig,\n): boolean {\n const lower = field.toLowerCase()\n if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true\n if (!entityType) return false\n const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()]\n if (!Array.isArray(scoped) || !scoped.length) return false\n return scoped.some((blocked) => lower.includes(blocked))\n}\n\n/**\n * Browser-safe accessor for the minimum search token length.\n *\n * Why: client components (e.g. global search dialog) must mirror the server-side\n * tokenizer's `minTokenLength` so the UI gates the request before hitting an\n * empty result set. Pulling the value through this single helper keeps the env\n * contract (`OM_SEARCH_MIN_LEN`) authoritative on both sides.\n *\n * How to apply: call from anywhere \u2014 server, client (when the host app exposes\n * `OM_SEARCH_MIN_LEN` through `next.config.ts`'s `env` block), or tests.\n */\nexport function resolveSearchMinTokenLength(): number {\n return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
|
-
import { resolveSearchConfig } from "./config.js";
|
|
2
|
+
import { resolveSearchConfig, resolveSearchTokenLimits } from "./config.js";
|
|
3
3
|
function normalizeText(text) {
|
|
4
4
|
return text.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[%_]/g, " ").toLowerCase();
|
|
5
5
|
}
|
|
6
6
|
function splitTokens(text, minLength) {
|
|
7
7
|
return normalizeText(text).split(/[^a-z0-9]+/i).filter((token) => token.length >= minLength);
|
|
8
8
|
}
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
function appendExpandedToken(token, config, seen, tokens, limit) {
|
|
10
|
+
const append = (candidate) => {
|
|
11
|
+
if (seen.has(candidate)) return tokens.length < limit;
|
|
12
|
+
seen.add(candidate);
|
|
13
|
+
tokens.push(candidate);
|
|
14
|
+
return tokens.length < limit;
|
|
15
|
+
};
|
|
16
|
+
if (!config.enablePartials) {
|
|
17
|
+
append(token);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (let length = config.minTokenLength; length <= token.length; length += 1) {
|
|
21
|
+
if (!append(token.slice(0, length))) return;
|
|
14
22
|
}
|
|
15
|
-
return results;
|
|
16
23
|
}
|
|
17
24
|
function hashToken(token, config) {
|
|
18
25
|
const cfg = config ?? resolveSearchConfig();
|
|
@@ -20,10 +27,15 @@ function hashToken(token, config) {
|
|
|
20
27
|
}
|
|
21
28
|
function tokenizeText(text, config) {
|
|
22
29
|
const cfg = config ?? resolveSearchConfig();
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
30
|
+
const limits = resolveSearchTokenLimits(cfg);
|
|
31
|
+
const boundedText = limits.maxFieldChars > 0 ? text.slice(0, limits.maxFieldChars) : text;
|
|
32
|
+
const tokenLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY;
|
|
33
|
+
const seen = /* @__PURE__ */ new Set();
|
|
34
|
+
const tokens = [];
|
|
35
|
+
for (const token of splitTokens(boundedText, cfg.minTokenLength)) {
|
|
36
|
+
if (tokens.length >= tokenLimit) break;
|
|
37
|
+
appendExpandedToken(token, cfg, seen, tokens, tokenLimit);
|
|
38
|
+
}
|
|
27
39
|
const hashes = tokens.map((token) => hashToken(token, cfg));
|
|
28
40
|
return { tokens, hashes };
|
|
29
41
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/search/tokenize.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'crypto'\nimport { resolveSearchConfig, type SearchConfig } from './config'\n\nexport type TokenizationResult = {\n tokens: string[]\n hashes: string[]\n}\n\nfunction normalizeText(text: string): string {\n return text\n .normalize('NFKD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[%_]/g, ' ')\n .toLowerCase()\n}\n\nfunction splitTokens(text: string, minLength: number): string[] {\n return normalizeText(text)\n .split(/[^a-z0-9]+/i)\n .filter((token) => token.length >= minLength)\n}\n\nfunction
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,
|
|
4
|
+
"sourcesContent": ["import crypto from 'crypto'\nimport { resolveSearchConfig, resolveSearchTokenLimits, type SearchConfig } from './config'\n\nexport type TokenizationResult = {\n tokens: string[]\n hashes: string[]\n}\n\nfunction normalizeText(text: string): string {\n return text\n .normalize('NFKD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[%_]/g, ' ')\n .toLowerCase()\n}\n\nfunction splitTokens(text: string, minLength: number): string[] {\n return normalizeText(text)\n .split(/[^a-z0-9]+/i)\n .filter((token) => token.length >= minLength)\n}\n\nfunction appendExpandedToken(\n token: string,\n config: SearchConfig,\n seen: Set<string>,\n tokens: string[],\n limit: number,\n): void {\n const append = (candidate: string): boolean => {\n if (seen.has(candidate)) return tokens.length < limit\n seen.add(candidate)\n tokens.push(candidate)\n return tokens.length < limit\n }\n\n if (!config.enablePartials) {\n append(token)\n return\n }\n\n for (let length = config.minTokenLength; length <= token.length; length += 1) {\n if (!append(token.slice(0, length))) return\n }\n}\n\nexport function hashToken(token: string, config?: SearchConfig): string {\n const cfg = config ?? resolveSearchConfig()\n return crypto.createHash(cfg.hashAlgorithm).update(token).digest('hex')\n}\n\nexport function tokenizeText(text: string, config?: SearchConfig): TokenizationResult {\n const cfg = config ?? resolveSearchConfig()\n const limits = resolveSearchTokenLimits(cfg)\n const boundedText = limits.maxFieldChars > 0 ? text.slice(0, limits.maxFieldChars) : text\n const tokenLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n const seen = new Set<string>()\n const tokens: string[] = []\n\n for (const token of splitTokens(boundedText, cfg.minTokenLength)) {\n if (tokens.length >= tokenLimit) break\n appendExpandedToken(token, cfg, seen, tokens, tokenLimit)\n }\n\n const hashes = tokens.map((token) => hashToken(token, cfg))\n return { tokens, hashes }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,qBAAqB,gCAAmD;AAOjF,SAAS,cAAc,MAAsB;AAC3C,SAAO,KACJ,UAAU,MAAM,EAChB,QAAQ,oBAAoB,EAAE,EAC9B,QAAQ,SAAS,GAAG,EACpB,YAAY;AACjB;AAEA,SAAS,YAAY,MAAc,WAA6B;AAC9D,SAAO,cAAc,IAAI,EACtB,MAAM,aAAa,EACnB,OAAO,CAAC,UAAU,MAAM,UAAU,SAAS;AAChD;AAEA,SAAS,oBACP,OACA,QACA,MACA,QACA,OACM;AACN,QAAM,SAAS,CAAC,cAA+B;AAC7C,QAAI,KAAK,IAAI,SAAS,EAAG,QAAO,OAAO,SAAS;AAChD,SAAK,IAAI,SAAS;AAClB,WAAO,KAAK,SAAS;AACrB,WAAO,OAAO,SAAS;AAAA,EACzB;AAEA,MAAI,CAAC,OAAO,gBAAgB;AAC1B,WAAO,KAAK;AACZ;AAAA,EACF;AAEA,WAAS,SAAS,OAAO,gBAAgB,UAAU,MAAM,QAAQ,UAAU,GAAG;AAC5E,QAAI,CAAC,OAAO,MAAM,MAAM,GAAG,MAAM,CAAC,EAAG;AAAA,EACvC;AACF;AAEO,SAAS,UAAU,OAAe,QAA+B;AACtE,QAAM,MAAM,UAAU,oBAAoB;AAC1C,SAAO,OAAO,WAAW,IAAI,aAAa,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACxE;AAEO,SAAS,aAAa,MAAc,QAA2C;AACpF,QAAM,MAAM,UAAU,oBAAoB;AAC1C,QAAM,SAAS,yBAAyB,GAAG;AAC3C,QAAM,cAAc,OAAO,gBAAgB,IAAI,KAAK,MAAM,GAAG,OAAO,aAAa,IAAI;AACrF,QAAM,aAAa,OAAO,oBAAoB,IAAI,OAAO,oBAAoB,OAAO;AACpF,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,SAAmB,CAAC;AAE1B,aAAW,SAAS,YAAY,aAAa,IAAI,cAAc,GAAG;AAChE,QAAI,OAAO,UAAU,WAAY;AACjC,wBAAoB,OAAO,KAAK,MAAM,QAAQ,UAAU;AAAA,EAC1D;AAEA,QAAM,SAAS,OAAO,IAAI,CAAC,UAAU,UAAU,OAAO,GAAG,CAAC;AAC1D,SAAO,EAAE,QAAQ,OAAO;AAC1B;",
|
|
6
6
|
"names": []
|
|
7
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.7-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6814.1.0627c7e9f1';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parseNumberWithDefault } from "../number.js";
|
|
2
|
+
const DEFAULT_WEBHOOK_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
3
|
+
class WebhookBodyTooLargeError extends Error {
|
|
4
|
+
constructor(limitBytes) {
|
|
5
|
+
super(`Webhook body exceeds the ${limitBytes}-byte limit`);
|
|
6
|
+
this.name = "WebhookBodyTooLargeError";
|
|
7
|
+
this.limitBytes = limitBytes;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function resolveWebhookBodyLimitBytes(raw = typeof process === "undefined" ? void 0 : process.env.OM_WEBHOOK_MAX_BODY_BYTES, fallbackBytes = DEFAULT_WEBHOOK_BODY_LIMIT_BYTES) {
|
|
11
|
+
const fallback = Number.isSafeInteger(fallbackBytes) && fallbackBytes > 0 ? fallbackBytes : DEFAULT_WEBHOOK_BODY_LIMIT_BYTES;
|
|
12
|
+
const parsed = parseNumberWithDefault(raw, fallback, { min: 1 });
|
|
13
|
+
return Number.isSafeInteger(parsed) ? parsed : fallback;
|
|
14
|
+
}
|
|
15
|
+
async function readBoundedRequestBody(request, options) {
|
|
16
|
+
const configuredLimit = options?.maxBytes ?? resolveWebhookBodyLimitBytes();
|
|
17
|
+
const maxBytes = Number.isSafeInteger(configuredLimit) && configuredLimit > 0 ? configuredLimit : DEFAULT_WEBHOOK_BODY_LIMIT_BYTES;
|
|
18
|
+
const declaredLength = request.headers.get("content-length")?.trim();
|
|
19
|
+
if (declaredLength && /^\d+$/.test(declaredLength)) {
|
|
20
|
+
const declaredBytes = Number(declaredLength);
|
|
21
|
+
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > maxBytes) {
|
|
22
|
+
throw new WebhookBodyTooLargeError(maxBytes);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (!request.body) return "";
|
|
26
|
+
const reader = request.body.getReader();
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let totalBytes = 0;
|
|
29
|
+
try {
|
|
30
|
+
while (true) {
|
|
31
|
+
const { done, value } = await reader.read();
|
|
32
|
+
if (done) break;
|
|
33
|
+
totalBytes += value.byteLength;
|
|
34
|
+
if (totalBytes > maxBytes) {
|
|
35
|
+
await reader.cancel().catch(() => void 0);
|
|
36
|
+
throw new WebhookBodyTooLargeError(maxBytes);
|
|
37
|
+
}
|
|
38
|
+
chunks.push(value);
|
|
39
|
+
}
|
|
40
|
+
} finally {
|
|
41
|
+
reader.releaseLock();
|
|
42
|
+
}
|
|
43
|
+
const body = new Uint8Array(totalBytes);
|
|
44
|
+
let offset = 0;
|
|
45
|
+
for (const chunk of chunks) {
|
|
46
|
+
body.set(chunk, offset);
|
|
47
|
+
offset += chunk.byteLength;
|
|
48
|
+
}
|
|
49
|
+
return new TextDecoder().decode(body);
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
|
|
53
|
+
WebhookBodyTooLargeError,
|
|
54
|
+
readBoundedRequestBody,
|
|
55
|
+
resolveWebhookBodyLimitBytes
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=body.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/webhooks/body.ts"],
|
|
4
|
+
"sourcesContent": ["import { parseNumberWithDefault } from '../number'\n\nexport const DEFAULT_WEBHOOK_BODY_LIMIT_BYTES = 1024 * 1024\n\nexport class WebhookBodyTooLargeError extends Error {\n readonly limitBytes: number\n\n constructor(limitBytes: number) {\n super(`Webhook body exceeds the ${limitBytes}-byte limit`)\n this.name = 'WebhookBodyTooLargeError'\n this.limitBytes = limitBytes\n }\n}\n\nexport function resolveWebhookBodyLimitBytes(\n raw = typeof process === 'undefined' ? undefined : process.env.OM_WEBHOOK_MAX_BODY_BYTES,\n fallbackBytes = DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,\n): number {\n const fallback = Number.isSafeInteger(fallbackBytes) && fallbackBytes > 0\n ? fallbackBytes\n : DEFAULT_WEBHOOK_BODY_LIMIT_BYTES\n const parsed = parseNumberWithDefault(raw, fallback, { min: 1 })\n return Number.isSafeInteger(parsed) ? parsed : fallback\n}\n\nexport async function readBoundedRequestBody(\n request: Request,\n options?: { maxBytes?: number },\n): Promise<string> {\n const configuredLimit = options?.maxBytes ?? resolveWebhookBodyLimitBytes()\n const maxBytes = Number.isSafeInteger(configuredLimit) && configuredLimit > 0\n ? configuredLimit\n : DEFAULT_WEBHOOK_BODY_LIMIT_BYTES\n const declaredLength = request.headers.get('content-length')?.trim()\n if (declaredLength && /^\\d+$/.test(declaredLength)) {\n const declaredBytes = Number(declaredLength)\n if (!Number.isSafeInteger(declaredBytes) || declaredBytes > maxBytes) {\n throw new WebhookBodyTooLargeError(maxBytes)\n }\n }\n\n if (!request.body) return ''\n\n const reader = request.body.getReader()\n const chunks: Uint8Array[] = []\n let totalBytes = 0\n\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBytes) {\n await reader.cancel().catch(() => undefined)\n throw new WebhookBodyTooLargeError(maxBytes)\n }\n chunks.push(value)\n }\n } finally {\n reader.releaseLock()\n }\n\n const body = new Uint8Array(totalBytes)\n let offset = 0\n for (const chunk of chunks) {\n body.set(chunk, offset)\n offset += chunk.byteLength\n }\n return new TextDecoder().decode(body)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AAEhC,MAAM,mCAAmC,OAAO;AAEhD,MAAM,iCAAiC,MAAM;AAAA,EAGlD,YAAY,YAAoB;AAC9B,UAAM,4BAA4B,UAAU,aAAa;AACzD,SAAK,OAAO;AACZ,SAAK,aAAa;AAAA,EACpB;AACF;AAEO,SAAS,6BACd,MAAM,OAAO,YAAY,cAAc,SAAY,QAAQ,IAAI,2BAC/D,gBAAgB,kCACR;AACR,QAAM,WAAW,OAAO,cAAc,aAAa,KAAK,gBAAgB,IACpE,gBACA;AACJ,QAAM,SAAS,uBAAuB,KAAK,UAAU,EAAE,KAAK,EAAE,CAAC;AAC/D,SAAO,OAAO,cAAc,MAAM,IAAI,SAAS;AACjD;AAEA,eAAsB,uBACpB,SACA,SACiB;AACjB,QAAM,kBAAkB,SAAS,YAAY,6BAA6B;AAC1E,QAAM,WAAW,OAAO,cAAc,eAAe,KAAK,kBAAkB,IACxE,kBACA;AACJ,QAAM,iBAAiB,QAAQ,QAAQ,IAAI,gBAAgB,GAAG,KAAK;AACnE,MAAI,kBAAkB,QAAQ,KAAK,cAAc,GAAG;AAClD,UAAM,gBAAgB,OAAO,cAAc;AAC3C,QAAI,CAAC,OAAO,cAAc,aAAa,KAAK,gBAAgB,UAAU;AACpE,YAAM,IAAI,yBAAyB,QAAQ;AAAA,IAC7C;AAAA,EACF;AAEA,MAAI,CAAC,QAAQ,KAAM,QAAO;AAE1B,QAAM,SAAS,QAAQ,KAAK,UAAU;AACtC,QAAM,SAAuB,CAAC;AAC9B,MAAI,aAAa;AAEjB,MAAI;AACF,WAAO,MAAM;AACX,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,UAAI,KAAM;AACV,oBAAc,MAAM;AACpB,UAAI,aAAa,UAAU;AACzB,cAAM,OAAO,OAAO,EAAE,MAAM,MAAM,MAAS;AAC3C,cAAM,IAAI,yBAAyB,QAAQ;AAAA,MAC7C;AACA,aAAO,KAAK,KAAK;AAAA,IACnB;AAAA,EACF,UAAE;AACA,WAAO,YAAY;AAAA,EACrB;AAEA,QAAM,OAAO,IAAI,WAAW,UAAU;AACtC,MAAI,SAAS;AACb,aAAW,SAAS,QAAQ;AAC1B,SAAK,IAAI,OAAO,MAAM;AACtB,cAAU,MAAM;AAAA,EAClB;AACA,SAAO,IAAI,YAAY,EAAE,OAAO,IAAI;AACtC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { signWebhookPayload, buildWebhookHeaders, generateMessageId } from "./sign.js";
|
|
2
|
-
import { verifyWebhookSignature } from "./verify.js";
|
|
2
|
+
import { WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, isWebhookTimestampWithinTolerance, verifyWebhookSignature } from "./verify.js";
|
|
3
3
|
import { generateWebhookSecret, parseWebhookSecret, isValidWebhookSecret } from "./secrets.js";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
|
|
6
|
+
readBoundedRequestBody,
|
|
7
|
+
resolveWebhookBodyLimitBytes,
|
|
8
|
+
WebhookBodyTooLargeError
|
|
9
|
+
} from "./body.js";
|
|
4
10
|
export {
|
|
11
|
+
DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
|
|
12
|
+
WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,
|
|
13
|
+
WebhookBodyTooLargeError,
|
|
5
14
|
buildWebhookHeaders,
|
|
6
15
|
generateMessageId,
|
|
7
16
|
generateWebhookSecret,
|
|
8
17
|
isValidWebhookSecret,
|
|
18
|
+
isWebhookTimestampWithinTolerance,
|
|
9
19
|
parseWebhookSecret,
|
|
20
|
+
readBoundedRequestBody,
|
|
21
|
+
resolveWebhookBodyLimitBytes,
|
|
10
22
|
signWebhookPayload,
|
|
11
23
|
verifyWebhookSignature
|
|
12
24
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/webhooks/index.ts"],
|
|
4
|
-
"sourcesContent": ["export { signWebhookPayload, buildWebhookHeaders, generateMessageId } from './sign'\nexport { verifyWebhookSignature } from './verify'\nexport { generateWebhookSecret, parseWebhookSecret, isValidWebhookSecret } from './secrets'\nexport type { StandardWebhookHeaders, WebhookSigningKey, WebhookVerificationResult, StandardWebhookPayload } from './types'\nexport type {\n InboundWebhookRequest,\n WebhookSourceCredentialField,\n WebhookSourceConfig,\n WebhookHandlerMeta,\n WebhookHandlerPayload,\n WebhookHandlerContext,\n WebhookHandler,\n WebhookHandlerRegistryEntry,\n WebhookHandlerResult,\n WebhookIngestionStatus,\n} from './inbound-types'\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB,qBAAqB,yBAAyB;AAC3E,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["export { signWebhookPayload, buildWebhookHeaders, generateMessageId } from './sign'\nexport { WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, isWebhookTimestampWithinTolerance, verifyWebhookSignature } from './verify'\nexport { generateWebhookSecret, parseWebhookSecret, isValidWebhookSecret } from './secrets'\nexport {\n DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,\n readBoundedRequestBody,\n resolveWebhookBodyLimitBytes,\n WebhookBodyTooLargeError,\n} from './body'\nexport type { StandardWebhookHeaders, WebhookSigningKey, WebhookVerificationResult, StandardWebhookPayload } from './types'\nexport type {\n InboundWebhookRequest,\n WebhookSourceCredentialField,\n WebhookSourceConfig,\n WebhookHandlerMeta,\n WebhookHandlerPayload,\n WebhookHandlerContext,\n WebhookHandler,\n WebhookHandlerRegistryEntry,\n WebhookHandlerResult,\n WebhookIngestionStatus,\n} from './inbound-types'\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB,qBAAqB,yBAAyB;AAC3E,SAAS,qCAAqC,mCAAmC,8BAA8B;AAC/G,SAAS,uBAAuB,oBAAoB,4BAA4B;AAChF;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { timingSafeEqual, createHmac } from "node:crypto";
|
|
2
2
|
import { parseWebhookSecret } from "./secrets.js";
|
|
3
|
-
const
|
|
4
|
-
function
|
|
5
|
-
const
|
|
6
|
-
if (
|
|
7
|
-
return
|
|
3
|
+
const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
|
|
4
|
+
function isWebhookTimestampWithinTolerance(timestamp, toleranceSeconds = WEBHOOK_SIGNATURE_TOLERANCE_SECONDS, nowSeconds = Math.floor(Date.now() / 1e3)) {
|
|
5
|
+
const normalizedTimestamp = timestamp.trim();
|
|
6
|
+
if (!/^\d+$/.test(normalizedTimestamp)) {
|
|
7
|
+
return false;
|
|
8
8
|
}
|
|
9
|
-
const
|
|
10
|
-
|
|
9
|
+
const timestampNum = Number.parseInt(normalizedTimestamp, 10);
|
|
10
|
+
return Math.abs(nowSeconds - timestampNum) <= toleranceSeconds;
|
|
11
|
+
}
|
|
12
|
+
function verifyWebhookSignature(msgId, timestamp, body, signatureHeader, secrets, toleranceSeconds = WEBHOOK_SIGNATURE_TOLERANCE_SECONDS) {
|
|
13
|
+
if (!isWebhookTimestampWithinTolerance(timestamp, toleranceSeconds)) {
|
|
11
14
|
return { valid: false };
|
|
12
15
|
}
|
|
13
16
|
const signedContent = `${msgId}.${timestamp}.${body}`;
|
|
@@ -29,6 +32,8 @@ function verifyWebhookSignature(msgId, timestamp, body, signatureHeader, secrets
|
|
|
29
32
|
return { valid: false };
|
|
30
33
|
}
|
|
31
34
|
export {
|
|
35
|
+
WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,
|
|
36
|
+
isWebhookTimestampWithinTolerance,
|
|
32
37
|
verifyWebhookSignature
|
|
33
38
|
};
|
|
34
39
|
//# sourceMappingURL=verify.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/webhooks/verify.ts"],
|
|
4
|
-
"sourcesContent": ["import { timingSafeEqual, createHmac } from 'node:crypto'\nimport { parseWebhookSecret } from './secrets'\nimport type { WebhookVerificationResult } from './types'\n\
|
|
5
|
-
"mappings": "AAAA,SAAS,iBAAiB,kBAAkB;AAC5C,SAAS,0BAA0B;
|
|
4
|
+
"sourcesContent": ["import { timingSafeEqual, createHmac } from 'node:crypto'\nimport { parseWebhookSecret } from './secrets'\nimport type { WebhookVerificationResult } from './types'\n\nexport const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 5 * 60 // 5 minutes\n\nexport function isWebhookTimestampWithinTolerance(\n timestamp: string,\n toleranceSeconds: number = WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,\n nowSeconds: number = Math.floor(Date.now() / 1000),\n): boolean {\n const normalizedTimestamp = timestamp.trim()\n if (!/^\\d+$/.test(normalizedTimestamp)) {\n return false\n }\n\n const timestampNum = Number.parseInt(normalizedTimestamp, 10)\n return Math.abs(nowSeconds - timestampNum) <= toleranceSeconds\n}\n\n/**\n * Verify a Standard Webhooks signature against one or more secrets.\n * Returns verification result with which key matched.\n */\nexport function verifyWebhookSignature(\n msgId: string,\n timestamp: string,\n body: string,\n signatureHeader: string,\n secrets: string[],\n toleranceSeconds: number = WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,\n): WebhookVerificationResult {\n if (!isWebhookTimestampWithinTolerance(timestamp, toleranceSeconds)) {\n return { valid: false }\n }\n\n const signedContent = `${msgId}.${timestamp}.${body}`\n const providedSignatures = signatureHeader.split(' ')\n\n for (let keyIndex = 0; keyIndex < secrets.length; keyIndex++) {\n const key = parseWebhookSecret(secrets[keyIndex])\n const expectedSig = createHmac('sha256', key)\n .update(signedContent)\n .digest('base64')\n\n for (const sig of providedSignatures) {\n const parts = sig.split(',')\n if (parts.length !== 2 || parts[0] !== 'v1') continue\n\n const sigBytes = Buffer.from(parts[1], 'base64')\n const expectedBytes = Buffer.from(expectedSig, 'base64')\n\n if (sigBytes.length !== expectedBytes.length) continue\n\n if (timingSafeEqual(sigBytes, expectedBytes)) {\n return { valid: true, matchedKeyIndex: keyIndex }\n }\n }\n }\n\n return { valid: false }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB,kBAAkB;AAC5C,SAAS,0BAA0B;AAG5B,MAAM,sCAAsC,IAAI;AAEhD,SAAS,kCACd,WACA,mBAA2B,qCAC3B,aAAqB,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,GACxC;AACT,QAAM,sBAAsB,UAAU,KAAK;AAC3C,MAAI,CAAC,QAAQ,KAAK,mBAAmB,GAAG;AACtC,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,OAAO,SAAS,qBAAqB,EAAE;AAC5D,SAAO,KAAK,IAAI,aAAa,YAAY,KAAK;AAChD;AAMO,SAAS,uBACd,OACA,WACA,MACA,iBACA,SACA,mBAA2B,qCACA;AAC3B,MAAI,CAAC,kCAAkC,WAAW,gBAAgB,GAAG;AACnE,WAAO,EAAE,OAAO,MAAM;AAAA,EACxB;AAEA,QAAM,gBAAgB,GAAG,KAAK,IAAI,SAAS,IAAI,IAAI;AACnD,QAAM,qBAAqB,gBAAgB,MAAM,GAAG;AAEpD,WAAS,WAAW,GAAG,WAAW,QAAQ,QAAQ,YAAY;AAC5D,UAAM,MAAM,mBAAmB,QAAQ,QAAQ,CAAC;AAChD,UAAM,cAAc,WAAW,UAAU,GAAG,EACzC,OAAO,aAAa,EACpB,OAAO,QAAQ;AAElB,eAAW,OAAO,oBAAoB;AACpC,YAAM,QAAQ,IAAI,MAAM,GAAG;AAC3B,UAAI,MAAM,WAAW,KAAK,MAAM,CAAC,MAAM,KAAM;AAE7C,YAAM,WAAW,OAAO,KAAK,MAAM,CAAC,GAAG,QAAQ;AAC/C,YAAM,gBAAgB,OAAO,KAAK,aAAa,QAAQ;AAEvD,UAAI,SAAS,WAAW,cAAc,OAAQ;AAE9C,UAAI,gBAAgB,UAAU,aAAa,GAAG;AAC5C,eAAO,EAAE,OAAO,MAAM,iBAAiB,SAAS;AAAA,MAClD;AAAA,IACF;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,MAAM;AACxB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_WEBHOOK_BODY_LIMIT_BYTES } from "../../lib/webhooks/body.js";
|
|
1
2
|
const ADAPTER_REGISTRY_KEY = "__openMercatoPaymentGatewayAdapters__";
|
|
2
3
|
const WEBHOOK_REGISTRY_KEY = "__openMercatoPaymentGatewayWebhookHandlers__";
|
|
3
4
|
const DESCRIPTOR_REGISTRY_KEY = "__openMercatoPaymentGatewayDescriptors__";
|
|
@@ -61,11 +62,17 @@ function clearGatewayAdapters() {
|
|
|
61
62
|
getAdapterRegistry().clear();
|
|
62
63
|
}
|
|
63
64
|
function registerWebhookHandler(providerKey, handler, options) {
|
|
65
|
+
if (options?.maxBodyBytes !== void 0 && (!Number.isSafeInteger(options.maxBodyBytes) || options.maxBodyBytes <= 0 || options.maxBodyBytes > DEFAULT_WEBHOOK_BODY_LIMIT_BYTES)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[internal] Payment gateway webhook maxBodyBytes must be a positive safe integer no greater than ${DEFAULT_WEBHOOK_BODY_LIMIT_BYTES}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
64
70
|
const webhookHandlerRegistry = getWebhookHandlerRegistry();
|
|
65
71
|
webhookHandlerRegistry.set(providerKey, {
|
|
66
72
|
handler,
|
|
67
73
|
queue: options?.queue,
|
|
68
|
-
readSessionIdHint: options?.readSessionIdHint
|
|
74
|
+
readSessionIdHint: options?.readSessionIdHint,
|
|
75
|
+
maxBodyBytes: options?.maxBodyBytes
|
|
69
76
|
});
|
|
70
77
|
return () => {
|
|
71
78
|
webhookHandlerRegistry.delete(providerKey);
|