@open-mercato/shared 0.6.8-develop.7099.1.7ba2771d06 → 0.7.0
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 -4
- package/dist/lib/auth/jwt.js +0 -6
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/organizationAccess.js +4 -7
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/server.js +7 -38
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +1 -6
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +8 -24
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +2 -8
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/location/countries.js +0 -12
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +1 -4
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/engine.js +34 -270
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/config.js +0 -1
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +15 -69
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +0 -15
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +3 -10
- package/src/lib/auth/__tests__/jwt.test.ts +0 -13
- package/src/lib/auth/__tests__/organizationAccess.test.ts +1 -36
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +0 -324
- package/src/lib/auth/__tests__/server.test.ts +0 -104
- package/src/lib/auth/jwt.ts +0 -17
- package/src/lib/auth/organizationAccess.ts +3 -11
- package/src/lib/auth/server.ts +8 -78
- package/src/lib/commands/__tests__/command-bus.test.ts +0 -31
- package/src/lib/commands/command-bus.ts +1 -8
- package/src/lib/crud/__tests__/crud-factory.test.ts +0 -165
- package/src/lib/crud/factory.ts +7 -33
- package/src/lib/data/__tests__/engine.event-validation.test.ts +1 -9
- package/src/lib/data/engine.ts +1 -7
- package/src/lib/location/__tests__/countries.test.ts +0 -15
- package/src/lib/location/countries.ts +0 -17
- package/src/lib/openapi/crud.ts +0 -3
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +15 -162
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +1 -11
- package/src/lib/query/__tests__/engine.test.ts +7 -445
- package/src/lib/query/engine.ts +54 -434
- package/src/lib/query/types.ts +0 -15
- package/src/lib/search/config.ts +0 -10
- package/src/modules/events/factory.ts +19 -111
- package/src/modules/events/types.ts +0 -17
- package/src/modules/registry.ts +0 -40
- package/src/modules/widgets/component-registry.ts +0 -14
- package/dist/lib/auth/mfaPendingAccess.js +0 -42
- package/dist/lib/auth/mfaPendingAccess.js.map +0 -7
- package/dist/lib/auth/principal-service.js +0 -1
- package/dist/lib/auth/principal-service.js.map +0 -7
- package/dist/lib/html/htmlToPlainText.js +0 -16
- package/dist/lib/html/htmlToPlainText.js.map +0 -7
- package/dist/lib/query/count-cap.js +0 -11
- package/dist/lib/query/count-cap.js.map +0 -7
- package/dist/lib/queue/dispatchOrigin.js +0 -20
- package/dist/lib/queue/dispatchOrigin.js.map +0 -7
- package/dist/lib/search/entityAccess.js +0 -44
- package/dist/lib/search/entityAccess.js.map +0 -7
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +0 -69
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +0 -67
- package/src/lib/auth/mfaPendingAccess.ts +0 -70
- package/src/lib/auth/principal-service.ts +0 -110
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +0 -59
- package/src/lib/html/htmlToPlainText.ts +0 -17
- package/src/lib/query/__tests__/count-cap-plan.test.ts +0 -240
- package/src/lib/query/__tests__/count-cap.test.ts +0 -41
- package/src/lib/query/count-cap.ts +0 -19
- package/src/lib/queue/dispatchOrigin.ts +0 -35
- package/src/lib/search/entityAccess.ts +0 -132
- package/src/modules/events/__tests__/factory.test.ts +0 -88
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
|
|
4
|
-
const PACKAGE_ROOT = join(__dirname, '..', '..', '..', '..')
|
|
5
|
-
const SUBPATH = './lib/auth/principal-service'
|
|
6
|
-
|
|
7
|
-
type ExportTarget = { types?: string | string[]; default?: string }
|
|
8
|
-
|
|
9
|
-
function readExports(): Record<string, ExportTarget | string> {
|
|
10
|
-
const packageJson = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as {
|
|
11
|
-
exports?: Record<string, ExportTarget | string>
|
|
12
|
-
}
|
|
13
|
-
return packageJson.exports ?? {}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Node's subpath resolution: an exact key wins outright, and only when none
|
|
18
|
-
* matches do the `*` patterns compete on the longest static prefix. Mirroring
|
|
19
|
-
* it here is the point of the test — a subpath that resolves only through a
|
|
20
|
-
* wildcard is at the mercy of whichever resolver a consumer happens to run
|
|
21
|
-
* (packed tarball, bundler, Jest), and those do not agree on wildcard handling.
|
|
22
|
-
*/
|
|
23
|
-
function resolveSubpath(exports: Record<string, ExportTarget | string>, subpath: string): string | null {
|
|
24
|
-
if (Object.prototype.hasOwnProperty.call(exports, subpath)) return subpath
|
|
25
|
-
let best: string | null = null
|
|
26
|
-
for (const key of Object.keys(exports)) {
|
|
27
|
-
const star = key.indexOf('*')
|
|
28
|
-
if (star < 0) continue
|
|
29
|
-
const prefix = key.slice(0, star)
|
|
30
|
-
const suffix = key.slice(star + 1)
|
|
31
|
-
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue
|
|
32
|
-
if (subpath.length < prefix.length + suffix.length) continue
|
|
33
|
-
if (!best || prefix.length > best.slice(0, best.indexOf('*')).length) best = key
|
|
34
|
-
}
|
|
35
|
-
return best
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('@open-mercato/shared principal-service export map', () => {
|
|
39
|
-
const exports = readExports()
|
|
40
|
-
|
|
41
|
-
it('resolves through an explicit entry rather than a wildcard pattern', () => {
|
|
42
|
-
expect(resolveSubpath(exports, SUBPATH)).toBe(SUBPATH)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('maps types to the source module and default to the built module', () => {
|
|
46
|
-
const target = exports[SUBPATH]
|
|
47
|
-
expect(typeof target).toBe('object')
|
|
48
|
-
expect(target).toEqual({
|
|
49
|
-
types: './src/lib/auth/principal-service.ts',
|
|
50
|
-
default: './dist/lib/auth/principal-service.js',
|
|
51
|
-
})
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
// Both halves of the mapping must exist in a packed package. `default` is
|
|
55
|
-
// produced by `yarn build:packages`, which the validation gate runs before
|
|
56
|
-
// `yarn test`.
|
|
57
|
-
it('ships both mapped files', () => {
|
|
58
|
-
const target = exports[SUBPATH] as ExportTarget
|
|
59
|
-
expect(existsSync(join(PACKAGE_ROOT, target.types as string))).toBe(true)
|
|
60
|
-
expect(existsSync(join(PACKAGE_ROOT, target.default as string))).toBe(true)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('keeps the module importable under the mapped default condition', async () => {
|
|
64
|
-
const target = exports[SUBPATH] as ExportTarget
|
|
65
|
-
await expect(import(join(PACKAGE_ROOT, target.default as string))).resolves.toBeDefined()
|
|
66
|
-
})
|
|
67
|
-
})
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
export type MfaPendingAccessRoute = {
|
|
2
|
-
path: string
|
|
3
|
-
methods: string[]
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
const registeredMethodsByPath = new Map<string, Set<string>>()
|
|
7
|
-
|
|
8
|
-
function normalizePath(path: string): string | null {
|
|
9
|
-
if (typeof path !== 'string') return null
|
|
10
|
-
const trimmed = path.trim()
|
|
11
|
-
if (!trimmed || !trimmed.startsWith('/')) return null
|
|
12
|
-
return (trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed).toLowerCase() || '/'
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function normalizeMethods(methods: unknown): string[] {
|
|
16
|
-
if (!Array.isArray(methods)) return []
|
|
17
|
-
const normalized = methods
|
|
18
|
-
.filter((method): method is string => typeof method === 'string' && method.trim().length > 0)
|
|
19
|
-
.map((method) => method.trim().toUpperCase())
|
|
20
|
-
return Array.from(new Set(normalized)).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Register routes that may accept an MFA-pending staff token. The registry starts empty and
|
|
25
|
-
* stays generic: every module that completes an MFA challenge on its own endpoints registers
|
|
26
|
-
* them here during its bootstrap — the canonical `/api/security/mfa/*` completion routes are
|
|
27
|
-
* registered by the enterprise `security` module (`lib/mfaCompletionRoutes.ts`), third-party
|
|
28
|
-
* MFA implementations register theirs the same way. Registration is additive: methods merge
|
|
29
|
-
* into any existing entry for the same path and are never removed.
|
|
30
|
-
*
|
|
31
|
-
* Contract defined in `.ai/specs/enterprise/implemented/SPEC-ENT-007-2026-03-06-auth-login-interceptors-extension.md`
|
|
32
|
-
* (§ Amendment 2026-08-21 — central MFA-pending token gate).
|
|
33
|
-
*/
|
|
34
|
-
export function registerMfaPendingAccessRoutes(routes: MfaPendingAccessRoute[]): void {
|
|
35
|
-
for (const route of Array.isArray(routes) ? routes : []) {
|
|
36
|
-
const path = normalizePath(route?.path ?? '')
|
|
37
|
-
const methods = normalizeMethods(route?.methods)
|
|
38
|
-
if (!path || !methods.length) continue
|
|
39
|
-
const existing = registeredMethodsByPath.get(path)
|
|
40
|
-
if (!existing) {
|
|
41
|
-
registeredMethodsByPath.set(path, new Set(methods))
|
|
42
|
-
continue
|
|
43
|
-
}
|
|
44
|
-
for (const method of methods) existing.add(method)
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Fail-closed check deciding whether an MFA-pending staff token may be resolved for this exact
|
|
50
|
-
* method + path pair. Only explicitly registered completion routes pass; every unregistered
|
|
51
|
-
* request is denied.
|
|
52
|
-
*/
|
|
53
|
-
export function isMfaPendingAccessAllowed(
|
|
54
|
-
method: string | null | undefined,
|
|
55
|
-
pathname: string | null | undefined,
|
|
56
|
-
): boolean {
|
|
57
|
-
const path = normalizePath(typeof pathname === 'string' ? pathname : '')
|
|
58
|
-
if (!path) return false
|
|
59
|
-
if (typeof method !== 'string' || method.trim().length === 0) return false
|
|
60
|
-
const methods = registeredMethodsByPath.get(path)
|
|
61
|
-
if (!methods) return false
|
|
62
|
-
return methods.has(method.trim().toUpperCase())
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Test/ops helper: current snapshot of the pending-access registry. */
|
|
66
|
-
export function listMfaPendingAccessRoutes(): ReadonlyArray<Readonly<MfaPendingAccessRoute>> {
|
|
67
|
-
return Array.from(registeredMethodsByPath.entries())
|
|
68
|
-
.map(([path, methods]) => ({ path, methods: Array.from(methods).sort((first, second) => (first < second ? -1 : first > second ? 1 : 0)) }))
|
|
69
|
-
.sort((first, second) => (first.path < second.path ? -1 : first.path > second.path ? 1 : 0))
|
|
70
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import type { AuthContext } from './server'
|
|
2
|
-
|
|
3
|
-
export type PrincipalScope = {
|
|
4
|
-
tenantId: string
|
|
5
|
-
organizationId: string
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export type AuthPrincipalType = 'user' | 'role'
|
|
9
|
-
|
|
10
|
-
export type AuthPrincipalLabel = {
|
|
11
|
-
id: string
|
|
12
|
-
label: string
|
|
13
|
-
secondary: string | null
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type AuthPrincipalRolePage = {
|
|
17
|
-
items: AuthPrincipalLabel[]
|
|
18
|
-
page: number
|
|
19
|
-
pageSize: number
|
|
20
|
-
total: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Public, request-scoped read boundary owned by the Auth module. */
|
|
24
|
-
export interface AuthPrincipalService {
|
|
25
|
-
principalExists(input: {
|
|
26
|
-
type: AuthPrincipalType
|
|
27
|
-
id: string
|
|
28
|
-
scope: PrincipalScope
|
|
29
|
-
}): Promise<boolean>
|
|
30
|
-
resolveActiveUserRoleIds(userId: string, scope: PrincipalScope): Promise<string[]>
|
|
31
|
-
filterActiveRoleIds(roleIds: string[], scope: PrincipalScope): Promise<string[]>
|
|
32
|
-
resolveLabels(input: {
|
|
33
|
-
type: AuthPrincipalType
|
|
34
|
-
ids: string[]
|
|
35
|
-
scope: PrincipalScope
|
|
36
|
-
}): Promise<AuthPrincipalLabel[]>
|
|
37
|
-
/**
|
|
38
|
-
* Optional additive capability for organization-eligible role pickers.
|
|
39
|
-
* Implementations must apply eligibility before pagination and bound the
|
|
40
|
-
* advertised result window so sparse ACL matches cannot amplify requests.
|
|
41
|
-
*/
|
|
42
|
-
queryActiveRolePage?(input: {
|
|
43
|
-
scope: PrincipalScope
|
|
44
|
-
search?: string
|
|
45
|
-
excludedIds?: string[]
|
|
46
|
-
page: number
|
|
47
|
-
pageSize: number
|
|
48
|
-
}): Promise<AuthPrincipalRolePage>
|
|
49
|
-
listSuperAdminUserIds(tenantId: string): Promise<string[]>
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Public, request-scoped read boundary owned by the API Keys module. */
|
|
53
|
-
export interface ApiKeyPrincipalService {
|
|
54
|
-
resolveAssignedRoleIds(apiKeyId: string, scope: PrincipalScope): Promise<string[]>
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export type OrganizationScope = {
|
|
58
|
-
selectedId: string | null
|
|
59
|
-
filterIds: string[] | null
|
|
60
|
-
allowedIds: string[] | null
|
|
61
|
-
tenantId: string | null
|
|
62
|
-
// True when an explicit organization selection could not be honored. Reads
|
|
63
|
-
// fall back to the caller's accessible organizations; writes must fail.
|
|
64
|
-
selectionRejected?: boolean
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export type OrganizationScopeAcl = {
|
|
68
|
-
isSuperAdmin: boolean
|
|
69
|
-
features: string[]
|
|
70
|
-
organizations: string[] | null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export type OrganizationScopeRequest = Request | {
|
|
74
|
-
cookies?: { get: (name: string) => { value: string } | undefined }
|
|
75
|
-
headers?: { get(name: string): string | null }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Narrow, request-scoped hierarchy boundary owned by Directory.
|
|
80
|
-
*
|
|
81
|
-
* `null` means the selected organization does not exist in the requested
|
|
82
|
-
* tenant. An empty array means it exists but has no ancestors.
|
|
83
|
-
*/
|
|
84
|
-
export interface OrganizationHierarchyService {
|
|
85
|
-
resolveAncestorIds(input: {
|
|
86
|
-
tenantId: string
|
|
87
|
-
organizationId: string
|
|
88
|
-
}): Promise<string[] | null>
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Public, request-scoped organization expansion boundary owned by Directory. */
|
|
92
|
-
export interface OrganizationScopeService {
|
|
93
|
-
resolve(input: {
|
|
94
|
-
auth: AuthContext | null | undefined
|
|
95
|
-
selectedId?: string | null
|
|
96
|
-
tenantId?: string | null
|
|
97
|
-
freshAcl?: boolean
|
|
98
|
-
}): Promise<OrganizationScope>
|
|
99
|
-
resolveFresh(input: {
|
|
100
|
-
auth: NonNullable<AuthContext>
|
|
101
|
-
selectedId?: string | null
|
|
102
|
-
tenantId?: string | null
|
|
103
|
-
}): Promise<{ scope: OrganizationScope; acl: OrganizationScopeAcl }>
|
|
104
|
-
resolveForRequest(input: {
|
|
105
|
-
auth: AuthContext | null | undefined
|
|
106
|
-
request?: OrganizationScopeRequest
|
|
107
|
-
selectedId?: string | null
|
|
108
|
-
tenantId?: string | null
|
|
109
|
-
}): Promise<OrganizationScope>
|
|
110
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { htmlToPlainText } from '../htmlToPlainText'
|
|
2
|
-
|
|
3
|
-
describe('htmlToPlainText', () => {
|
|
4
|
-
it('returns an empty string for empty input without invoking the parser', () => {
|
|
5
|
-
expect(htmlToPlainText('')).toBe('')
|
|
6
|
-
})
|
|
7
|
-
|
|
8
|
-
it('renders block-level markup as readable prose', () => {
|
|
9
|
-
const html = '<html><body><p>Hello,</p><p>second paragraph.</p></body></html>'
|
|
10
|
-
|
|
11
|
-
const text = htmlToPlainText(html)
|
|
12
|
-
|
|
13
|
-
expect(text).toContain('Hello,')
|
|
14
|
-
expect(text).toContain('second paragraph.')
|
|
15
|
-
expect(text).not.toContain('<p>')
|
|
16
|
-
expect(text).not.toContain('</body>')
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('drops script and style content instead of inlining it as text', () => {
|
|
20
|
-
// Email HTML routinely carries a <style> block and a tracking <script>.
|
|
21
|
-
// Rendering either as text is what made ingested bodies unreadable.
|
|
22
|
-
const html = [
|
|
23
|
-
'<!DOCTYPE html><html><head>',
|
|
24
|
-
'<style>body { margin: 0; font-family: Helvetica; }</style>',
|
|
25
|
-
'<script>trackOpen("beacon")</script>',
|
|
26
|
-
'</head><body><p>Visible copy</p></body></html>',
|
|
27
|
-
].join('')
|
|
28
|
-
|
|
29
|
-
const text = htmlToPlainText(html)
|
|
30
|
-
|
|
31
|
-
expect(text).toBe('Visible copy')
|
|
32
|
-
expect(text).not.toContain('font-family')
|
|
33
|
-
expect(text).not.toContain('trackOpen')
|
|
34
|
-
expect(text).not.toContain('<!DOCTYPE')
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('normalizes CRLF and lone CR line endings to LF', () => {
|
|
38
|
-
const text = htmlToPlainText('<pre>first\r\nsecond\rthird</pre>')
|
|
39
|
-
|
|
40
|
-
expect(text).not.toContain('\r')
|
|
41
|
-
expect(text).toBe('first\nsecond\nthird')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('collapses runs of three or more blank lines down to one', () => {
|
|
45
|
-
const text = htmlToPlainText('<pre>top\n\n\n\n\nbottom</pre>')
|
|
46
|
-
|
|
47
|
-
expect(text).toBe('top\n\nbottom')
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('preserves a single blank line between paragraphs', () => {
|
|
51
|
-
const text = htmlToPlainText('<pre>top\n\nbottom</pre>')
|
|
52
|
-
|
|
53
|
-
expect(text).toBe('top\n\nbottom')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('trims surrounding whitespace', () => {
|
|
57
|
-
expect(htmlToPlainText('<pre>\n\n padded \n\n</pre>')).toBe('padded')
|
|
58
|
-
})
|
|
59
|
-
})
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { convert } from 'html-to-text'
|
|
2
|
-
|
|
3
|
-
export function htmlToPlainText(html: string): string {
|
|
4
|
-
if (!html) return ''
|
|
5
|
-
return convert(html, {
|
|
6
|
-
wordwrap: false,
|
|
7
|
-
preserveNewlines: true,
|
|
8
|
-
selectors: [
|
|
9
|
-
{ selector: 'script', format: 'skip' },
|
|
10
|
-
{ selector: 'style', format: 'skip' },
|
|
11
|
-
],
|
|
12
|
-
})
|
|
13
|
-
.replace(/\r\n/g, '\n')
|
|
14
|
-
.replace(/\r/g, '\n')
|
|
15
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
16
|
-
.trim()
|
|
17
|
-
}
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plan-level guard for the capped list COUNT (#4552 Phase 2), run against a real
|
|
3
|
-
* PostgreSQL. This is the only test that fails if a future refactor reintroduces
|
|
4
|
-
* a blocking node (Aggregate/Sort/Unique) between the probe `Limit` and the base
|
|
5
|
-
* scan — a mocked total cannot catch a bound that does not bind. It doubles as
|
|
6
|
-
* the count-parity check: with the cap disabled, the rebuilt count must equal
|
|
7
|
-
* ground truth across the cf-filter / or-group / plain matrices.
|
|
8
|
-
*
|
|
9
|
-
* Gated on OM_COUNT_CAP_PG_URL (a throwaway database — the suite creates and
|
|
10
|
-
* drops its own tables). Example:
|
|
11
|
-
* docker run --rm -d -p 54329:5432 -e POSTGRES_PASSWORD=t postgres:16
|
|
12
|
-
* OM_COUNT_CAP_PG_URL=postgres://postgres:t@localhost:54329/postgres yarn jest count-cap-plan
|
|
13
|
-
*/
|
|
14
|
-
import { Kysely, PostgresDialect, sql } from 'kysely'
|
|
15
|
-
import { BasicQueryEngine } from '../engine'
|
|
16
|
-
|
|
17
|
-
const PG_URL = process.env.OM_COUNT_CAP_PG_URL
|
|
18
|
-
const maybe = PG_URL ? describe : describe.skip
|
|
19
|
-
|
|
20
|
-
const TENANT = '11111111-1111-4111-8111-111111111111'
|
|
21
|
-
const ORG = '22222222-2222-4222-8222-222222222222'
|
|
22
|
-
const ENTITY = 'capcheck:om_capcheck_order'
|
|
23
|
-
const TABLE = 'om_capcheck_orders'
|
|
24
|
-
const ROWS = 120
|
|
25
|
-
const RED_ROWS = 30
|
|
26
|
-
|
|
27
|
-
maybe('capped list COUNT against PostgreSQL', () => {
|
|
28
|
-
jest.setTimeout(60_000)
|
|
29
|
-
let db: Kysely<any>
|
|
30
|
-
let sqlLog: string[] = []
|
|
31
|
-
let paramLog: unknown[][] = []
|
|
32
|
-
const originalCap = process.env.OM_LIST_COUNT_CAP
|
|
33
|
-
|
|
34
|
-
beforeAll(async () => {
|
|
35
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
36
|
-
const { Pool } = require('pg')
|
|
37
|
-
db = new Kysely<any>({
|
|
38
|
-
dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }),
|
|
39
|
-
log(event) {
|
|
40
|
-
if (event.level === 'query' || event.level === 'error') {
|
|
41
|
-
sqlLog.push(event.query.sql)
|
|
42
|
-
paramLog.push([...event.query.parameters])
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
})
|
|
46
|
-
await sql`drop table if exists om_capcheck_orders`.execute(db)
|
|
47
|
-
await sql`create table om_capcheck_orders (
|
|
48
|
-
id uuid primary key,
|
|
49
|
-
tenant_id uuid not null,
|
|
50
|
-
organization_id uuid not null,
|
|
51
|
-
deleted_at timestamptz,
|
|
52
|
-
status text not null
|
|
53
|
-
)`.execute(db)
|
|
54
|
-
await sql`create table if not exists custom_field_defs (
|
|
55
|
-
id serial primary key,
|
|
56
|
-
key text not null,
|
|
57
|
-
entity_id text not null,
|
|
58
|
-
kind text,
|
|
59
|
-
config_json jsonb,
|
|
60
|
-
is_active boolean not null default true,
|
|
61
|
-
organization_id uuid,
|
|
62
|
-
tenant_id uuid,
|
|
63
|
-
updated_at timestamptz,
|
|
64
|
-
deleted_at timestamptz
|
|
65
|
-
)`.execute(db)
|
|
66
|
-
await sql`create table if not exists custom_field_values (
|
|
67
|
-
id serial primary key,
|
|
68
|
-
entity_id text not null,
|
|
69
|
-
field_key text not null,
|
|
70
|
-
record_id text not null,
|
|
71
|
-
value_text text,
|
|
72
|
-
value_multiline text,
|
|
73
|
-
value_int int,
|
|
74
|
-
value_float float,
|
|
75
|
-
value_bool boolean,
|
|
76
|
-
organization_id uuid,
|
|
77
|
-
tenant_id uuid
|
|
78
|
-
)`.execute(db)
|
|
79
|
-
await sql`delete from custom_field_defs where entity_id = ${ENTITY}`.execute(db)
|
|
80
|
-
await sql`delete from custom_field_values where entity_id = ${ENTITY}`.execute(db)
|
|
81
|
-
await sql`insert into custom_field_defs (key, entity_id, kind, is_active, tenant_id)
|
|
82
|
-
values ('color', ${ENTITY}, 'text', true, null)`.execute(db)
|
|
83
|
-
for (let i = 0; i < ROWS; i++) {
|
|
84
|
-
const id = `33333333-3333-4333-8333-${String(i).padStart(12, '0')}`
|
|
85
|
-
await sql`insert into om_capcheck_orders (id, tenant_id, organization_id, deleted_at, status)
|
|
86
|
-
values (${id}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, ${i % 2 === 0 ? 'open' : 'closed'})`.execute(db)
|
|
87
|
-
if (i < RED_ROWS) {
|
|
88
|
-
await sql`insert into custom_field_values (entity_id, field_key, record_id, value_text, tenant_id)
|
|
89
|
-
values (${ENTITY}, 'color', ${id}, 'red', ${TENANT}::uuid)`.execute(db)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
afterAll(async () => {
|
|
95
|
-
await sql`drop table if exists om_capcheck_orders`.execute(db)
|
|
96
|
-
await sql`delete from custom_field_defs where entity_id = ${ENTITY}`.execute(db)
|
|
97
|
-
await sql`delete from custom_field_values where entity_id = ${ENTITY}`.execute(db)
|
|
98
|
-
await db.destroy()
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
afterEach(() => {
|
|
102
|
-
if (originalCap === undefined) delete process.env.OM_LIST_COUNT_CAP
|
|
103
|
-
else process.env.OM_LIST_COUNT_CAP = originalCap
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
const makeEngine = () => new BasicQueryEngine({} as any, () => db as any)
|
|
107
|
-
const baseOpts = {
|
|
108
|
-
tenantId: TENANT,
|
|
109
|
-
organizationId: ORG,
|
|
110
|
-
fields: ['id'],
|
|
111
|
-
page: { page: 1, pageSize: 10 },
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function lastCountSql(): { text: string; params: unknown[] } {
|
|
115
|
-
for (let i = sqlLog.length - 1; i >= 0; i--) {
|
|
116
|
-
if (/count\(\*\)/i.test(sqlLog[i])) return { text: sqlLog[i], params: paramLog[i] }
|
|
117
|
-
}
|
|
118
|
-
throw new Error('no count SQL captured')
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function collectNodes(node: any, out: any[] = []): any[] {
|
|
122
|
-
if (!node) return out
|
|
123
|
-
out.push(node)
|
|
124
|
-
for (const child of node.Plans ?? []) collectNodes(child, out)
|
|
125
|
-
return out
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
test('probe plan: no blocking node between the Limit and the base scan (cf-filtered)', async () => {
|
|
129
|
-
process.env.OM_LIST_COUNT_CAP = '50'
|
|
130
|
-
sqlLog = []; paramLog = []
|
|
131
|
-
const result = await makeEngine().query(ENTITY, {
|
|
132
|
-
...baseOpts,
|
|
133
|
-
filters: { status: { $eq: 'open' } },
|
|
134
|
-
})
|
|
135
|
-
expect(result.total).toBe(50)
|
|
136
|
-
expect(result.meta?.listCountCapWarning).toEqual({ entity: ENTITY, cap: 50 })
|
|
137
|
-
|
|
138
|
-
const { text, params } = lastCountSql()
|
|
139
|
-
expect(text.toLowerCase()).toContain('limit')
|
|
140
|
-
const explained = await sql
|
|
141
|
-
.raw(`explain (format json) ${text.replace(/\$(\d+)/g, (_, n) => {
|
|
142
|
-
const value = params[Number(n) - 1]
|
|
143
|
-
return typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`
|
|
144
|
-
})}`)
|
|
145
|
-
.execute(db)
|
|
146
|
-
const plan = (explained.rows[0] as any)['QUERY PLAN'][0].Plan
|
|
147
|
-
const all = collectNodes(plan)
|
|
148
|
-
const limitNode = all.find((n) => n['Node Type'] === 'Limit')
|
|
149
|
-
expect(limitNode).toBeTruthy()
|
|
150
|
-
// Below the Limit: only row-producing nodes down to the scan. An Aggregate,
|
|
151
|
-
// Sort, or Unique here means the bound does not bind.
|
|
152
|
-
const belowLimit = collectNodes(limitNode).slice(1)
|
|
153
|
-
const blocking = belowLimit.filter((n) => /Aggregate|Sort|Unique/.test(String(n['Node Type'])))
|
|
154
|
-
expect(blocking).toEqual([])
|
|
155
|
-
// The base scan is inside the Limit subtree.
|
|
156
|
-
expect(belowLimit.some((n) => n['Relation Name'] === TABLE)).toBe(true)
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
test('cf filter compiles to EXISTS and stays cappable', async () => {
|
|
160
|
-
process.env.OM_LIST_COUNT_CAP = '10'
|
|
161
|
-
sqlLog = []; paramLog = []
|
|
162
|
-
const result = await makeEngine().query(ENTITY, {
|
|
163
|
-
...baseOpts,
|
|
164
|
-
filters: { cf_color: { $eq: 'red' } },
|
|
165
|
-
})
|
|
166
|
-
expect(result.total).toBe(10)
|
|
167
|
-
expect(result.meta?.listCountCapWarning).toEqual({ entity: ENTITY, cap: 10 })
|
|
168
|
-
const { text } = lastCountSql()
|
|
169
|
-
expect(text.toLowerCase()).toContain('exists')
|
|
170
|
-
expect(text.toLowerCase()).not.toContain('group by')
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
test('parity with the cap disabled: rebuilt count matches the display query across the filter matrix', async () => {
|
|
174
|
-
process.env.OM_LIST_COUNT_CAP = '0'
|
|
175
|
-
const engine = makeEngine()
|
|
176
|
-
const wide = { page: { page: 1, pageSize: 1000 } }
|
|
177
|
-
|
|
178
|
-
// Each case asserts the rebuilt count against ground truth where the filter
|
|
179
|
-
// semantics are well-defined, and always against the display query's own
|
|
180
|
-
// row set — the regression guard for "count rebuild changing a total".
|
|
181
|
-
const expectParity = async (filters: any, expectedTotal?: number) => {
|
|
182
|
-
const result = await engine.query(ENTITY, { ...baseOpts, ...wide, filters })
|
|
183
|
-
expect(result.total).toBe(result.items.length)
|
|
184
|
-
if (expectedTotal !== undefined) expect(result.total).toBe(expectedTotal)
|
|
185
|
-
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
186
|
-
return result
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
await expectParity(undefined, ROWS)
|
|
190
|
-
await expectParity({ status: { $eq: 'open' } }, ROWS / 2)
|
|
191
|
-
await expectParity({ cf_color: { $eq: 'red' } }, RED_ROWS)
|
|
192
|
-
await expectParity({ $and: [{ status: { $eq: 'open' } }, { cf_color: { $eq: 'red' } }] }, RED_ROWS / 2)
|
|
193
|
-
// Base-column-only $or exercises the or-group path in the count shape.
|
|
194
|
-
await expectParity({ $or: [{ status: { $eq: 'open' } }, { status: { $eq: 'closed' } }] }, ROWS)
|
|
195
|
-
// Mixed base + cf disjunction (#5039): the count shape must compile the cf
|
|
196
|
-
// leaf to an EXISTS rather than dropping it — a dropped leaf narrows the OR
|
|
197
|
-
// and undercounts. closed (60) ∪ red (30, half of which are closed) = 75.
|
|
198
|
-
await expectParity({ $or: [{ status: { $eq: 'closed' } }, { cf_color: { $eq: 'red' } }] }, ROWS / 2 + RED_ROWS / 2)
|
|
199
|
-
// cf-only disjunction: red (30) ∪ blue (0) = 30.
|
|
200
|
-
await expectParity({ $or: [{ cf_color: { $eq: 'red' } }, { cf_color: { $eq: 'blue' } }] }, RED_ROWS)
|
|
201
|
-
await expectParity({ cf_color: { $eq: 'blue' } }, 0)
|
|
202
|
-
await expectParity({ cf_color: { $eq: null } }, ROWS - RED_ROWS)
|
|
203
|
-
await expectParity({ cf_color: { $ne: 'red' } }, 0)
|
|
204
|
-
await expectParity({ cf_color: { $in: ['red', 'blue'] } }, RED_ROWS)
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
test('kill switch (cap=0): the cf-filtered count still plans set-oriented, not per-row', async () => {
|
|
208
|
-
// OM_LIST_COUNT_CAP=0 restores exact totals but runs them through the
|
|
209
|
-
// rebuilt shape. The documented escape hatch must not be slower than the
|
|
210
|
-
// problem: the uncapped EXISTS should plan as a semi-join or a hashed
|
|
211
|
-
// subplan, never an un-hashed per-row subplan re-executed for every row.
|
|
212
|
-
process.env.OM_LIST_COUNT_CAP = '0'
|
|
213
|
-
sqlLog = []; paramLog = []
|
|
214
|
-
const result = await makeEngine().query(ENTITY, {
|
|
215
|
-
...baseOpts,
|
|
216
|
-
filters: { cf_color: { $eq: 'red' } },
|
|
217
|
-
})
|
|
218
|
-
expect(result.total).toBe(RED_ROWS)
|
|
219
|
-
|
|
220
|
-
const { text, params } = lastCountSql()
|
|
221
|
-
const explained = await sql
|
|
222
|
-
.raw(`explain (format json) ${text.replace(/\$(\d+)/g, (_, n) => {
|
|
223
|
-
const value = params[Number(n) - 1]
|
|
224
|
-
return typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`
|
|
225
|
-
})}`)
|
|
226
|
-
.execute(db)
|
|
227
|
-
const plan = (explained.rows[0] as any)['QUERY PLAN'][0].Plan
|
|
228
|
-
const all = collectNodes(plan)
|
|
229
|
-
const semiJoin = all.some((n) => /Semi/.test(String(n['Join Type'] ?? '')))
|
|
230
|
-
const hashedSubplan = all.some((n) => /hashed/i.test(String(n['Subplan Name'] ?? '')))
|
|
231
|
-
expect(semiJoin || hashedSubplan).toBe(true)
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
test('sub-cap totals stay exact with the cap active', async () => {
|
|
235
|
-
process.env.OM_LIST_COUNT_CAP = '1000'
|
|
236
|
-
const result = await makeEngine().query(ENTITY, { ...baseOpts, filters: { cf_color: { $eq: 'red' } } })
|
|
237
|
-
expect(result.total).toBe(RED_ROWS)
|
|
238
|
-
expect(result.meta?.listCountCapWarning).toBeUndefined()
|
|
239
|
-
})
|
|
240
|
-
})
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { resolveListCountCap, DEFAULT_LIST_COUNT_CAP } from '../count-cap'
|
|
2
|
-
|
|
3
|
-
describe('resolveListCountCap', () => {
|
|
4
|
-
const original = process.env.OM_LIST_COUNT_CAP
|
|
5
|
-
afterEach(() => {
|
|
6
|
-
if (original === undefined) delete process.env.OM_LIST_COUNT_CAP
|
|
7
|
-
else process.env.OM_LIST_COUNT_CAP = original
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
test('unset: the default cap — the cap is on by default', () => {
|
|
11
|
-
delete process.env.OM_LIST_COUNT_CAP
|
|
12
|
-
expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
test('blank: the default cap', () => {
|
|
16
|
-
process.env.OM_LIST_COUNT_CAP = ' '
|
|
17
|
-
expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
test('0 disables capping', () => {
|
|
21
|
-
process.env.OM_LIST_COUNT_CAP = '0'
|
|
22
|
-
expect(resolveListCountCap()).toBeNull()
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
test('negative values disable capping like 0', () => {
|
|
26
|
-
process.env.OM_LIST_COUNT_CAP = '-5'
|
|
27
|
-
expect(resolveListCountCap()).toBeNull()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
test('a positive integer is used as-is; floats floor', () => {
|
|
31
|
-
process.env.OM_LIST_COUNT_CAP = '500'
|
|
32
|
-
expect(resolveListCountCap()).toBe(500)
|
|
33
|
-
process.env.OM_LIST_COUNT_CAP = '500.9'
|
|
34
|
-
expect(resolveListCountCap()).toBe(500)
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
test('unparseable input falls back to the default — bad input must not silently disable the cap', () => {
|
|
38
|
-
process.env.OM_LIST_COUNT_CAP = 'unbounded'
|
|
39
|
-
expect(resolveListCountCap()).toBe(DEFAULT_LIST_COUNT_CAP)
|
|
40
|
-
})
|
|
41
|
-
})
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { parseNumberWithDefault } from '../number'
|
|
2
|
-
|
|
3
|
-
export const DEFAULT_LIST_COUNT_CAP = 10_000
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Cap on how many matching rows a list COUNT may visit before reporting
|
|
7
|
-
* `total: cap` with `meta.listCountCapWarning` (surfaced to clients as
|
|
8
|
-
* `totalIsCapped`). Returns the cap as a number, or `null` when capping is
|
|
9
|
-
* disabled.
|
|
10
|
-
*
|
|
11
|
-
* Resolution of `OM_LIST_COUNT_CAP`:
|
|
12
|
-
* - unset / blank / unparseable → `DEFAULT_LIST_COUNT_CAP` (the cap is on by
|
|
13
|
-
* default, and bad input must not silently disable it)
|
|
14
|
-
* - `0` (or negative) → `null` — capping disabled, exact counts everywhere
|
|
15
|
-
*/
|
|
16
|
-
export function resolveListCountCap(): number | null {
|
|
17
|
-
const parsed = parseNumberWithDefault(process.env.OM_LIST_COUNT_CAP, DEFAULT_LIST_COUNT_CAP, { integer: true })
|
|
18
|
-
return parsed <= 0 ? null : parsed
|
|
19
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Trusted dispatch-origin markers for queue jobs.
|
|
3
|
-
*
|
|
4
|
-
* Jobs that drive privileged side effects (payment webhook processors and
|
|
5
|
-
* similar sinks) MUST only execute payloads enqueued by trusted infrastructure
|
|
6
|
-
* code paths. The scheduler dispatch layer and inbound webhook routes mark the
|
|
7
|
-
* payloads they enqueue; sensitive workers verify the marker before doing work.
|
|
8
|
-
*
|
|
9
|
-
* The scheduler payload sanitizer strips every caller-supplied `_`-prefixed
|
|
10
|
-
* key from scheduled target payloads, so a marker cannot be forged through the
|
|
11
|
-
* scheduler job API. Direct queue/Redis write access is operator-level access
|
|
12
|
-
* and outside this threat model.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const QUEUE_JOB_ORIGIN_KEY = '_jobOrigin'
|
|
16
|
-
|
|
17
|
-
export type QueueJobOrigin = 'inbound-webhook' | 'scheduler'
|
|
18
|
-
|
|
19
|
-
export function markQueueJobOrigin<T extends Record<string, unknown>>(
|
|
20
|
-
payload: T,
|
|
21
|
-
origin: QueueJobOrigin,
|
|
22
|
-
): T {
|
|
23
|
-
return { ...payload, [QUEUE_JOB_ORIGIN_KEY]: origin }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function readQueueJobOrigin(payload: unknown): QueueJobOrigin | null {
|
|
27
|
-
if (!payload || typeof payload !== 'object') return null
|
|
28
|
-
const value = (payload as Record<string, unknown>)[QUEUE_JOB_ORIGIN_KEY]
|
|
29
|
-
if (value === 'inbound-webhook' || value === 'scheduler') return value
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function isTrustedWebhookDispatch(payload: unknown): boolean {
|
|
34
|
-
return readQueueJobOrigin(payload) === 'inbound-webhook'
|
|
35
|
-
}
|