@open-mercato/shared 0.9.1-develop.7574.1.837288eec1 → 0.10.1-develop.7576.1.21d5b50435
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 +4 -4
- package/dist/lib/auth/server.js +1 -1
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +6 -46
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +3 -4
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/catalog-visibility/index.js +0 -4
- package/dist/lib/catalog-visibility/index.js.map +2 -2
- package/dist/lib/commands/command-bus.js +13 -45
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/types.js.map +2 -2
- package/dist/lib/crud/exporters.js +1 -1
- package/dist/lib/crud/exporters.js.map +2 -2
- package/dist/lib/crud/ids.js +1 -13
- package/dist/lib/crud/ids.js.map +2 -2
- package/dist/lib/db/pg-errors.js +0 -14
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/localization/index.js +1 -9
- package/dist/lib/localization/index.js.map +2 -2
- package/dist/lib/localization/translatable-fields.js +0 -26
- package/dist/lib/localization/translatable-fields.js.map +2 -2
- package/dist/lib/number.js +0 -6
- package/dist/lib/number.js.map +2 -2
- package/dist/lib/openapi/generator.js +67 -84
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/query/engine.js +10 -45
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/join-utils.js +1 -1
- package/dist/lib/query/join-utils.js.map +2 -2
- package/dist/lib/query/types.js.map +2 -2
- package/dist/lib/url.js +0 -24
- package/dist/lib/url.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 +3 -7
- package/dist/lib/webhooks/body.js.map +2 -2
- package/dist/lib/webhooks/index.js +0 -2
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/modules/integrations/types.js.map +2 -2
- package/dist/modules/notifications/types.js +0 -4
- package/dist/modules/notifications/types.js.map +3 -3
- package/dist/modules/payment_gateways/types.js +1 -14
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/search.js.map +2 -2
- package/dist/modules/widgets/injection-loader.js +3 -14
- package/dist/modules/widgets/injection-loader.js.map +2 -2
- package/package.json +3 -10
- package/src/lib/__tests__/number.test.ts +1 -24
- package/src/lib/auth/__tests__/server.test.ts +0 -14
- package/src/lib/auth/server.ts +1 -1
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
- package/src/lib/bootstrap/__tests__/factory.test.ts +6 -18
- package/src/lib/bootstrap/dynamicLoader.ts +5 -63
- package/src/lib/bootstrap/factory.ts +3 -8
- package/src/lib/bootstrap/types.ts +1 -5
- package/src/lib/catalog-visibility/index.ts +0 -2
- package/src/lib/commands/__tests__/command-bus.test.ts +1 -100
- package/src/lib/commands/command-bus.ts +10 -42
- package/src/lib/commands/command-interceptor.ts +0 -4
- package/src/lib/commands/types.ts +0 -14
- package/src/lib/crud/__tests__/exporters.test.ts +0 -18
- package/src/lib/crud/__tests__/ids.test.ts +1 -34
- package/src/lib/crud/exporters.ts +1 -1
- package/src/lib/crud/ids.ts +0 -24
- package/src/lib/db/__tests__/pg-errors.test.ts +1 -68
- package/src/lib/db/pg-errors.ts +0 -35
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -23
- package/src/lib/localization/__tests__/translatable-fields.test.ts +0 -17
- package/src/lib/localization/index.ts +1 -8
- package/src/lib/localization/translatable-fields.ts +0 -46
- package/src/lib/number.ts +0 -13
- package/src/lib/openapi/generator.ts +77 -106
- package/src/lib/query/engine.ts +8 -58
- package/src/lib/query/join-utils.ts +1 -1
- package/src/lib/query/types.ts +1 -13
- package/src/lib/url.ts +0 -30
- package/src/lib/webhooks/__tests__/body.test.ts +0 -48
- package/src/lib/webhooks/body.ts +4 -11
- package/src/lib/webhooks/index.ts +0 -1
- package/src/modules/integrations/types.ts +0 -6
- package/src/modules/notifications/types.ts +0 -7
- package/src/modules/payment_gateways/__tests__/types.test.ts +0 -44
- package/src/modules/payment_gateways/types.ts +6 -52
- package/src/modules/registry.ts +0 -5
- package/src/modules/search.ts +0 -39
- package/src/modules/widgets/injection-loader.ts +3 -33
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +0 -6
- package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +0 -7
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js +0 -62
- package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +0 -7
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +0 -33
- package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +0 -7
- package/dist/lib/catalog-visibility/hashEffectiveScope.js +0 -9
- package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +0 -7
- package/dist/lib/db/advisoryLock.js +0 -172
- package/dist/lib/db/advisoryLock.js.map +0 -7
- package/dist/lib/query/overlap.js +0 -58
- package/dist/lib/query/overlap.js.map +0 -7
- package/dist/modules/notifications/safeHref.js +0 -20
- package/dist/modules/notifications/safeHref.js.map +0 -7
- package/src/lib/__tests__/url.cross-site.test.ts +0 -140
- package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +0 -141
- package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +0 -174
- package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +0 -3
- package/src/lib/bootstrap/next-subpath-resolve-hook.ts +0 -130
- package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +0 -121
- package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +0 -41
- package/src/lib/catalog-visibility/hashEffectiveScope.ts +0 -8
- package/src/lib/db/__tests__/advisoryLock.integration.test.ts +0 -343
- package/src/lib/db/__tests__/advisoryLock.test.ts +0 -564
- package/src/lib/db/advisoryLock.ts +0 -271
- package/src/lib/openapi/__tests__/optional-path-params.test.ts +0 -102
- package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +0 -89
- package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +0 -79
- package/src/lib/query/__tests__/overlap.test.ts +0 -219
- package/src/lib/query/overlap.ts +0 -106
- package/src/modules/notifications/safeHref.ts +0 -19
- package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +0 -78
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import * as nodeModule from "node:module";
|
|
2
|
-
const NEXT_BARE_SUBPATH = /^next\/[A-Za-z0-9_-]+$/;
|
|
3
|
-
let installed = false;
|
|
4
|
-
function isModuleNotFound(error) {
|
|
5
|
-
return Boolean(error) && typeof error === "object" && error.code === "ERR_MODULE_NOT_FOUND";
|
|
6
|
-
}
|
|
7
|
-
function resolveNextSubpath(specifier, context, nextResolve) {
|
|
8
|
-
if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context);
|
|
9
|
-
try {
|
|
10
|
-
return nextResolve(specifier, context);
|
|
11
|
-
} catch (error) {
|
|
12
|
-
if (!isModuleNotFound(error)) throw error;
|
|
13
|
-
try {
|
|
14
|
-
return nextResolve(`${specifier}.js`, context);
|
|
15
|
-
} catch {
|
|
16
|
-
throw error;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
async function resolveNextSubpathAsync(specifier, context, nextResolve) {
|
|
21
|
-
if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context);
|
|
22
|
-
try {
|
|
23
|
-
return await nextResolve(specifier, context);
|
|
24
|
-
} catch (error) {
|
|
25
|
-
if (!isModuleNotFound(error)) throw error;
|
|
26
|
-
try {
|
|
27
|
-
return await nextResolve(`${specifier}.js`, context);
|
|
28
|
-
} catch {
|
|
29
|
-
throw error;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
function runtimeRegisterHooks() {
|
|
34
|
-
const candidate = nodeModule.registerHooks;
|
|
35
|
-
return typeof candidate === "function" ? candidate : void 0;
|
|
36
|
-
}
|
|
37
|
-
function runtimeRegisterHookModule() {
|
|
38
|
-
const candidate = nodeModule.register;
|
|
39
|
-
return typeof candidate === "function" ? candidate : void 0;
|
|
40
|
-
}
|
|
41
|
-
function installNextSubpathResolveHook(registerHooks = runtimeRegisterHooks(), registerHookModule = runtimeRegisterHookModule()) {
|
|
42
|
-
if (installed) return false;
|
|
43
|
-
if (typeof registerHooks === "function") {
|
|
44
|
-
registerHooks({ resolve: resolveNextSubpath });
|
|
45
|
-
} else if (typeof registerHookModule === "function") {
|
|
46
|
-
registerHookModule(new URL("./next-subpath-resolve-hook-worker.js", import.meta.url), import.meta.url);
|
|
47
|
-
} else {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
installed = true;
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
function resetNextSubpathResolveHookForTests() {
|
|
54
|
-
installed = false;
|
|
55
|
-
}
|
|
56
|
-
export {
|
|
57
|
-
installNextSubpathResolveHook,
|
|
58
|
-
resetNextSubpathResolveHookForTests,
|
|
59
|
-
resolveNextSubpath,
|
|
60
|
-
resolveNextSubpathAsync
|
|
61
|
-
};
|
|
62
|
-
//# sourceMappingURL=next-subpath-resolve-hook.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/bootstrap/next-subpath-resolve-hook.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Node ESM resolution for `next/<subpath>` outside the Next bundler (#6118, #7102).\n *\n * Installed by the `mercato` CLI entry, so every CLI command and worker it\n * runs can load package dists that import `next/dynamic`, `next/link`,\n * `next/server` \u2026 The `next/dynamic` imports in particular MUST keep the bare\n * specifier: Next's `next_dynamic` compiler transform only matches it exactly.\n *\n * `mercato ai_assistant mcp:serve-http` (and `mcp:serve`, `mcp:dev`,\n * `mcp:list-tools`) run in plain Node, and the AI API operation runner loads\n * API route modules with a runtime `import()`. Those modules import\n * `NextResponse` from `'next/server'`: a bare package subpath that only a\n * bundler resolves, because `next` ships no `exports` map and Node's ESM\n * resolver does no extension guessing. Every route with that import (287 of\n * them on the 0.7.0 packages) therefore fails to load with\n * `ERR_MODULE_NOT_FOUND \u2026 node_modules/next/server`, and every tool backed by\n * such a route returns \"Failed to load route module\".\n *\n * `next/server.js`, `next/headers.js`, `next/navigation.js` \u2026 do exist as files,\n * which is why `@open-mercato/shared` imports `'next/headers.js'`. Rather than\n * rewriting several hundred imports across every package (and every\n * third-party module), this hook retries a failed bare `next/<subpath>` with\n * the `.js` suffix. It only runs in the processes that install it, only for\n * `next/*` specifiers, and only after the normal resolution failed, so it\n * becomes a no-op the day `next` publishes an `exports` map.\n */\nimport * as nodeModule from 'node:module'\n\ntype ResolveContext = Record<string, unknown>\ntype ResolveResult = { url: string; format?: string | null | undefined; shortCircuit?: boolean }\ntype NextResolve = (specifier: string, context?: ResolveContext) => ResolveResult\ntype AsyncNextResolve = (specifier: string, context?: ResolveContext) => ResolveResult | Promise<ResolveResult>\n\n/** Bare `next/<one segment>` specifiers, e.g. `next/server`, `next/headers`. */\nconst NEXT_BARE_SUBPATH = /^next\\/[A-Za-z0-9_-]+$/\n\nlet installed = false\n\nfunction isModuleNotFound(error: unknown): boolean {\n return Boolean(error) && typeof error === 'object' && (error as { code?: unknown }).code === 'ERR_MODULE_NOT_FOUND'\n}\n\n/**\n * The resolve hook itself. Exported so it can be tested without touching the\n * process-global hook chain.\n */\nexport function resolveNextSubpath(\n specifier: string,\n context: ResolveContext,\n nextResolve: NextResolve,\n): ResolveResult {\n if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)\n try {\n return nextResolve(specifier, context)\n } catch (error) {\n if (!isModuleNotFound(error)) throw error\n try {\n return nextResolve(`${specifier}.js`, context)\n } catch {\n // Neither form resolves (Next is not installed at all): surface the\n // original error, which names the specifier the caller wrote.\n throw error\n }\n }\n}\n\nexport async function resolveNextSubpathAsync(\n specifier: string,\n context: ResolveContext,\n nextResolve: AsyncNextResolve,\n): Promise<ResolveResult> {\n if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)\n try {\n return await nextResolve(specifier, context)\n } catch (error) {\n if (!isModuleNotFound(error)) throw error\n try {\n return await nextResolve(`${specifier}.js`, context)\n } catch {\n throw error\n }\n }\n}\n\ntype RegisterHooks = (hooks: { resolve: typeof resolveNextSubpath }) => unknown\ntype RegisterHookModule = (specifier: string | URL, parentURL?: string | URL) => unknown\n\n/**\n * `module.registerHooks` exists from Node 22.15. It is read off the namespace\n * rather than imported by name: in the published ESM build a named import of an\n * export the running Node lacks fails while the module links, before the check\n * in `installNextSubpathResolveHook` can run, and the package still declares\n * `node >= 22.0.0`.\n */\nfunction runtimeRegisterHooks(): RegisterHooks | undefined {\n const candidate = (nodeModule as unknown as { registerHooks?: unknown }).registerHooks\n return typeof candidate === 'function' ? (candidate as RegisterHooks) : undefined\n}\n\nfunction runtimeRegisterHookModule(): RegisterHookModule | undefined {\n const candidate = (nodeModule as unknown as { register?: unknown }).register\n return typeof candidate === 'function' ? (candidate as RegisterHookModule) : undefined\n}\n\n/**\n * Install the hook once per process. Returns `true` when it was installed by\n * this call, `false` when it already was or when the runtime has neither\n * registration API. Node 22.15+ uses the synchronous hook API; earlier\n * supported Node 22 releases use the asynchronous hook API.\n */\nexport function installNextSubpathResolveHook(\n registerHooks: RegisterHooks | null | undefined = runtimeRegisterHooks(),\n registerHookModule: RegisterHookModule | null | undefined = runtimeRegisterHookModule(),\n): boolean {\n if (installed) return false\n if (typeof registerHooks === 'function') {\n registerHooks({ resolve: resolveNextSubpath })\n } else if (typeof registerHookModule === 'function') {\n registerHookModule(new URL('./next-subpath-resolve-hook-worker.js', import.meta.url), import.meta.url)\n } else {\n return false\n }\n installed = true\n return true\n}\n\n/** Test seam: forget that the hook was installed. */\nexport function resetNextSubpathResolveHookForTests(): void {\n installed = false\n}\n"],
|
|
5
|
-
"mappings": "AA0BA,YAAY,gBAAgB;AAQ5B,MAAM,oBAAoB;AAE1B,IAAI,YAAY;AAEhB,SAAS,iBAAiB,OAAyB;AACjD,SAAO,QAAQ,KAAK,KAAK,OAAO,UAAU,YAAa,MAA6B,SAAS;AAC/F;AAMO,SAAS,mBACd,WACA,SACA,aACe;AACf,MAAI,CAAC,kBAAkB,KAAK,SAAS,EAAG,QAAO,YAAY,WAAW,OAAO;AAC7E,MAAI;AACF,WAAO,YAAY,WAAW,OAAO;AAAA,EACvC,SAAS,OAAO;AACd,QAAI,CAAC,iBAAiB,KAAK,EAAG,OAAM;AACpC,QAAI;AACF,aAAO,YAAY,GAAG,SAAS,OAAO,OAAO;AAAA,IAC/C,QAAQ;AAGN,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAEA,eAAsB,wBACpB,WACA,SACA,aACwB;AACxB,MAAI,CAAC,kBAAkB,KAAK,SAAS,EAAG,QAAO,YAAY,WAAW,OAAO;AAC7E,MAAI;AACF,WAAO,MAAM,YAAY,WAAW,OAAO;AAAA,EAC7C,SAAS,OAAO;AACd,QAAI,CAAC,iBAAiB,KAAK,EAAG,OAAM;AACpC,QAAI;AACF,aAAO,MAAM,YAAY,GAAG,SAAS,OAAO,OAAO;AAAA,IACrD,QAAQ;AACN,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAYA,SAAS,uBAAkD;AACzD,QAAM,YAAa,WAAsD;AACzE,SAAO,OAAO,cAAc,aAAc,YAA8B;AAC1E;AAEA,SAAS,4BAA4D;AACnE,QAAM,YAAa,WAAiD;AACpE,SAAO,OAAO,cAAc,aAAc,YAAmC;AAC/E;AAQO,SAAS,8BACd,gBAAkD,qBAAqB,GACvE,qBAA4D,0BAA0B,GAC7E;AACT,MAAI,UAAW,QAAO;AACtB,MAAI,OAAO,kBAAkB,YAAY;AACvC,kBAAc,EAAE,SAAS,mBAAmB,CAAC;AAAA,EAC/C,WAAW,OAAO,uBAAuB,YAAY;AACnD,uBAAmB,IAAI,IAAI,yCAAyC,YAAY,GAAG,GAAG,YAAY,GAAG;AAAA,EACvG,OAAO;AACL,WAAO;AAAA,EACT;AACA,cAAY;AACZ,SAAO;AACT;AAGO,SAAS,sCAA4C;AAC1D,cAAY;AACd;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
const ID_LIST_KEYS = [
|
|
2
|
-
"categoryIds",
|
|
3
|
-
"excludeCategoryIds",
|
|
4
|
-
"excludeProductIds",
|
|
5
|
-
"excludeTagIds",
|
|
6
|
-
"tagIds"
|
|
7
|
-
];
|
|
8
|
-
function compareStrings(left, right) {
|
|
9
|
-
if (left < right) return -1;
|
|
10
|
-
if (left > right) return 1;
|
|
11
|
-
return 0;
|
|
12
|
-
}
|
|
13
|
-
function sortedUnique(values) {
|
|
14
|
-
return Array.from(new Set(values)).sort(compareStrings);
|
|
15
|
-
}
|
|
16
|
-
function canonicalizeBranch(scope) {
|
|
17
|
-
const members = [];
|
|
18
|
-
const nested = (scope.allOf ?? []).map(canonicalizeBranch);
|
|
19
|
-
if (nested.length > 0) members.push(`"allOf":[${sortedUnique(nested).join(",")}]`);
|
|
20
|
-
for (const key of ID_LIST_KEYS) {
|
|
21
|
-
const ids = scope[key] ?? [];
|
|
22
|
-
if (ids.length > 0) members.push(`${JSON.stringify(key)}:${JSON.stringify(sortedUnique(ids))}`);
|
|
23
|
-
}
|
|
24
|
-
return `{${members.join(",")}}`;
|
|
25
|
-
}
|
|
26
|
-
function canonicalizeEffectiveScope(scope) {
|
|
27
|
-
if (scope === null) return "null";
|
|
28
|
-
return `[${sortedUnique(scope.map(canonicalizeBranch)).join(",")}]`;
|
|
29
|
-
}
|
|
30
|
-
export {
|
|
31
|
-
canonicalizeEffectiveScope
|
|
32
|
-
};
|
|
33
|
-
//# sourceMappingURL=canonicalizeEffectiveScope.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/catalog-visibility/canonicalizeEffectiveScope.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AssortmentScope, EffectiveAssortmentScope } from './types'\n\nconst ID_LIST_KEYS = [\n 'categoryIds',\n 'excludeCategoryIds',\n 'excludeProductIds',\n 'excludeTagIds',\n 'tagIds',\n] as const\n\nfunction compareStrings(left: string, right: string): number {\n if (left < right) return -1\n if (left > right) return 1\n return 0\n}\n\nfunction sortedUnique(values: string[]): string[] {\n return Array.from(new Set(values)).sort(compareStrings)\n}\n\nfunction canonicalizeBranch(scope: AssortmentScope): string {\n const members: string[] = []\n const nested = (scope.allOf ?? []).map(canonicalizeBranch)\n if (nested.length > 0) members.push(`\"allOf\":[${sortedUnique(nested).join(',')}]`)\n for (const key of ID_LIST_KEYS) {\n const ids = scope[key] ?? []\n if (ids.length > 0) members.push(`${JSON.stringify(key)}:${JSON.stringify(sortedUnique(ids))}`)\n }\n return `{${members.join(',')}}`\n}\n\n/**\n * Deterministic serialization of a resolved scope. `null` (unrestricted) serializes as `null`\n * and `[]` (deny-all) as `[]`, so the two never collide. Empty and absent arrays are\n * equivalent, id lists are sorted and deduplicated, and OR-branches and `allOf` entries are\n * ordered by their own canonical form with identical ones collapsed.\n */\nexport function canonicalizeEffectiveScope(scope: EffectiveAssortmentScope): string {\n if (scope === null) return 'null'\n return `[${sortedUnique(scope.map(canonicalizeBranch)).join(',')}]`\n}\n"],
|
|
5
|
-
"mappings": "AAEA,MAAM,eAAe;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,SAAS,eAAe,MAAc,OAAuB;AAC3D,MAAI,OAAO,MAAO,QAAO;AACzB,MAAI,OAAO,MAAO,QAAO;AACzB,SAAO;AACT;AAEA,SAAS,aAAa,QAA4B;AAChD,SAAO,MAAM,KAAK,IAAI,IAAI,MAAM,CAAC,EAAE,KAAK,cAAc;AACxD;AAEA,SAAS,mBAAmB,OAAgC;AAC1D,QAAM,UAAoB,CAAC;AAC3B,QAAM,UAAU,MAAM,SAAS,CAAC,GAAG,IAAI,kBAAkB;AACzD,MAAI,OAAO,SAAS,EAAG,SAAQ,KAAK,YAAY,aAAa,MAAM,EAAE,KAAK,GAAG,CAAC,GAAG;AACjF,aAAW,OAAO,cAAc;AAC9B,UAAM,MAAM,MAAM,GAAG,KAAK,CAAC;AAC3B,QAAI,IAAI,SAAS,EAAG,SAAQ,KAAK,GAAG,KAAK,UAAU,GAAG,CAAC,IAAI,KAAK,UAAU,aAAa,GAAG,CAAC,CAAC,EAAE;AAAA,EAChG;AACA,SAAO,IAAI,QAAQ,KAAK,GAAG,CAAC;AAC9B;AAQO,SAAS,2BAA2B,OAAyC;AAClF,MAAI,UAAU,KAAM,QAAO;AAC3B,SAAO,IAAI,aAAa,MAAM,IAAI,kBAAkB,CAAC,EAAE,KAAK,GAAG,CAAC;AAClE;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { canonicalizeEffectiveScope } from "./canonicalizeEffectiveScope.js";
|
|
3
|
-
function hashEffectiveScope(scope) {
|
|
4
|
-
return createHash("sha256").update(canonicalizeEffectiveScope(scope)).digest("hex").slice(0, 16);
|
|
5
|
-
}
|
|
6
|
-
export {
|
|
7
|
-
hashEffectiveScope
|
|
8
|
-
};
|
|
9
|
-
//# sourceMappingURL=hashEffectiveScope.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/catalog-visibility/hashEffectiveScope.ts"],
|
|
4
|
-
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport { canonicalizeEffectiveScope } from './canonicalizeEffectiveScope'\nimport type { EffectiveAssortmentScope } from './types'\n\n/** 16-hex-character sha256 digest of the canonical scope (SPEC-029 \u00A76.1 truncation). Server-only: uses node:crypto. */\nexport function hashEffectiveScope(scope: EffectiveAssortmentScope): string {\n return createHash('sha256').update(canonicalizeEffectiveScope(scope)).digest('hex').slice(0, 16)\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,kCAAkC;AAIpC,SAAS,mBAAmB,OAAyC;AAC1E,SAAO,WAAW,QAAQ,EAAE,OAAO,2BAA2B,KAAK,CAAC,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AACjG;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { calculateBackoffDelayMs } from "../delivery/retry.js";
|
|
2
|
-
import { isTransientLockDbError } from "./pg-errors.js";
|
|
3
|
-
const ADVISORY_LOCK_UNAVAILABLE_BRAND = /* @__PURE__ */ Symbol.for("@open-mercato/shared:AdvisoryLockUnavailableError");
|
|
4
|
-
const ADVISORY_LOCK_UNAVAILABLE_MESSAGES = {
|
|
5
|
-
deadline: "[internal] Advisory lock not acquired before the wait deadline",
|
|
6
|
-
transient_db: "[internal] Advisory lock transaction failed with a transient database error",
|
|
7
|
-
commit_unknown: "[internal] Advisory lock transaction failed after its callback returned; the commit outcome is unknown"
|
|
8
|
-
};
|
|
9
|
-
class AdvisoryLockUnavailableError extends Error {
|
|
10
|
-
static [Symbol.hasInstance](value) {
|
|
11
|
-
return typeof value === "object" && value !== null && Reflect.get(value, ADVISORY_LOCK_UNAVAILABLE_BRAND) === true;
|
|
12
|
-
}
|
|
13
|
-
constructor(reason, key, cause) {
|
|
14
|
-
super(ADVISORY_LOCK_UNAVAILABLE_MESSAGES[reason], cause === void 0 ? void 0 : { cause });
|
|
15
|
-
Object.defineProperty(this, ADVISORY_LOCK_UNAVAILABLE_BRAND, { value: true, enumerable: false });
|
|
16
|
-
this.name = "AdvisoryLockUnavailableError";
|
|
17
|
-
this.reason = reason;
|
|
18
|
-
this.key = key;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
const DEFAULT_WAIT_DEADLINE_MS = 15e3;
|
|
22
|
-
const BACKOFF_ATTEMPT_CAP = 5;
|
|
23
|
-
const BACKOFF_OPTIONS = { baseDelayMs: 50, maxJitterMs: 25 };
|
|
24
|
-
const KEY_NAMESPACE_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
25
|
-
const ADVISORY_LOCK_SQL = "select pg_try_advisory_xact_lock(hashtextextended(?, 0)) as locked";
|
|
26
|
-
const LOCK_FORK_OPTIONS = {
|
|
27
|
-
disableContextResolution: true,
|
|
28
|
-
clear: true,
|
|
29
|
-
useContext: false,
|
|
30
|
-
disableTransactions: false,
|
|
31
|
-
cloneEventManager: true
|
|
32
|
-
};
|
|
33
|
-
const GLOBAL_SLOTS_KEY = "__openMercatoAdvisoryLockSlots__";
|
|
34
|
-
const NOT_ACQUIRED = { acquired: false };
|
|
35
|
-
const releaseNothing = () => void 0;
|
|
36
|
-
function getSlotRegistry() {
|
|
37
|
-
const globals = globalThis;
|
|
38
|
-
const existing = globals[GLOBAL_SLOTS_KEY];
|
|
39
|
-
if (existing instanceof Map) return existing;
|
|
40
|
-
const created = /* @__PURE__ */ new Map();
|
|
41
|
-
globals[GLOBAL_SLOTS_KEY] = created;
|
|
42
|
-
return created;
|
|
43
|
-
}
|
|
44
|
-
function getNamespaceSlots(namespace) {
|
|
45
|
-
const registry = getSlotRegistry();
|
|
46
|
-
const existing = registry.get(namespace);
|
|
47
|
-
if (existing) return existing;
|
|
48
|
-
const created = { held: 0, waiters: [] };
|
|
49
|
-
registry.set(namespace, created);
|
|
50
|
-
return created;
|
|
51
|
-
}
|
|
52
|
-
function handOffSlots(slots) {
|
|
53
|
-
let index = 0;
|
|
54
|
-
while (index < slots.waiters.length) {
|
|
55
|
-
const waiter = slots.waiters[index];
|
|
56
|
-
if (slots.held >= waiter.limit) {
|
|
57
|
-
index += 1;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
slots.waiters.splice(index, 1);
|
|
61
|
-
slots.held += 1;
|
|
62
|
-
waiter.grant();
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
function createSlotRelease(slots) {
|
|
66
|
-
let released = false;
|
|
67
|
-
return () => {
|
|
68
|
-
if (released) return;
|
|
69
|
-
released = true;
|
|
70
|
-
slots.held -= 1;
|
|
71
|
-
handOffSlots(slots);
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
function acquireSlot(namespace, limit, key, deadlineAt) {
|
|
75
|
-
const slots = getNamespaceSlots(namespace);
|
|
76
|
-
if (slots.held < limit) {
|
|
77
|
-
slots.held += 1;
|
|
78
|
-
return Promise.resolve(createSlotRelease(slots));
|
|
79
|
-
}
|
|
80
|
-
const remainingMs = deadlineAt - Date.now();
|
|
81
|
-
if (remainingMs <= 0) return Promise.reject(new AdvisoryLockUnavailableError("deadline", key));
|
|
82
|
-
return new Promise((resolve, reject) => {
|
|
83
|
-
const waiter = {
|
|
84
|
-
limit,
|
|
85
|
-
grant: () => {
|
|
86
|
-
clearTimeout(deadlineTimer);
|
|
87
|
-
resolve(createSlotRelease(slots));
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
const deadlineTimer = setTimeout(() => {
|
|
91
|
-
const position = slots.waiters.indexOf(waiter);
|
|
92
|
-
if (position >= 0) slots.waiters.splice(position, 1);
|
|
93
|
-
reject(new AdvisoryLockUnavailableError("deadline", key));
|
|
94
|
-
}, remainingMs);
|
|
95
|
-
slots.waiters.push(waiter);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
function parseKeyNamespace(key) {
|
|
99
|
-
const separator = typeof key === "string" ? key.indexOf(":") : -1;
|
|
100
|
-
if (typeof key === "string" && separator > 0 && separator < key.length - 1) {
|
|
101
|
-
const namespace = key.slice(0, separator);
|
|
102
|
-
if (KEY_NAMESPACE_PATTERN.test(namespace)) return namespace;
|
|
103
|
-
}
|
|
104
|
-
throw new TypeError('[internal] Advisory lock key must be "<namespace>:<parts>" with a namespace matching ^[a-z][a-z0-9_]*$');
|
|
105
|
-
}
|
|
106
|
-
function resolveWaitDeadlineMs(value) {
|
|
107
|
-
if (value === void 0) return DEFAULT_WAIT_DEADLINE_MS;
|
|
108
|
-
if (Number.isFinite(value) && value >= 0) return value;
|
|
109
|
-
throw new TypeError("[internal] Advisory lock waitDeadlineMs must be a finite number of at least 0");
|
|
110
|
-
}
|
|
111
|
-
function resolveMaxConcurrentHolders(value) {
|
|
112
|
-
if (value === void 0) return null;
|
|
113
|
-
if (Number.isInteger(value) && value > 0) return value;
|
|
114
|
-
throw new TypeError("[internal] Advisory lock maxConcurrentHolders must be a positive integer");
|
|
115
|
-
}
|
|
116
|
-
function sleep(delayMs) {
|
|
117
|
-
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
118
|
-
}
|
|
119
|
-
async function attemptLock(lockEm, key, fn) {
|
|
120
|
-
const callback = { thrown: false, returned: false, error: void 0 };
|
|
121
|
-
try {
|
|
122
|
-
return await lockEm.transactional(async (txEm) => {
|
|
123
|
-
const rows = await txEm.execute(ADVISORY_LOCK_SQL, [key]);
|
|
124
|
-
if (rows[0]?.locked !== true) return NOT_ACQUIRED;
|
|
125
|
-
try {
|
|
126
|
-
const value = await fn(txEm);
|
|
127
|
-
callback.returned = true;
|
|
128
|
-
return { acquired: true, value };
|
|
129
|
-
} catch (error) {
|
|
130
|
-
callback.thrown = true;
|
|
131
|
-
callback.error = error;
|
|
132
|
-
throw error;
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
} catch (error) {
|
|
136
|
-
if (callback.thrown) throw callback.error;
|
|
137
|
-
if (isTransientLockDbError(error)) {
|
|
138
|
-
throw new AdvisoryLockUnavailableError(callback.returned ? "commit_unknown" : "transient_db", key, error);
|
|
139
|
-
}
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
async function withAdvisoryXactLock(em, key, fn, options = {}) {
|
|
144
|
-
const namespace = parseKeyNamespace(key);
|
|
145
|
-
const waitDeadlineMs = resolveWaitDeadlineMs(options.waitDeadlineMs);
|
|
146
|
-
const maxConcurrentHolders = resolveMaxConcurrentHolders(options.maxConcurrentHolders);
|
|
147
|
-
const deadlineAt = Date.now() + waitDeadlineMs;
|
|
148
|
-
const lockEm = em.fork(LOCK_FORK_OPTIONS);
|
|
149
|
-
for (let attempt = 1; ; attempt += 1) {
|
|
150
|
-
const releaseSlot = maxConcurrentHolders === null ? releaseNothing : await acquireSlot(namespace, maxConcurrentHolders, key, deadlineAt);
|
|
151
|
-
let outcome;
|
|
152
|
-
try {
|
|
153
|
-
outcome = await attemptLock(lockEm, key, fn);
|
|
154
|
-
} finally {
|
|
155
|
-
releaseSlot();
|
|
156
|
-
}
|
|
157
|
-
if (outcome.acquired) return outcome.value;
|
|
158
|
-
const remainingMs = deadlineAt - Date.now();
|
|
159
|
-
if (remainingMs <= 0) throw new AdvisoryLockUnavailableError("deadline", key);
|
|
160
|
-
await sleep(Math.min(calculateBackoffDelayMs(Math.min(attempt, BACKOFF_ATTEMPT_CAP), BACKOFF_OPTIONS), remainingMs));
|
|
161
|
-
if (options.onWait) {
|
|
162
|
-
const waitResult = await options.onWait();
|
|
163
|
-
if (waitResult.done) return waitResult.value;
|
|
164
|
-
}
|
|
165
|
-
if (Date.now() >= deadlineAt) throw new AdvisoryLockUnavailableError("deadline", key);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
export {
|
|
169
|
-
AdvisoryLockUnavailableError,
|
|
170
|
-
withAdvisoryXactLock
|
|
171
|
-
};
|
|
172
|
-
//# sourceMappingURL=advisoryLock.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/db/advisoryLock.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { calculateBackoffDelayMs } from '../delivery/retry'\nimport { isTransientLockDbError } from './pg-errors'\n\n/** @internal */\nexport type AdvisoryLockWaitResult<T> = { done: true; value: T } | { done: false }\n\nexport type AdvisoryLockOptions<T> = {\n /** Total waiting budget from the call start. Default 15_000. */\n waitDeadlineMs?: number\n /** @internal Called after each failed attempt, with no connection held; `done: true` short-circuits. */\n onWait?: () => Promise<AdvisoryLockWaitResult<T>>\n /** Per process and key namespace: at most this many lock transactions at once (attempts included).\n * Waiting for a slot holds no connection and counts against `waitDeadlineMs`. Default: unlimited. */\n maxConcurrentHolders?: number\n}\n\nconst ADVISORY_LOCK_UNAVAILABLE_BRAND = Symbol.for('@open-mercato/shared:AdvisoryLockUnavailableError')\n\n/**\n * The helper's own work failed with an error `isTransientLockDbError` accepts (kept as `cause`), or\n * the wait ran out. `deadline`: the wait budget ran out (contention or no free holder slot), so `fn`\n * never ran. `transient_db`: the begin or the lock query failed, so `fn` never ran. `commit_unknown`:\n * `fn` returned and the flush or commit after it failed, so its writes may or may not be committed.\n * `reason` is additive-only; branch on it, never on the message.\n */\nexport type AdvisoryLockUnavailableReason = 'deadline' | 'transient_db' | 'commit_unknown'\n\nconst ADVISORY_LOCK_UNAVAILABLE_MESSAGES: Record<AdvisoryLockUnavailableReason, string> = {\n deadline: '[internal] Advisory lock not acquired before the wait deadline',\n transient_db: '[internal] Advisory lock transaction failed with a transient database error',\n commit_unknown: '[internal] Advisory lock transaction failed after its callback returned; the commit outcome is unknown',\n}\n\nexport class AdvisoryLockUnavailableError extends Error {\n static [Symbol.hasInstance](value: unknown): boolean {\n return typeof value === 'object' && value !== null && Reflect.get(value, ADVISORY_LOCK_UNAVAILABLE_BRAND) === true\n }\n\n readonly reason: AdvisoryLockUnavailableReason\n readonly key: string\n constructor(reason: AdvisoryLockUnavailableReason, key: string, cause?: unknown) {\n super(ADVISORY_LOCK_UNAVAILABLE_MESSAGES[reason], cause === undefined ? undefined : { cause })\n Object.defineProperty(this, ADVISORY_LOCK_UNAVAILABLE_BRAND, { value: true, enumerable: false })\n this.name = 'AdvisoryLockUnavailableError'\n this.reason = reason\n this.key = key\n }\n}\n\nconst DEFAULT_WAIT_DEADLINE_MS = 15_000\nconst BACKOFF_ATTEMPT_CAP = 5\nconst BACKOFF_OPTIONS = { baseDelayMs: 50, maxJitterMs: 25 }\nconst KEY_NAMESPACE_PATTERN = /^[a-z][a-z0-9_]*$/\nconst ADVISORY_LOCK_SQL = 'select pg_try_advisory_xact_lock(hashtextextended(?, 0)) as locked'\n\n/**\n * `disableContextResolution` keeps the fork off an outer transaction resolved from the\n * async context, `useContext: false` keeps the fork's own transaction from joining one\n * later, `disableTransactions: false` keeps a source EntityManager with transactions disabled\n * from turning the lock into a statement-scoped no-op, and the cloned event manager carries\n * the request's subscribers (tenant field encryption among them) into the lock transaction.\n */\nconst LOCK_FORK_OPTIONS = {\n disableContextResolution: true,\n clear: true,\n useContext: false,\n disableTransactions: false,\n cloneEventManager: true,\n}\n\n/**\n * Holder slots live on `globalThis` so duplicated module instances in one process (dev\n * HMR, standalone server chunks) count against one limit. The map is created once and\n * never replaced; each namespace entry is created on first use and kept.\n */\nconst GLOBAL_SLOTS_KEY = '__openMercatoAdvisoryLockSlots__'\n\ntype SlotWaiter = {\n limit: number\n grant: () => void\n}\n\ntype NamespaceSlots = {\n held: number\n waiters: SlotWaiter[]\n}\n\ntype SlotRelease = () => void\n\ntype AttemptOutcome<T> = { acquired: true; value: T } | { acquired: false }\n\nconst NOT_ACQUIRED: AttemptOutcome<never> = { acquired: false }\n\nconst releaseNothing: SlotRelease = () => undefined\n\nfunction getSlotRegistry(): Map<string, NamespaceSlots> {\n const globals = globalThis as Record<string, unknown>\n const existing = globals[GLOBAL_SLOTS_KEY]\n if (existing instanceof Map) return existing as Map<string, NamespaceSlots>\n const created = new Map<string, NamespaceSlots>()\n globals[GLOBAL_SLOTS_KEY] = created\n return created\n}\n\nfunction getNamespaceSlots(namespace: string): NamespaceSlots {\n const registry = getSlotRegistry()\n const existing = registry.get(namespace)\n if (existing) return existing\n const created: NamespaceSlots = { held: 0, waiters: [] }\n registry.set(namespace, created)\n return created\n}\n\nfunction handOffSlots(slots: NamespaceSlots): void {\n let index = 0\n while (index < slots.waiters.length) {\n const waiter = slots.waiters[index]\n if (slots.held >= waiter.limit) {\n index += 1\n continue\n }\n slots.waiters.splice(index, 1)\n slots.held += 1\n waiter.grant()\n }\n}\n\nfunction createSlotRelease(slots: NamespaceSlots): SlotRelease {\n let released = false\n return () => {\n if (released) return\n released = true\n slots.held -= 1\n handOffSlots(slots)\n }\n}\n\nfunction acquireSlot(namespace: string, limit: number, key: string, deadlineAt: number): Promise<SlotRelease> {\n const slots = getNamespaceSlots(namespace)\n if (slots.held < limit) {\n slots.held += 1\n return Promise.resolve(createSlotRelease(slots))\n }\n const remainingMs = deadlineAt - Date.now()\n if (remainingMs <= 0) return Promise.reject(new AdvisoryLockUnavailableError('deadline', key))\n return new Promise<SlotRelease>((resolve, reject) => {\n const waiter: SlotWaiter = {\n limit,\n grant: () => {\n clearTimeout(deadlineTimer)\n resolve(createSlotRelease(slots))\n },\n }\n const deadlineTimer = setTimeout(() => {\n const position = slots.waiters.indexOf(waiter)\n if (position >= 0) slots.waiters.splice(position, 1)\n reject(new AdvisoryLockUnavailableError('deadline', key))\n }, remainingMs)\n slots.waiters.push(waiter)\n })\n}\n\nfunction parseKeyNamespace(key: unknown): string {\n const separator = typeof key === 'string' ? key.indexOf(':') : -1\n if (typeof key === 'string' && separator > 0 && separator < key.length - 1) {\n const namespace = key.slice(0, separator)\n if (KEY_NAMESPACE_PATTERN.test(namespace)) return namespace\n }\n throw new TypeError('[internal] Advisory lock key must be \"<namespace>:<parts>\" with a namespace matching ^[a-z][a-z0-9_]*$')\n}\n\nfunction resolveWaitDeadlineMs(value: number | undefined): number {\n if (value === undefined) return DEFAULT_WAIT_DEADLINE_MS\n if (Number.isFinite(value) && value >= 0) return value\n throw new TypeError('[internal] Advisory lock waitDeadlineMs must be a finite number of at least 0')\n}\n\nfunction resolveMaxConcurrentHolders(value: number | undefined): number | null {\n if (value === undefined) return null\n if (Number.isInteger(value) && value > 0) return value\n throw new TypeError('[internal] Advisory lock maxConcurrentHolders must be a positive integer')\n}\n\nfunction sleep(delayMs: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, delayMs))\n}\n\nasync function attemptLock<T>(\n lockEm: EntityManager,\n key: string,\n fn: (txEm: EntityManager) => Promise<T>,\n): Promise<AttemptOutcome<T>> {\n const callback: { thrown: boolean; returned: boolean; error: unknown } = { thrown: false, returned: false, error: undefined }\n try {\n return await lockEm.transactional(async (txEm): Promise<AttemptOutcome<T>> => {\n const rows = await txEm.execute<Array<{ locked: boolean }>>(ADVISORY_LOCK_SQL, [key])\n if (rows[0]?.locked !== true) return NOT_ACQUIRED\n try {\n const value = await fn(txEm)\n callback.returned = true\n return { acquired: true, value }\n } catch (error) {\n callback.thrown = true\n callback.error = error\n throw error\n }\n })\n } catch (error) {\n if (callback.thrown) throw callback.error\n if (isTransientLockDbError(error)) {\n throw new AdvisoryLockUnavailableError(callback.returned ? 'commit_unknown' : 'transient_db', key, error)\n }\n throw error\n }\n}\n\n/**\n * Runs `fn` in one transaction that holds `pg_try_advisory_xact_lock(hashtextextended(key, 0))`.\n * - `key` is `<namespace>:<parts>` with namespace `^[a-z][a-z0-9_]*$`; anything else throws `TypeError`.\n * - `waitDeadlineMs` must be a finite number \u2265 0 and `maxConcurrentHolders` a positive integer;\n * anything else throws `TypeError` before any connection is taken.\n * - Pass the request (container) EntityManager. The helper forks it with\n * `{ disableContextResolution: true, clear: true, useContext: false, disableTransactions: false, cloneEventManager: true }`,\n * so the lock transaction never nests in a caller's transaction and commits on its own.\n * - `fn` MUST do all DB I/O on `txEm`, MUST flush its own writes, MUST be bounded (\u2264 one external\n * call), MUST NOT call `transactional` again (it only opens a savepoint in the lock transaction) and\n * MUST NOT fork or use `getConnection().execute` (another pooled connection, outside the lock).\n * - `txEm` carries the request EntityManager's subscribers (cloned event manager).\n * - A failed attempt ends its empty transaction, so waiters hold no connection between attempts.\n * They back off 50 ms doubling up to 800 ms (plus up to 24 ms jitter), never past\n * `waitDeadlineMs` measured from the call start, then retry.\n * - With `maxConcurrentHolders`, each attempt first takes a per-process slot for the key\n * namespace and gives it back when the attempt fails or the lock transaction settles.\n * - Errors from the helper's own begin, lock query and commit that `isTransientLockDbError` accepts,\n * and the deadline, throw `AdvisoryLockUnavailableError` (its `reason` says whether `fn` ran); any\n * other error from them, and every error thrown by `fn`, propagates unchanged (a throw from `fn`\n * rolls back).\n */\nexport async function withAdvisoryXactLock<T>(\n em: EntityManager,\n key: string,\n fn: (txEm: EntityManager) => Promise<T>,\n options: AdvisoryLockOptions<T> = {},\n): Promise<T> {\n const namespace = parseKeyNamespace(key)\n const waitDeadlineMs = resolveWaitDeadlineMs(options.waitDeadlineMs)\n const maxConcurrentHolders = resolveMaxConcurrentHolders(options.maxConcurrentHolders)\n const deadlineAt = Date.now() + waitDeadlineMs\n const lockEm = em.fork(LOCK_FORK_OPTIONS)\n for (let attempt = 1; ; attempt += 1) {\n const releaseSlot = maxConcurrentHolders === null\n ? releaseNothing\n : await acquireSlot(namespace, maxConcurrentHolders, key, deadlineAt)\n let outcome: AttemptOutcome<T>\n try {\n outcome = await attemptLock(lockEm, key, fn)\n } finally {\n releaseSlot()\n }\n if (outcome.acquired) return outcome.value\n const remainingMs = deadlineAt - Date.now()\n if (remainingMs <= 0) throw new AdvisoryLockUnavailableError('deadline', key)\n await sleep(Math.min(calculateBackoffDelayMs(Math.min(attempt, BACKOFF_ATTEMPT_CAP), BACKOFF_OPTIONS), remainingMs))\n if (options.onWait) {\n const waitResult = await options.onWait()\n if (waitResult.done) return waitResult.value\n }\n if (Date.now() >= deadlineAt) throw new AdvisoryLockUnavailableError('deadline', key)\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { sql } from "kysely";
|
|
2
|
-
const LITERAL_SAFE_DOC_KEY = /^[A-Za-z0-9_:.-]{1,128}$/;
|
|
3
|
-
function normalizeOverlapValues(value) {
|
|
4
|
-
const list = Array.isArray(value) ? value : value === void 0 || value === null ? [] : [value];
|
|
5
|
-
const out = [];
|
|
6
|
-
const seen = /* @__PURE__ */ new Set();
|
|
7
|
-
for (const entry of list) {
|
|
8
|
-
if (entry === void 0 || entry === null) continue;
|
|
9
|
-
const normalized = String(entry);
|
|
10
|
-
if (seen.has(normalized)) continue;
|
|
11
|
-
seen.add(normalized);
|
|
12
|
-
out.push(normalized);
|
|
13
|
-
}
|
|
14
|
-
return out;
|
|
15
|
-
}
|
|
16
|
-
function jsonbDocKeyExpression(docColumnRef, key) {
|
|
17
|
-
const keyExpr = LITERAL_SAFE_DOC_KEY.test(key) ? sql.lit(key) : sql`${key}`;
|
|
18
|
-
return sql`(${sql.ref(docColumnRef)} -> ${keyExpr})`;
|
|
19
|
-
}
|
|
20
|
-
function buildJsonbOverlapPredicate(docColumnRef, key, value) {
|
|
21
|
-
const values = normalizeOverlapValues(value);
|
|
22
|
-
if (!values.length) return sql`false`;
|
|
23
|
-
return sql`${jsonbDocKeyExpression(docColumnRef, key)} ?| ${values}::text[]`;
|
|
24
|
-
}
|
|
25
|
-
function buildJsonbNoOverlapPredicate(docColumnRef, key, value) {
|
|
26
|
-
const values = normalizeOverlapValues(value);
|
|
27
|
-
const keyExpr = jsonbDocKeyExpression(docColumnRef, key);
|
|
28
|
-
const isArray = sql`jsonb_typeof(${keyExpr}) = 'array'`;
|
|
29
|
-
if (!values.length) return isArray;
|
|
30
|
-
return sql`(${isArray} and not (${keyExpr} ?| ${values}::text[]))`;
|
|
31
|
-
}
|
|
32
|
-
function buildArrayColumnOverlapPredicate(column, value) {
|
|
33
|
-
const values = normalizeOverlapValues(value);
|
|
34
|
-
if (!values.length) return sql`false`;
|
|
35
|
-
const columnExpr = typeof column === "string" ? sql.ref(column) : column;
|
|
36
|
-
return sql`${columnExpr} && ${values}`;
|
|
37
|
-
}
|
|
38
|
-
function buildArrayColumnNoOverlapPredicate(column, value) {
|
|
39
|
-
const values = normalizeOverlapValues(value);
|
|
40
|
-
const columnExpr = typeof column === "string" ? sql.ref(column) : column;
|
|
41
|
-
if (!values.length) return sql`${columnExpr} is not null`;
|
|
42
|
-
return sql`not (${columnExpr} && ${values})`;
|
|
43
|
-
}
|
|
44
|
-
function buildScalarOverlapPredicate(expression, value) {
|
|
45
|
-
const values = normalizeOverlapValues(value);
|
|
46
|
-
if (!values.length) return sql`false`;
|
|
47
|
-
return sql`${expression} in (${sql.join(values.map((entry) => sql`${entry}`), sql`, `)})`;
|
|
48
|
-
}
|
|
49
|
-
export {
|
|
50
|
-
buildArrayColumnNoOverlapPredicate,
|
|
51
|
-
buildArrayColumnOverlapPredicate,
|
|
52
|
-
buildJsonbNoOverlapPredicate,
|
|
53
|
-
buildJsonbOverlapPredicate,
|
|
54
|
-
buildScalarOverlapPredicate,
|
|
55
|
-
jsonbDocKeyExpression,
|
|
56
|
-
normalizeOverlapValues
|
|
57
|
-
};
|
|
58
|
-
//# sourceMappingURL=overlap.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/lib/query/overlap.ts"],
|
|
4
|
-
"sourcesContent": ["import { sql, type RawBuilder } from 'kysely'\n\nconst LITERAL_SAFE_DOC_KEY = /^[A-Za-z0-9_:.-]{1,128}$/\n\n/**\n * Normalizes the right-hand side of an `overlap` filter into a de-duplicated list of strings.\n *\n * `null`/`undefined` entries are dropped; a scalar is treated as a one-element list. An empty\n * result means the filter matches nothing \u2014 every `overlap` builder compiles it to `false`\n * rather than dropping the predicate, so a caller that computes an empty inclusion set can\n * never widen a query by accident.\n */\nexport function normalizeOverlapValues(value: unknown): string[] {\n const list: readonly unknown[] = Array.isArray(value)\n ? value\n : value === undefined || value === null\n ? []\n : [value]\n const out: string[] = []\n const seen = new Set<string>()\n for (const entry of list) {\n if (entry === undefined || entry === null) continue\n const normalized = String(entry)\n if (seen.has(normalized)) continue\n seen.add(normalized)\n out.push(normalized)\n }\n return out\n}\n\n/**\n * `(<docColumn> -> '<key>')` \u2014 the jsonb sub-document of an index document.\n *\n * The key is emitted as an escaped SQL literal when it is a plain identifier so the expression is\n * textually identical to a GIN expression index such as `((doc -> 'scope_keys'))` and the planner\n * can match it. Any other key falls back to a bound parameter (still correct, just not indexable).\n */\nexport function jsonbDocKeyExpression(docColumnRef: string, key: string): RawBuilder<unknown> {\n const keyExpr = LITERAL_SAFE_DOC_KEY.test(key) ? sql.lit(key) : sql`${key}`\n return sql`(${sql.ref(docColumnRef)} -> ${keyExpr})`\n}\n\n/**\n * jsonb \"has any of\" over a string-array key of an index document:\n * `(<docColumn> -> '<key>') ?| $n::text[]`. The values are bound as one array parameter.\n * A missing key or a JSON `null` evaluates to SQL NULL, i.e. the row does not match.\n */\nexport function buildJsonbOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n if (!values.length) return sql<boolean>`false`\n return sql<boolean>`${jsonbDocKeyExpression(docColumnRef, key)} ?| ${values}::text[]`\n}\n\n/**\n * jsonb \"has none of\" over a string-array key of an index document (`noverlap`):\n * `(jsonb_typeof(<expr>) = 'array' and not (<expr> ?| $n::text[]))`.\n *\n * Only a document whose key holds a JSON array can match: a missing key, a JSON `null` or a\n * scalar never satisfies it, so an unindexed document can never slip through an exclusion.\n * An empty value list excludes nothing (only the array check remains).\n */\nexport function buildJsonbNoOverlapPredicate(docColumnRef: string, key: string, value: unknown): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n const keyExpr = jsonbDocKeyExpression(docColumnRef, key)\n const isArray = sql<boolean>`jsonb_typeof(${keyExpr}) = 'array'`\n if (!values.length) return isArray\n return sql<boolean>`(${isArray} and not (${keyExpr} ?| ${values}::text[]))`\n}\n\n/**\n * Postgres array overlap for array-typed columns: `<column> && $n`. The bound array takes its\n * element type from the column, so `text[]`, `varchar[]` and `uuid[]` columns all work.\n */\nexport function buildArrayColumnOverlapPredicate(\n column: string | RawBuilder<unknown>,\n value: unknown,\n): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n if (!values.length) return sql<boolean>`false`\n const columnExpr = typeof column === 'string' ? sql.ref(column) : column\n return sql<boolean>`${columnExpr} && ${values}`\n}\n\n/**\n * Postgres array \"has none of\" for array-typed columns (`noverlap`): `not (<column> && $n)`.\n * A NULL column never matches; an empty value list excludes nothing (`<column> is not null`).\n */\nexport function buildArrayColumnNoOverlapPredicate(\n column: string | RawBuilder<unknown>,\n value: unknown,\n): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n const columnExpr = typeof column === 'string' ? sql.ref(column) : column\n if (!values.length) return sql<boolean>`${columnExpr} is not null`\n return sql<boolean>`not (${columnExpr} && ${values})`\n}\n\n/**\n * `overlap` over a scalar expression evaluated once per stored value (custom-field value rows):\n * the record matches when any of its values is one of the requested ones \u2014 `<expr> in (...)`.\n */\nexport function buildScalarOverlapPredicate(expression: RawBuilder<unknown>, value: unknown): RawBuilder<boolean> {\n const values = normalizeOverlapValues(value)\n if (!values.length) return sql<boolean>`false`\n return sql<boolean>`${expression} in (${sql.join(values.map((entry) => sql`${entry}`), sql`, `)})`\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,WAA4B;AAErC,MAAM,uBAAuB;AAUtB,SAAS,uBAAuB,OAA0B;AAC/D,QAAM,OAA2B,MAAM,QAAQ,KAAK,IAChD,QACA,UAAU,UAAa,UAAU,OAC/B,CAAC,IACD,CAAC,KAAK;AACZ,QAAM,MAAgB,CAAC;AACvB,QAAM,OAAO,oBAAI,IAAY;AAC7B,aAAW,SAAS,MAAM;AACxB,QAAI,UAAU,UAAa,UAAU,KAAM;AAC3C,UAAM,aAAa,OAAO,KAAK;AAC/B,QAAI,KAAK,IAAI,UAAU,EAAG;AAC1B,SAAK,IAAI,UAAU;AACnB,QAAI,KAAK,UAAU;AAAA,EACrB;AACA,SAAO;AACT;AASO,SAAS,sBAAsB,cAAsB,KAAkC;AAC5F,QAAM,UAAU,qBAAqB,KAAK,GAAG,IAAI,IAAI,IAAI,GAAG,IAAI,MAAM,GAAG;AACzE,SAAO,OAAO,IAAI,IAAI,YAAY,CAAC,OAAO,OAAO;AACnD;AAOO,SAAS,2BAA2B,cAAsB,KAAa,OAAqC;AACjH,QAAM,SAAS,uBAAuB,KAAK;AAC3C,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,MAAe,sBAAsB,cAAc,GAAG,CAAC,OAAO,MAAM;AAC7E;AAUO,SAAS,6BAA6B,cAAsB,KAAa,OAAqC;AACnH,QAAM,SAAS,uBAAuB,KAAK;AAC3C,QAAM,UAAU,sBAAsB,cAAc,GAAG;AACvD,QAAM,UAAU,mBAA4B,OAAO;AACnD,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,OAAgB,OAAO,aAAa,OAAO,OAAO,MAAM;AACjE;AAMO,SAAS,iCACd,QACA,OACqB;AACrB,QAAM,SAAS,uBAAuB,KAAK;AAC3C,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,QAAM,aAAa,OAAO,WAAW,WAAW,IAAI,IAAI,MAAM,IAAI;AAClE,SAAO,MAAe,UAAU,OAAO,MAAM;AAC/C;AAMO,SAAS,mCACd,QACA,OACqB;AACrB,QAAM,SAAS,uBAAuB,KAAK;AAC3C,QAAM,aAAa,OAAO,WAAW,WAAW,IAAI,IAAI,MAAM,IAAI;AAClE,MAAI,CAAC,OAAO,OAAQ,QAAO,MAAe,UAAU;AACpD,SAAO,WAAoB,UAAU,OAAO,MAAM;AACpD;AAMO,SAAS,4BAA4B,YAAiC,OAAqC;AAChH,QAAM,SAAS,uBAAuB,KAAK;AAC3C,MAAI,CAAC,OAAO,OAAQ,QAAO;AAC3B,SAAO,MAAe,UAAU,QAAQ,IAAI,KAAK,OAAO,IAAI,CAAC,UAAU,MAAM,KAAK,EAAE,GAAG,OAAO,CAAC;AACjG;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
const SENTINEL_ORIGIN = "https://notifications.open-mercato.local";
|
|
2
|
-
const CONTROL_OR_BACKSLASH = /[\u0000-\u001F\u007F\\]/;
|
|
3
|
-
function isSafeNotificationHref(href) {
|
|
4
|
-
if (!href.startsWith("/") || href.startsWith("//")) {
|
|
5
|
-
return false;
|
|
6
|
-
}
|
|
7
|
-
if (CONTROL_OR_BACKSLASH.test(href)) {
|
|
8
|
-
return false;
|
|
9
|
-
}
|
|
10
|
-
try {
|
|
11
|
-
const parsed = new URL(href, SENTINEL_ORIGIN);
|
|
12
|
-
return parsed.origin === SENTINEL_ORIGIN && !parsed.pathname.startsWith("//");
|
|
13
|
-
} catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
export {
|
|
18
|
-
isSafeNotificationHref
|
|
19
|
-
};
|
|
20
|
-
//# sourceMappingURL=safeHref.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/modules/notifications/safeHref.ts"],
|
|
4
|
-
"sourcesContent": ["const SENTINEL_ORIGIN = 'https://notifications.open-mercato.local'\nconst CONTROL_OR_BACKSLASH = /[\\u0000-\\u001F\\u007F\\\\]/\n\nexport function isSafeNotificationHref(href: string): boolean {\n if (!href.startsWith('/') || href.startsWith('//')) {\n return false\n }\n\n if (CONTROL_OR_BACKSLASH.test(href)) {\n return false\n }\n\n try {\n const parsed = new URL(href, SENTINEL_ORIGIN)\n return parsed.origin === SENTINEL_ORIGIN && !parsed.pathname.startsWith('//')\n } catch {\n return false\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,MAAM,kBAAkB;AACxB,MAAM,uBAAuB;AAEtB,SAAS,uBAAuB,MAAuB;AAC5D,MAAI,CAAC,KAAK,WAAW,GAAG,KAAK,KAAK,WAAW,IAAI,GAAG;AAClD,WAAO;AAAA,EACT;AAEA,MAAI,qBAAqB,KAAK,IAAI,GAAG;AACnC,WAAO;AAAA,EACT;AAEA,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,MAAM,eAAe;AAC5C,WAAO,OAAO,WAAW,mBAAmB,CAAC,OAAO,SAAS,WAAW,IAAI;AAAA,EAC9E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { isCrossSiteBrowserRequest } from '../url'
|
|
2
|
-
|
|
3
|
-
function req(headers: Record<string, string>, url = 'https://app.example.com/api/auth/login') {
|
|
4
|
-
return new Request(url, { method: 'POST', headers })
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
const configured = { APP_URL: 'https://app.example.com', NODE_ENV: 'production' }
|
|
8
|
-
const unconfiguredDev = { NODE_ENV: 'development' }
|
|
9
|
-
|
|
10
|
-
describe('isCrossSiteBrowserRequest', () => {
|
|
11
|
-
describe('non-browser callers (no Origin, no Sec-Fetch-Site) are never cross-site', () => {
|
|
12
|
-
test.each([
|
|
13
|
-
['configured', configured],
|
|
14
|
-
['unconfigured dev', unconfiguredDev],
|
|
15
|
-
])('%s', (_label, env) => {
|
|
16
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com' }), env)).toBe(false)
|
|
17
|
-
})
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
describe('Fetch Metadata is authoritative when present', () => {
|
|
21
|
-
test('cross-site is rejected regardless of Origin', () => {
|
|
22
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'cross-site' }), configured)).toBe(true)
|
|
23
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'cross-site', origin: 'https://app.example.com' }), configured)).toBe(true)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('same-origin and none are accepted even when the proxy hides the public host', () => {
|
|
27
|
-
const internal = 'http://app-internal:3000/api/auth/login'
|
|
28
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', origin: 'https://public.example.net', 'sec-fetch-site': 'same-origin' }, internal), {})).toBe(false)
|
|
29
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', 'sec-fetch-site': 'none' }, internal), {})).toBe(false)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('same-site falls back to the Origin allowlist', () => {
|
|
33
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://blog.example.com', 'sec-fetch-site': 'same-site' }), configured)).toBe(true)
|
|
34
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://ops.example.com', 'sec-fetch-site': 'same-site' }), {
|
|
35
|
-
...configured,
|
|
36
|
-
APP_ALLOWED_ORIGINS: 'https://ops.example.com',
|
|
37
|
-
})).toBe(false)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('an unknown Sec-Fetch-Site token falls back to the Origin check', () => {
|
|
41
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://evil.example', 'sec-fetch-site': 'bogus' }), configured)).toBe(true)
|
|
42
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://app.example.com', 'sec-fetch-site': 'bogus' }), configured)).toBe(false)
|
|
43
|
-
})
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
describe('Origin check (browsers without Fetch Metadata)', () => {
|
|
47
|
-
test('Origin: null is cross-site', () => {
|
|
48
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'null' }), configured)).toBe(true)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('a malformed Origin is cross-site', () => {
|
|
52
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'not a url' }), configured)).toBe(true)
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('a foreign Origin is cross-site', () => {
|
|
56
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://evil.example' }), configured)).toBe(true)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test('Origin matching a configured origin (APP_URL / NEXT_PUBLIC_APP_URL / APP_ALLOWED_ORIGINS) is accepted', () => {
|
|
60
|
-
const internal = 'http://app-internal:3000/api/auth/login'
|
|
61
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', origin: 'https://app.example.com' }, internal), configured)).toBe(false)
|
|
62
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', origin: 'https://admin.example.com' }, internal), {
|
|
63
|
-
NEXT_PUBLIC_APP_URL: 'https://admin.example.com',
|
|
64
|
-
})).toBe(false)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
test('Origin matching the request Host authority is accepted (portal custom domains, unconfigured dev)', () => {
|
|
68
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'shop.acme.test', origin: 'https://shop.acme.test' }, 'https://shop.acme.test/api/customer_accounts/login'), configured)).toBe(false)
|
|
69
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'localhost:3000', origin: 'http://localhost:3000' }, 'http://localhost:3000/api/auth/login'), unconfiguredDev)).toBe(false)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
test('Origin matching X-Forwarded-Host is accepted even when the proxy did not forward the scheme', () => {
|
|
73
|
-
expect(isCrossSiteBrowserRequest(req({
|
|
74
|
-
host: 'app-internal:3000',
|
|
75
|
-
'x-forwarded-host': 'admin.example.com',
|
|
76
|
-
origin: 'https://admin.example.com',
|
|
77
|
-
}, 'http://app-internal:3000/api/auth/login'), {})).toBe(false)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
test('a different port on the same host is cross-origin', () => {
|
|
81
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://app.example.com:8443' }), configured)).toBe(true)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test('a Host carrying the default port matches the Origin', () => {
|
|
85
|
-
expect(isCrossSiteBrowserRequest(req({
|
|
86
|
-
host: 'app.example.com:443',
|
|
87
|
-
'x-forwarded-proto': 'https',
|
|
88
|
-
origin: 'https://app.example.com',
|
|
89
|
-
}, 'http://app.example.com:443/api/auth/login'), {})).toBe(false)
|
|
90
|
-
expect(isCrossSiteBrowserRequest(req({
|
|
91
|
-
host: 'app.example.com:443',
|
|
92
|
-
origin: 'https://app.example.com',
|
|
93
|
-
}, 'http://app.example.com:443/api/auth/login'), {})).toBe(false)
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
test('an uppercase Host matches the Origin', () => {
|
|
97
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'APP.Example.COM', origin: 'https://app.example.com' }), {})).toBe(false)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test('an IPv6 loopback Host matches the Origin in dev', () => {
|
|
101
|
-
expect(isCrossSiteBrowserRequest(req({ host: '[::1]:3000', origin: 'http://[::1]:3000' }, 'http://[::1]:3000/api/auth/login'), unconfiguredDev)).toBe(false)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('only the first X-Forwarded-Host value is used', () => {
|
|
105
|
-
const internal = 'http://app-internal:3000/api/auth/login'
|
|
106
|
-
expect(isCrossSiteBrowserRequest(req({
|
|
107
|
-
host: 'app-internal:3000',
|
|
108
|
-
'x-forwarded-host': 'admin.example.com, evil.example',
|
|
109
|
-
origin: 'https://admin.example.com',
|
|
110
|
-
}, internal), {})).toBe(false)
|
|
111
|
-
expect(isCrossSiteBrowserRequest(req({
|
|
112
|
-
host: 'app-internal:3000',
|
|
113
|
-
'x-forwarded-host': 'admin.example.com, evil.example',
|
|
114
|
-
origin: 'https://evil.example',
|
|
115
|
-
}, internal), {})).toBe(true)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
test('a forwarded scheme that differs from the Origin scheme is cross-origin', () => {
|
|
119
|
-
expect(isCrossSiteBrowserRequest(req({
|
|
120
|
-
host: 'app.example.com',
|
|
121
|
-
'x-forwarded-proto': 'https',
|
|
122
|
-
origin: 'http://app.example.com',
|
|
123
|
-
}), {})).toBe(true)
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
describe('Fetch Metadata without Origin', () => {
|
|
128
|
-
test('same-site without Origin is cross-site', () => {
|
|
129
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'same-site' }), configured)).toBe(true)
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
test('an unknown token without Origin is cross-site', () => {
|
|
133
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'bogus' }), configured)).toBe(true)
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
test('the Sec-Fetch-Site value is compared case- and whitespace-insensitively', () => {
|
|
137
|
-
expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://app.example.com', 'sec-fetch-site': ' Cross-Site ' }), configured)).toBe(true)
|
|
138
|
-
})
|
|
139
|
-
})
|
|
140
|
-
})
|