@byline/core 3.20.4 → 4.0.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/dist/@types/collection-types.d.ts +72 -31
- package/dist/@types/db-types.d.ts +93 -47
- package/dist/@types/field-data-types.d.ts +8 -10
- package/dist/@types/field-data-types.js +1 -1
- package/dist/@types/field-data-types.test.node.d.ts +1 -0
- package/dist/@types/field-data-types.test.node.js +70 -0
- package/dist/@types/field-types.d.ts +25 -1
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/relation-types.d.ts +38 -0
- package/dist/@types/relation-types.js +8 -0
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +52 -13
- package/dist/auth/apply-before-read.d.ts +23 -11
- package/dist/auth/apply-before-read.js +139 -33
- package/dist/auth/apply-before-read.test.node.js +241 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/read-context-scope.d.ts +20 -0
- package/dist/auth/read-context-scope.js +48 -0
- package/dist/codegen/fixtures/all-fields.d.ts +342 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
- package/dist/codegen/fixtures/all-fields.expected.js +1 -0
- package/dist/codegen/fixtures/all-fields.js +94 -0
- package/dist/codegen/index.d.ts +16 -0
- package/dist/codegen/index.js +431 -0
- package/dist/codegen/index.test.node.d.ts +1 -0
- package/dist/codegen/index.test.node.js +230 -0
- package/dist/config/attach-hooks.d.ts +25 -0
- package/dist/config/attach-hooks.js +130 -0
- package/dist/config/attach-hooks.test.node.d.ts +1 -0
- package/dist/config/attach-hooks.test.node.js +173 -0
- package/dist/config/config-hooks.test.node.d.ts +1 -0
- package/dist/config/config-hooks.test.node.js +56 -0
- package/dist/config/config.d.ts +9 -5
- package/dist/config/config.js +20 -2
- package/dist/config/routes.d.ts +5 -5
- package/dist/config/routes.js +42 -9
- package/dist/config/routes.test.node.d.ts +1 -0
- package/dist/config/routes.test.node.js +152 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.js +20 -14
- package/dist/core.test.node.d.ts +1 -0
- package/dist/core.test.node.js +28 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +14 -0
- package/dist/query/parse-where.d.ts +10 -1
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -1
- package/dist/schemas/zod/builder.js +7 -3
- package/dist/schemas/zod/builder.test.node.d.ts +1 -0
- package/dist/schemas/zod/builder.test.node.js +49 -0
- package/dist/services/collection-bootstrap.d.ts +1 -1
- package/dist/services/collection-bootstrap.test.node.js +30 -59
- package/dist/services/discover-counter-groups.d.ts +1 -1
- package/dist/services/discover-counter-groups.test.node.js +23 -0
- package/dist/services/document-lifecycle/audit.d.ts +22 -1
- package/dist/services/document-lifecycle/audit.js +32 -1
- package/dist/services/document-lifecycle/create.d.ts +2 -2
- package/dist/services/document-lifecycle/create.js +18 -8
- package/dist/services/document-lifecycle/delete.d.ts +17 -1
- package/dist/services/document-lifecycle/delete.js +91 -26
- package/dist/services/document-lifecycle/index.d.ts +1 -1
- package/dist/services/document-lifecycle/internals.d.ts +0 -20
- package/dist/services/document-lifecycle/internals.js +22 -46
- package/dist/services/document-lifecycle/status.d.ts +1 -1
- package/dist/services/document-lifecycle/status.js +20 -3
- package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
- package/dist/services/document-lifecycle/system-fields.js +112 -74
- package/dist/services/document-lifecycle/tree.d.ts +22 -24
- package/dist/services/document-lifecycle/tree.js +244 -123
- package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/tree.test.node.js +663 -0
- package/dist/services/document-lifecycle/update.d.ts +4 -4
- package/dist/services/document-lifecycle/update.js +11 -5
- package/dist/services/document-lifecycle.test.node.js +426 -16
- package/dist/services/document-read.d.ts +14 -6
- package/dist/services/document-read.js +47 -9
- package/dist/services/field-upload.test.node.js +70 -0
- package/dist/services/index.d.ts +3 -2
- package/dist/services/index.js +3 -2
- package/dist/services/normalize-numeric-fields.d.ts +23 -0
- package/dist/services/normalize-numeric-fields.js +89 -0
- package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
- package/dist/services/normalize-numeric-fields.test.node.js +99 -0
- package/dist/services/populate.d.ts +9 -23
- package/dist/services/populate.js +180 -28
- package/dist/services/populate.test.node.js +59 -0
- package/dist/services/richtext-embed.d.ts +39 -2
- package/dist/services/richtext-embed.js +4 -34
- package/dist/services/richtext-embed.test.node.js +15 -0
- package/dist/services/richtext-populate.d.ts +21 -3
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- package/dist/services/validate-search-config.d.ts +1 -1
- package/dist/storage/collection-fingerprint.js +6 -0
- package/dist/storage/collection-fingerprint.test.node.js +21 -0
- package/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- package/package.json +7 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeRootRelativeRedirect } from '../utils/root-relative-redirect.js';
|
|
3
|
+
import { defineClientConfig, getClientConfig } from './config.js';
|
|
4
|
+
import { resolveRoutes } from './routes.js';
|
|
5
|
+
describe('resolveRoutes', () => {
|
|
6
|
+
it('returns canonical defaults', () => {
|
|
7
|
+
const routes = resolveRoutes();
|
|
8
|
+
expect(routes).toEqual({ admin: '/admin', api: '/api', signIn: '/sign-in' });
|
|
9
|
+
expect(Object.isFrozen(routes)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it.each([
|
|
12
|
+
['cms', '/cms'],
|
|
13
|
+
['/cms', '/cms'],
|
|
14
|
+
['/cms/', '/cms'],
|
|
15
|
+
['///cms///', '/cms'],
|
|
16
|
+
['/internal/cms/', '/internal/cms'],
|
|
17
|
+
])('canonicalizes an admin route of %j', (admin, expected) => {
|
|
18
|
+
expect(resolveRoutes({ admin }).admin).toBe(expected);
|
|
19
|
+
});
|
|
20
|
+
it('falls back for empty route values', () => {
|
|
21
|
+
expect(resolveRoutes({ admin: '', api: ' ', signIn: '' })).toEqual({
|
|
22
|
+
admin: '/admin',
|
|
23
|
+
api: '/api',
|
|
24
|
+
signIn: '/sign-in',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it.each([
|
|
28
|
+
'/../cms',
|
|
29
|
+
'/cms?next=x',
|
|
30
|
+
'/cms#section',
|
|
31
|
+
'/cms\\users',
|
|
32
|
+
'/%63ms',
|
|
33
|
+
])('rejects a non-segment admin route of %j', (admin) => {
|
|
34
|
+
expect(() => resolveRoutes({ admin })).toThrow(/routes\.admin/);
|
|
35
|
+
});
|
|
36
|
+
it('normalizes and validates the API route too', () => {
|
|
37
|
+
expect(resolveRoutes({ api: 'content-api/' }).api).toBe('/content-api');
|
|
38
|
+
expect(resolveRoutes({ api: '/internal/api' }).api).toBe('/internal/api');
|
|
39
|
+
});
|
|
40
|
+
it.each([
|
|
41
|
+
['staff/login', '/staff/login'],
|
|
42
|
+
['/staff/login/', '/staff/login'],
|
|
43
|
+
['/staff//login/', '/staff/login'],
|
|
44
|
+
])('canonicalizes a sign-in route of %j', (signIn, expected) => {
|
|
45
|
+
expect(resolveRoutes({ signIn }).signIn).toBe(expected);
|
|
46
|
+
});
|
|
47
|
+
it.each([
|
|
48
|
+
'https://evil.test/login',
|
|
49
|
+
'//evil.test/login',
|
|
50
|
+
'/staff/login?next=/cms',
|
|
51
|
+
'/staff/login#form',
|
|
52
|
+
'/staff\\login',
|
|
53
|
+
'/staff/%6cogin',
|
|
54
|
+
'/staff/../login',
|
|
55
|
+
])('rejects an unsafe sign-in route of %j', (signIn) => {
|
|
56
|
+
expect(() => resolveRoutes({ signIn })).toThrow(/routes\.signIn/);
|
|
57
|
+
});
|
|
58
|
+
it('rejects conflicting admin and API segments', () => {
|
|
59
|
+
expect(() => resolveRoutes({ admin: '/internal', api: 'internal/' })).toThrow(/routes\.admin and routes\.api/);
|
|
60
|
+
});
|
|
61
|
+
it.each([
|
|
62
|
+
['/internal', '/internal/api'],
|
|
63
|
+
['/internal/admin', '/internal'],
|
|
64
|
+
])('rejects overlapping admin and API trees %j and %j', (admin, api) => {
|
|
65
|
+
expect(() => resolveRoutes({ admin, api })).toThrow(/routes\.admin and routes\.api/);
|
|
66
|
+
});
|
|
67
|
+
it.each([
|
|
68
|
+
['/cms', '/cms'],
|
|
69
|
+
['/cms', '/cms/login'],
|
|
70
|
+
['/cms', '/api/login'],
|
|
71
|
+
['/internal/cms', '/internal'],
|
|
72
|
+
])('rejects conflicting admin %j and sign-in %j paths', (admin, signIn) => {
|
|
73
|
+
expect(() => resolveRoutes({ admin, signIn })).toThrow(/routes\.signIn/);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('normalizeRootRelativeRedirect', () => {
|
|
77
|
+
it.each([
|
|
78
|
+
['/cms', '/cms'],
|
|
79
|
+
['/cms/account?tab=profile#name', '/cms/account?tab=profile#name'],
|
|
80
|
+
['/cms/account?query=hello world', '/cms/account?query=hello%20world'],
|
|
81
|
+
])('accepts and canonicalizes %j', (value, expected) => {
|
|
82
|
+
expect(normalizeRootRelativeRedirect(value)).toBe(expected);
|
|
83
|
+
});
|
|
84
|
+
it.each([
|
|
85
|
+
'',
|
|
86
|
+
' /cms',
|
|
87
|
+
'https://evil.test',
|
|
88
|
+
'//evil.test',
|
|
89
|
+
'/\\evil.test',
|
|
90
|
+
'/cms\\account',
|
|
91
|
+
'/cms\naccount',
|
|
92
|
+
'/cms\u0085account',
|
|
93
|
+
'/cms/../account',
|
|
94
|
+
'/cms/./account',
|
|
95
|
+
'/%2F%2Fevil.test',
|
|
96
|
+
'/cms/%2e%2e/account',
|
|
97
|
+
])('rejects %j', (value) => {
|
|
98
|
+
expect(normalizeRootRelativeRedirect(value)).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('route configuration boundary', () => {
|
|
102
|
+
it('stores and exposes resolved routes independently of partial input', () => {
|
|
103
|
+
const input = { admin: 'internal/cms/' };
|
|
104
|
+
const registered = defineClientConfig({
|
|
105
|
+
serverURL: 'https://example.test',
|
|
106
|
+
routes: input,
|
|
107
|
+
collections: [],
|
|
108
|
+
admin: [],
|
|
109
|
+
i18n: {
|
|
110
|
+
interface: { defaultLocale: 'en', locales: [] },
|
|
111
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
input.admin = '/changed';
|
|
115
|
+
expect(registered.routes).toEqual({
|
|
116
|
+
admin: '/internal/cms',
|
|
117
|
+
api: '/api',
|
|
118
|
+
signIn: '/sign-in',
|
|
119
|
+
});
|
|
120
|
+
expect(getClientConfig().routes).toBe(registered.routes);
|
|
121
|
+
expect(Object.isFrozen(registered.routes)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
it('prevents post-registration route mutation', () => {
|
|
124
|
+
const { routes } = defineClientConfig({
|
|
125
|
+
serverURL: 'https://example.test',
|
|
126
|
+
routes: { admin: '/internal/cms' },
|
|
127
|
+
collections: [],
|
|
128
|
+
admin: [],
|
|
129
|
+
i18n: {
|
|
130
|
+
interface: { defaultLocale: 'en', locales: [] },
|
|
131
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
expect(() => {
|
|
135
|
+
;
|
|
136
|
+
routes.admin = '/changed';
|
|
137
|
+
}).toThrow(TypeError);
|
|
138
|
+
expect(getClientConfig().routes.admin).toBe('/internal/cms');
|
|
139
|
+
});
|
|
140
|
+
it('rejects unsafe routes during registration', () => {
|
|
141
|
+
expect(() => defineClientConfig({
|
|
142
|
+
serverURL: 'https://example.test',
|
|
143
|
+
routes: { admin: '/cms?next=/account' },
|
|
144
|
+
collections: [],
|
|
145
|
+
admin: [],
|
|
146
|
+
i18n: {
|
|
147
|
+
interface: { defaultLocale: 'en', locales: [] },
|
|
148
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
149
|
+
},
|
|
150
|
+
})).toThrow(/routes\.admin/);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/dist/core.d.ts
CHANGED
|
@@ -9,10 +9,10 @@ import { type AbilityDescriptor, AbilityRegistry, type SessionProvider } from '@
|
|
|
9
9
|
import type { Logger as PinoLogger } from 'pino';
|
|
10
10
|
import { type BylineLogger } from './lib/logger.js';
|
|
11
11
|
import { type CollectionRecord } from './services/collection-bootstrap.js';
|
|
12
|
-
import type { CollectionDefinition, IDbAdapter, IStorageProvider, ServerConfig } from './@types/index.js';
|
|
12
|
+
import type { CollectionDefinition, IDbAdapter, IStorageProvider, ResolvedServerConfig, ServerConfig } from './@types/index.js';
|
|
13
13
|
export interface BylineCore<TAdminStore = unknown> {
|
|
14
|
-
config:
|
|
15
|
-
collections: CollectionDefinition[];
|
|
14
|
+
config: ResolvedServerConfig<TAdminStore>;
|
|
15
|
+
collections: readonly CollectionDefinition[];
|
|
16
16
|
db: IDbAdapter;
|
|
17
17
|
storage: IStorageProvider | undefined;
|
|
18
18
|
logger: BylineLogger;
|
package/dist/core.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AbilityRegistry } from '@byline/auth';
|
|
9
9
|
import { registerCollectionAbilities } from './auth/register-collection-abilities.js';
|
|
10
|
-
import { defineBylineCore,
|
|
10
|
+
import { defineBylineCore, getBylineCoreUnsafe, registerServerConfig, resolveServerConfig, } from './config/config.js';
|
|
11
11
|
import { createBylineLogger, defineLogger } from './lib/logger.js';
|
|
12
12
|
import { Registry } from './lib/registry.js';
|
|
13
13
|
import { ensureCollections } from './services/collection-bootstrap.js';
|
|
14
14
|
import { discoverCounterGroups } from './services/discover-counter-groups.js';
|
|
15
|
+
import { validateTreeAuditCapability } from './services/document-lifecycle/audit.js';
|
|
15
16
|
import { validateTranslations } from './services/i18n-validator.js';
|
|
16
17
|
import { validateRichTextFieldFlags } from './services/richtext-populate.js';
|
|
17
18
|
import { validateSearchConfig } from './services/validate-search-config.js';
|
|
@@ -32,11 +33,12 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
32
33
|
const { pino } = await import('pino');
|
|
33
34
|
pinoLogger = pino({ level: 'info' });
|
|
34
35
|
}
|
|
36
|
+
const resolvedConfig = resolveServerConfig(config);
|
|
35
37
|
const registry = new Registry()
|
|
36
|
-
.addValue('config',
|
|
37
|
-
.addValue('collections',
|
|
38
|
-
.addValue('db',
|
|
39
|
-
.addValue('storage',
|
|
38
|
+
.addValue('config', resolvedConfig)
|
|
39
|
+
.addValue('collections', resolvedConfig.collections)
|
|
40
|
+
.addValue('db', resolvedConfig.db)
|
|
41
|
+
.addValue('storage', resolvedConfig.storage)
|
|
40
42
|
.addFactory('logger', createBylineLogger);
|
|
41
43
|
const composed = registry.compose({ pinoLogger });
|
|
42
44
|
// Validate richText field flags against the registered server adapter
|
|
@@ -44,15 +46,18 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
44
46
|
// (both flags off) and missing-adapter cases at boot rather than at
|
|
45
47
|
// request time.
|
|
46
48
|
validateRichTextFieldFlags(composed.collections, {
|
|
47
|
-
populate:
|
|
48
|
-
embed:
|
|
49
|
+
populate: resolvedConfig.fields?.richText?.populate != null,
|
|
50
|
+
embed: resolvedConfig.fields?.richText?.embed != null,
|
|
49
51
|
});
|
|
50
52
|
// Validate search configuration: a collection that opts into search must
|
|
51
53
|
// have a SearchProvider registered, otherwise indexing / client.search()
|
|
52
54
|
// would silently no-op. Fail-fast at boot, same posture as richText above.
|
|
53
55
|
validateSearchConfig(composed.collections, {
|
|
54
|
-
provider:
|
|
56
|
+
provider: resolvedConfig.search != null,
|
|
55
57
|
});
|
|
58
|
+
// Tree edges are unversioned metadata and may only run on adapters that can
|
|
59
|
+
// lock, mutate, and append audit rows in one transaction.
|
|
60
|
+
validateTreeAuditCapability(composed.collections, composed.db);
|
|
56
61
|
// Validate the admin i18n translation registry against the configured
|
|
57
62
|
// interface locale set. Throws on structural errors (missing bundle for
|
|
58
63
|
// a declared locale, defaultLocale outside the permitted set, …); soft
|
|
@@ -62,9 +67,9 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
62
67
|
// validator takes the assembled shape so locale config and bundle
|
|
63
68
|
// travel together.
|
|
64
69
|
const i18nValidation = validateTranslations({
|
|
65
|
-
defaultLocale:
|
|
66
|
-
locales:
|
|
67
|
-
translations:
|
|
70
|
+
defaultLocale: resolvedConfig.i18n.interface.defaultLocale,
|
|
71
|
+
locales: resolvedConfig.i18n.interface.locales,
|
|
72
|
+
translations: resolvedConfig.i18n.translations,
|
|
68
73
|
});
|
|
69
74
|
for (const warning of i18nValidation.warnings) {
|
|
70
75
|
composed.logger.warn({
|
|
@@ -73,9 +78,6 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
73
78
|
missingKeys: warning.missingKeys,
|
|
74
79
|
}, `[i18n] '${warning.locale}.${warning.namespace}' is missing ${warning.missingKeys.length} key(s) relative to the other locales`);
|
|
75
80
|
}
|
|
76
|
-
// Backward compat: populate globalThis singletons
|
|
77
|
-
defineServerConfig(config);
|
|
78
|
-
defineLogger(composed.logger);
|
|
79
81
|
// Reconcile collection definitions with the database: insert new rows,
|
|
80
82
|
// bump schema versions when the fingerprint has drifted, and build the
|
|
81
83
|
// in-memory record cache used by the lifecycle/upload/client paths.
|
|
@@ -140,6 +142,10 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
140
142
|
sessionProvider: composed.config.sessionProvider,
|
|
141
143
|
adminStore: composed.config.adminStore,
|
|
142
144
|
};
|
|
145
|
+
// Commit globals only after the replacement core has fully initialized. A
|
|
146
|
+
// failed reinitialization leaves the prior config, logger, and core intact.
|
|
147
|
+
registerServerConfig(resolvedConfig);
|
|
148
|
+
defineLogger(composed.logger);
|
|
143
149
|
// Register on the global singleton so server-side packages
|
|
144
150
|
// (`@byline/host-tanstack-start/server-fns/*`, future hosts) can read
|
|
145
151
|
// post-init state via `getBylineCore()` instead of importing the
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineServerConfig, getServerConfig } from './config/config.js';
|
|
3
|
+
import { initBylineCore } from './core.js';
|
|
4
|
+
function serverConfig(admin) {
|
|
5
|
+
return {
|
|
6
|
+
serverURL: 'https://example.test',
|
|
7
|
+
routes: { admin },
|
|
8
|
+
collections: [],
|
|
9
|
+
db: {},
|
|
10
|
+
i18n: {
|
|
11
|
+
interface: { defaultLocale: 'en', locales: [] },
|
|
12
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe('initBylineCore configuration registration', () => {
|
|
17
|
+
it('does not overwrite a valid singleton when synchronous validation fails', async () => {
|
|
18
|
+
const valid = defineServerConfig(serverConfig('/stable/admin'));
|
|
19
|
+
const invalid = serverConfig('/replacement/admin');
|
|
20
|
+
invalid.i18n = {
|
|
21
|
+
interface: { defaultLocale: 'en', locales: ['en'] },
|
|
22
|
+
content: { defaultLocale: 'en', locales: [] },
|
|
23
|
+
};
|
|
24
|
+
await expect(initBylineCore(invalid, {})).rejects.toThrow(/translations bundle/i);
|
|
25
|
+
expect(getServerConfig()).toBe(valid);
|
|
26
|
+
expect(getServerConfig().routes.admin).toBe('/stable/admin');
|
|
27
|
+
});
|
|
28
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
export * from './@types/index.js';
|
|
2
|
-
export { applyBeforeRead, assertActorCanPerform, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './auth/index.js';
|
|
2
|
+
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, registerCollectionAbilities, } from './auth/index.js';
|
|
3
3
|
export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
|
|
4
4
|
export { resolveRoutes } from './config/routes.js';
|
|
5
5
|
export { validateAdminConfigs } from './config/validate-admin-configs.js';
|
|
6
6
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
7
7
|
export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
|
|
8
8
|
export * from './defaults/default-values.js';
|
|
9
|
-
export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_STORAGE, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, } from './lib/errors.js';
|
|
9
|
+
export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
10
10
|
export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
|
|
11
11
|
export { type BylineLogger, getLogger } from './lib/logger.js';
|
|
12
12
|
export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
|
|
13
13
|
export * from './patches/index.js';
|
|
14
|
-
export { mergePredicates, type ParseContext, type ParsedSort, type ParsedWhere, parseSort, parseWhere, } from './query/parse-where.js';
|
|
14
|
+
export { mergePredicates, type ParseContext, type ParsedSort, type ParsedWhere, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
|
|
15
15
|
export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
|
|
16
16
|
export * from './services/index.js';
|
|
17
17
|
export * from './storage/index.js';
|
|
18
|
+
export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
|
|
18
19
|
export { formatTextValue, looksLikeISODate, type SlugifierFn, type SlugifyContext, slugify, } from './utils/slugify.js';
|
|
19
20
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
20
21
|
export * from './workflow/index.js';
|
package/dist/index.js
CHANGED
|
@@ -15,22 +15,23 @@
|
|
|
15
15
|
// through this main entry or `@byline/client`.
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
export * from './@types/index.js';
|
|
18
|
-
export { applyBeforeRead, assertActorCanPerform, COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './auth/index.js';
|
|
18
|
+
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, registerCollectionAbilities, } from './auth/index.js';
|
|
19
19
|
export { defineClientConfig, defineServerConfig, getClientConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, resolveItemViewColumns, } from './config/config.js';
|
|
20
20
|
export { resolveRoutes } from './config/routes.js';
|
|
21
21
|
export { validateAdminConfigs } from './config/validate-admin-configs.js';
|
|
22
22
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
23
23
|
export { getBylineCore, initBylineCore } from './core.js';
|
|
24
24
|
export * from './defaults/default-values.js';
|
|
25
|
-
export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_STORAGE, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, } from './lib/errors.js';
|
|
25
|
+
export { BylineError, ERR_AUDIT_UNSUPPORTED, ERR_CONFLICT, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_READ_RECURSION, ERR_STORAGE, ERR_TREE_HOOK_COMMITTED, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, TREE_HOOK_COMMITTED_MARKER, TREE_PLACEMENT_STALE_MARKER, } from './lib/errors.js';
|
|
26
26
|
export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
|
|
27
27
|
export { getLogger } from './lib/logger.js';
|
|
28
28
|
export { AsyncRegistry, Registry } from './lib/registry.js';
|
|
29
29
|
export * from './patches/index.js';
|
|
30
|
-
export { mergePredicates, parseSort, parseWhere, } from './query/parse-where.js';
|
|
30
|
+
export { mergePredicates, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
|
|
31
31
|
export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
|
|
32
32
|
export * from './services/index.js';
|
|
33
33
|
export * from './storage/index.js';
|
|
34
|
+
export { normalizeRootRelativeRedirect } from './utils/root-relative-redirect.js';
|
|
34
35
|
export { formatTextValue, looksLikeISODate, slugify, } from './utils/slugify.js';
|
|
35
36
|
export { getUploadFields, hasUploadField, isUploadField } from './utils/storage-utils.js';
|
|
36
37
|
export * from './workflow/index.js';
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -85,9 +85,15 @@ export declare const ErrorCodes: {
|
|
|
85
85
|
readonly DATABASE: 'ERR_DATABASE';
|
|
86
86
|
readonly STORAGE: 'ERR_STORAGE';
|
|
87
87
|
readonly READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED';
|
|
88
|
+
readonly READ_RECURSION: 'ERR_READ_RECURSION';
|
|
88
89
|
readonly PATH_CONFLICT: 'ERR_PATH_CONFLICT';
|
|
89
90
|
readonly AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED';
|
|
91
|
+
readonly TREE_HOOK_COMMITTED: 'ERR_TREE_HOOK_COMMITTED';
|
|
90
92
|
};
|
|
93
|
+
/** Stable message fallback for stale tree-placement `ERR_CONFLICT` errors. */
|
|
94
|
+
export declare const TREE_PLACEMENT_STALE_MARKER = "[ERR_CONFLICT:TREE_PLACEMENT_STALE]";
|
|
95
|
+
/** Stable message fallback for `ERR_TREE_HOOK_COMMITTED` errors. */
|
|
96
|
+
export declare const TREE_HOOK_COMMITTED_MARKER = "[ERR_TREE_HOOK_COMMITTED]";
|
|
91
97
|
export declare const ERR_UNHANDLED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
92
98
|
export declare const ERR_NOT_FOUND: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
93
99
|
export declare const ERR_CONFLICT: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
@@ -97,6 +103,7 @@ export declare const ERR_PATCH_FAILED: (opts: BylineErrorOptions, errorConstruct
|
|
|
97
103
|
export declare const ERR_DATABASE: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
98
104
|
export declare const ERR_STORAGE: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
99
105
|
export declare const ERR_READ_BUDGET_EXCEEDED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
106
|
+
export declare const ERR_READ_RECURSION: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
100
107
|
/**
|
|
101
108
|
* Thrown when a write attempts to set `path` to a value already used by
|
|
102
109
|
* another document in the same `(collection, locale)` scope. Surfaces the
|
|
@@ -114,3 +121,9 @@ export declare const ERR_PATH_CONFLICT: (opts: BylineErrorOptions, errorConstruc
|
|
|
114
121
|
* docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
|
|
115
122
|
*/
|
|
116
123
|
export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
124
|
+
/**
|
|
125
|
+
* A tree mutation and its audit row committed, but its post-commit
|
|
126
|
+
* `afterTreeChange` work failed. Callers still receive a rejection so they can
|
|
127
|
+
* reconcile, while transports can distinguish it from a rolled-back mutation.
|
|
128
|
+
*/
|
|
129
|
+
export declare const ERR_TREE_HOOK_COMMITTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
package/dist/lib/errors.js
CHANGED
|
@@ -53,6 +53,7 @@ export class BylineError extends Error {
|
|
|
53
53
|
super(message, { cause });
|
|
54
54
|
}
|
|
55
55
|
this.code = code;
|
|
56
|
+
this.name = 'BylineError';
|
|
56
57
|
this.details = details;
|
|
57
58
|
this.logExtra = logExtra;
|
|
58
59
|
this.logLevel = logLevel;
|
|
@@ -118,9 +119,15 @@ export const ErrorCodes = {
|
|
|
118
119
|
DATABASE: 'ERR_DATABASE',
|
|
119
120
|
STORAGE: 'ERR_STORAGE',
|
|
120
121
|
READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED',
|
|
122
|
+
READ_RECURSION: 'ERR_READ_RECURSION',
|
|
121
123
|
PATH_CONFLICT: 'ERR_PATH_CONFLICT',
|
|
122
124
|
AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED',
|
|
125
|
+
TREE_HOOK_COMMITTED: 'ERR_TREE_HOOK_COMMITTED',
|
|
123
126
|
};
|
|
127
|
+
/** Stable message fallback for stale tree-placement `ERR_CONFLICT` errors. */
|
|
128
|
+
export const TREE_PLACEMENT_STALE_MARKER = '[ERR_CONFLICT:TREE_PLACEMENT_STALE]';
|
|
129
|
+
/** Stable message fallback for `ERR_TREE_HOOK_COMMITTED` errors. */
|
|
130
|
+
export const TREE_HOOK_COMMITTED_MARKER = '[ERR_TREE_HOOK_COMMITTED]';
|
|
124
131
|
// ---------------------------------------------------------------------------
|
|
125
132
|
// Pre-instantiated factories
|
|
126
133
|
// ---------------------------------------------------------------------------
|
|
@@ -133,6 +140,7 @@ export const ERR_PATCH_FAILED = createErrorType(ErrorCodes.PATCH_FAILED);
|
|
|
133
140
|
export const ERR_DATABASE = createErrorType(ErrorCodes.DATABASE);
|
|
134
141
|
export const ERR_STORAGE = createErrorType(ErrorCodes.STORAGE);
|
|
135
142
|
export const ERR_READ_BUDGET_EXCEEDED = createErrorType(ErrorCodes.READ_BUDGET_EXCEEDED);
|
|
143
|
+
export const ERR_READ_RECURSION = createErrorType(ErrorCodes.READ_RECURSION);
|
|
136
144
|
/**
|
|
137
145
|
* Thrown when a write attempts to set `path` to a value already used by
|
|
138
146
|
* another document in the same `(collection, locale)` scope. Surfaces the
|
|
@@ -150,3 +158,9 @@ export const ERR_PATH_CONFLICT = createErrorType(ErrorCodes.PATH_CONFLICT, 'warn
|
|
|
150
158
|
* docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
|
|
151
159
|
*/
|
|
152
160
|
export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTED);
|
|
161
|
+
/**
|
|
162
|
+
* A tree mutation and its audit row committed, but its post-commit
|
|
163
|
+
* `afterTreeChange` work failed. Callers still receive a rejection so they can
|
|
164
|
+
* reconcile, while transports can distinguish it from a rolled-back mutation.
|
|
165
|
+
*/
|
|
166
|
+
export const ERR_TREE_HOOK_COMMITTED = createErrorType(ErrorCodes.TREE_HOOK_COMMITTED, 'warn');
|
|
@@ -28,7 +28,7 @@ declare const DOCUMENT_LEVEL_KEYS: Set<string>;
|
|
|
28
28
|
*/
|
|
29
29
|
export interface ParseContext {
|
|
30
30
|
/** All registered collection definitions. */
|
|
31
|
-
collections: CollectionDefinition[];
|
|
31
|
+
collections: readonly CollectionDefinition[];
|
|
32
32
|
/** Resolve a collection path → DB row id. */
|
|
33
33
|
resolveCollectionId: (path: string) => Promise<string>;
|
|
34
34
|
/**
|
|
@@ -87,6 +87,15 @@ declare const DOCUMENT_SORT_COLUMNS: Record<string, string>;
|
|
|
87
87
|
* sensible composition through a relation hop.
|
|
88
88
|
*/
|
|
89
89
|
export declare function parseWhere(where: WhereClause | undefined, definition: CollectionDefinition, ctx?: ParseContext): Promise<ParsedWhere>;
|
|
90
|
+
/**
|
|
91
|
+
* Compile a predicate wholly to adapter `DocumentFilter`s. Unlike
|
|
92
|
+
* `parseWhere`, top-level `status` and `path` are emitted as document-column
|
|
93
|
+
* filters rather than scalar list-query options. This is the form used by
|
|
94
|
+
* `beforeRead` on detail, populate, count, and tree reads.
|
|
95
|
+
*/
|
|
96
|
+
export declare function parsePredicateFilters(where: QueryPredicate | undefined, definition: CollectionDefinition, ctx?: ParseContext, options?: {
|
|
97
|
+
strict?: boolean;
|
|
98
|
+
}): Promise<DocumentFilter[]>;
|
|
90
99
|
/**
|
|
91
100
|
* Combine a `beforeRead` hook predicate with a caller-supplied where
|
|
92
101
|
* clause using implicit AND. Returns whichever side is non-empty, or
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { ERR_VALIDATION } from '../lib/errors.js';
|
|
8
9
|
import { fieldTypeToStore } from '../storage/field-store-map.js';
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Document-level reserved keys
|
|
@@ -44,6 +45,144 @@ const DOCUMENT_SORT_COLUMNS = {
|
|
|
44
45
|
export async function parseWhere(where, definition, ctx) {
|
|
45
46
|
return parseWhereInternal(where, definition, ctx, { isNested: false, inCombinator: false });
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Compile a predicate wholly to adapter `DocumentFilter`s. Unlike
|
|
50
|
+
* `parseWhere`, top-level `status` and `path` are emitted as document-column
|
|
51
|
+
* filters rather than scalar list-query options. This is the form used by
|
|
52
|
+
* `beforeRead` on detail, populate, count, and tree reads.
|
|
53
|
+
*/
|
|
54
|
+
export async function parsePredicateFilters(where, definition, ctx, options = {}) {
|
|
55
|
+
if (options.strict) {
|
|
56
|
+
await validateSecurityPredicate(where, definition, ctx);
|
|
57
|
+
}
|
|
58
|
+
const parsed = await parseWhereInternal(where, definition, ctx, {
|
|
59
|
+
isNested: false,
|
|
60
|
+
inCombinator: true,
|
|
61
|
+
});
|
|
62
|
+
return parsed.filters;
|
|
63
|
+
}
|
|
64
|
+
const FILTER_OPERATORS = new Set([
|
|
65
|
+
'$eq',
|
|
66
|
+
'$ne',
|
|
67
|
+
'$gt',
|
|
68
|
+
'$gte',
|
|
69
|
+
'$lt',
|
|
70
|
+
'$lte',
|
|
71
|
+
'$contains',
|
|
72
|
+
'$in',
|
|
73
|
+
'$nin',
|
|
74
|
+
]);
|
|
75
|
+
const DOCUMENT_COLUMN_OPERATORS = {
|
|
76
|
+
status: new Set(['$eq', '$ne', '$in', '$nin']),
|
|
77
|
+
path: new Set(['$eq', '$ne', '$contains', '$in', '$nin']),
|
|
78
|
+
id: new Set(['$eq', '$ne', '$in', '$nin']),
|
|
79
|
+
};
|
|
80
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
81
|
+
/** Fail-closed validation used only for security predicates from hooks. */
|
|
82
|
+
async function validateSecurityPredicate(predicate, definition, ctx, allowEmpty = false) {
|
|
83
|
+
if (predicate == null || typeof predicate !== 'object' || Array.isArray(predicate)) {
|
|
84
|
+
throw invalidSecurityPredicate(definition.path, 'predicate must be an object');
|
|
85
|
+
}
|
|
86
|
+
const entries = Object.entries(predicate).filter(([, value]) => value !== undefined);
|
|
87
|
+
if (entries.length === 0 && !allowEmpty) {
|
|
88
|
+
throw invalidSecurityPredicate(definition.path, 'predicate must contain at least one clause');
|
|
89
|
+
}
|
|
90
|
+
for (const [key, value] of entries) {
|
|
91
|
+
if (key === '$and' || key === '$or') {
|
|
92
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
93
|
+
throw invalidSecurityPredicate(definition.path, `${key} must be a non-empty array`);
|
|
94
|
+
}
|
|
95
|
+
for (const child of value) {
|
|
96
|
+
await validateSecurityPredicate(child, definition, ctx);
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (key === 'query') {
|
|
101
|
+
throw invalidSecurityPredicate(definition.path, '`query` is not supported in beforeRead security predicates');
|
|
102
|
+
}
|
|
103
|
+
if (key === 'status' || key === 'path' || key === 'id') {
|
|
104
|
+
validateDocumentColumnPredicate(value, definition.path, key);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const field = definition.fields.find((candidate) => candidate.name === key);
|
|
108
|
+
if (field == null) {
|
|
109
|
+
throw invalidSecurityPredicate(definition.path, `unknown field '${key}'`);
|
|
110
|
+
}
|
|
111
|
+
if (field.type === 'relation' && (isQuantifierObject(value) || isPlainSubWhere(value))) {
|
|
112
|
+
const targetPath = field.targetCollection;
|
|
113
|
+
const target = targetPath
|
|
114
|
+
? ctx?.collections.find((candidate) => candidate.path === targetPath)
|
|
115
|
+
: null;
|
|
116
|
+
if (target == null) {
|
|
117
|
+
throw invalidSecurityPredicate(definition.path, `cannot resolve target collection for relation field '${key}'`);
|
|
118
|
+
}
|
|
119
|
+
if (isQuantifierObject(value)) {
|
|
120
|
+
for (const [quantifier, nested] of Object.entries(value)) {
|
|
121
|
+
await validateSecurityPredicate(nested, target, ctx, quantifier === '$none');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
await validateSecurityPredicate(value, target, ctx);
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (fieldTypeToStore[field.type] == null) {
|
|
130
|
+
throw invalidSecurityPredicate(definition.path, `field '${key}' cannot be used as a predicate`);
|
|
131
|
+
}
|
|
132
|
+
validateSecurityOperator(value, definition.path, key);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function validateDocumentColumnPredicate(value, collectionPath, column) {
|
|
136
|
+
validateSecurityOperator(value, collectionPath, column);
|
|
137
|
+
const parsed = normaliseToOperator(value);
|
|
138
|
+
if (parsed == null || !DOCUMENT_COLUMN_OPERATORS[column].has(parsed.operator)) {
|
|
139
|
+
throw invalidSecurityPredicate(collectionPath, `operator '${parsed?.operator ?? 'unknown'}' is not supported for '${column}'`);
|
|
140
|
+
}
|
|
141
|
+
const values = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
|
|
142
|
+
if ((parsed.operator === '$in' || parsed.operator === '$nin') && !Array.isArray(parsed.value)) {
|
|
143
|
+
throw invalidSecurityPredicate(collectionPath, `${parsed.operator} for '${column}' requires an array`);
|
|
144
|
+
}
|
|
145
|
+
if (parsed.operator !== '$in' && parsed.operator !== '$nin' && Array.isArray(parsed.value)) {
|
|
146
|
+
throw invalidSecurityPredicate(collectionPath, `${parsed.operator} for '${column}' requires a string`);
|
|
147
|
+
}
|
|
148
|
+
if (values.some((item) => typeof item !== 'string')) {
|
|
149
|
+
throw invalidSecurityPredicate(collectionPath, `predicate for '${column}' requires string values`);
|
|
150
|
+
}
|
|
151
|
+
if (column === 'id' && values.some((item) => !UUID_RE.test(item))) {
|
|
152
|
+
throw invalidSecurityPredicate(collectionPath, `predicate for 'id' requires UUID values; use { id: { $in: [] } } to deny all rows`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function validateSecurityOperator(value, collectionPath, fieldName) {
|
|
156
|
+
if (value === null ||
|
|
157
|
+
typeof value === 'string' ||
|
|
158
|
+
typeof value === 'number' ||
|
|
159
|
+
typeof value === 'boolean') {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
163
|
+
throw invalidSecurityPredicate(collectionPath, `invalid predicate for '${fieldName}'`);
|
|
164
|
+
}
|
|
165
|
+
const entries = Object.entries(value);
|
|
166
|
+
if (entries.length !== 1 || !FILTER_OPERATORS.has(entries[0]?.[0] ?? '')) {
|
|
167
|
+
throw invalidSecurityPredicate(collectionPath, `predicate for '${fieldName}' must contain exactly one supported operator`);
|
|
168
|
+
}
|
|
169
|
+
const [operator, operand] = entries[0] ?? [];
|
|
170
|
+
if ((operator === '$in' || operator === '$nin') && !Array.isArray(operand)) {
|
|
171
|
+
throw invalidSecurityPredicate(collectionPath, `${operator} for '${fieldName}' requires an array`);
|
|
172
|
+
}
|
|
173
|
+
if (operator === '$contains' && typeof operand !== 'string') {
|
|
174
|
+
throw invalidSecurityPredicate(collectionPath, `$contains for '${fieldName}' requires a string`);
|
|
175
|
+
}
|
|
176
|
+
if (operand === undefined) {
|
|
177
|
+
throw invalidSecurityPredicate(collectionPath, `operator for '${fieldName}' requires a value`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function invalidSecurityPredicate(collectionPath, reason) {
|
|
181
|
+
return ERR_VALIDATION({
|
|
182
|
+
message: `invalid beforeRead predicate for '${collectionPath}': ${reason}`,
|
|
183
|
+
details: { collectionPath, reason },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
47
186
|
/**
|
|
48
187
|
* Combine a `beforeRead` hook predicate with a caller-supplied where
|
|
49
188
|
* clause using implicit AND. Returns whichever side is non-empty, or
|
|
@@ -179,7 +318,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
179
318
|
kind: 'docColumn',
|
|
180
319
|
column: 'status',
|
|
181
320
|
operator: parsed.operator,
|
|
182
|
-
value:
|
|
321
|
+
value: stringifyDocumentColumnValue(parsed.value),
|
|
183
322
|
});
|
|
184
323
|
}
|
|
185
324
|
continue;
|
|
@@ -211,7 +350,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
|
|
|
211
350
|
kind: 'docColumn',
|
|
212
351
|
column: 'path',
|
|
213
352
|
operator: parsed.operator,
|
|
214
|
-
value:
|
|
353
|
+
value: stringifyDocumentColumnValue(parsed.value),
|
|
215
354
|
});
|
|
216
355
|
}
|
|
217
356
|
continue;
|
|
@@ -449,5 +588,10 @@ function normaliseToOperator(raw) {
|
|
|
449
588
|
value: val,
|
|
450
589
|
};
|
|
451
590
|
}
|
|
591
|
+
function stringifyDocumentColumnValue(value) {
|
|
592
|
+
if (value === null)
|
|
593
|
+
return null;
|
|
594
|
+
return Array.isArray(value) ? value.map(String) : String(value);
|
|
595
|
+
}
|
|
452
596
|
/** Exported for testing. */
|
|
453
597
|
export { DOCUMENT_LEVEL_KEYS, DOCUMENT_SORT_COLUMNS };
|