@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
|
@@ -76,9 +76,9 @@ export type PredicateValue = string | number | boolean | null | FilterOperators
|
|
|
76
76
|
* }
|
|
77
77
|
* ```
|
|
78
78
|
*
|
|
79
|
-
* Returning `undefined` from `beforeRead` means "no scoping". Use
|
|
80
|
-
*
|
|
81
|
-
*
|
|
79
|
+
* Returning `undefined` from `beforeRead` means "no scoping". Use
|
|
80
|
+
* `{ id: { $in: [] } }` as the always-false predicate rather than an invalid
|
|
81
|
+
* UUID sentinel or an exception when the actor can read nothing. Empty results
|
|
82
82
|
* are the correct shape for list endpoints.
|
|
83
83
|
*/
|
|
84
84
|
export interface QueryPredicate {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/** The persisted, unpopulated value of a relation field. */
|
|
9
|
+
export interface RelatedDocumentValue {
|
|
10
|
+
targetDocumentId: string;
|
|
11
|
+
targetCollectionId: string;
|
|
12
|
+
relationshipType?: string;
|
|
13
|
+
cascadeDelete?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** A relation value that has not passed through population. */
|
|
16
|
+
export interface UnpopulatedRelationValue extends RelatedDocumentValue {
|
|
17
|
+
_resolved?: never;
|
|
18
|
+
_cycle?: never;
|
|
19
|
+
document?: never;
|
|
20
|
+
}
|
|
21
|
+
/** Marker used when the target was already materialised in this read request. */
|
|
22
|
+
export interface CycleRelationValue extends RelatedDocumentValue {
|
|
23
|
+
_resolved: true;
|
|
24
|
+
_cycle: true;
|
|
25
|
+
}
|
|
26
|
+
/** Marker used when the referenced target could not be read. */
|
|
27
|
+
export interface UnresolvedRelationValue extends RelatedDocumentValue {
|
|
28
|
+
_resolved: false;
|
|
29
|
+
}
|
|
30
|
+
/** Relation envelope used when population successfully resolves the target. */
|
|
31
|
+
export interface PopulatedRelationValue<TDocument = Record<string, any>> extends RelatedDocumentValue {
|
|
32
|
+
_resolved: true;
|
|
33
|
+
document: TDocument;
|
|
34
|
+
}
|
|
35
|
+
/** Every relation shape that can occur on the read path. */
|
|
36
|
+
export type RelationReadValue<TDocument = Record<string, any>> = UnpopulatedRelationValue | PopulatedRelationValue<TDocument> | UnresolvedRelationValue | CycleRelationValue;
|
|
37
|
+
/** Read shape for a single-target or `hasMany` relation field. */
|
|
38
|
+
export type RelationFieldReadValue<HasMany extends boolean, TDocument = Record<string, any>> = HasMany extends true ? RelationReadValue<TDocument>[] : RelationReadValue<TDocument>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -203,10 +203,9 @@ export interface SearchResults {
|
|
|
203
203
|
/**
|
|
204
204
|
* Total matches across all pages (not just the returned `hits`).
|
|
205
205
|
*
|
|
206
|
-
* This is the
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* counts — is approximate under scoping and exact without it.
|
|
206
|
+
* This is the provider-level count. Client authorization layers must not
|
|
207
|
+
* expose it when collections or rows are removed after ranking; they return
|
|
208
|
+
* a conservative authorized page count instead.
|
|
210
209
|
*/
|
|
211
210
|
total: number;
|
|
212
211
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { SessionProvider } from '@byline/auth';
|
|
9
9
|
import type { SlugifierFn } from '../utils/slugify.js';
|
|
10
10
|
import type { CollectionAdminConfig } from './admin-types.js';
|
|
11
|
-
import type { CollectionDefinition } from './collection-types.js';
|
|
11
|
+
import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, UploadHooks, UploadHooksLoader } from './collection-types.js';
|
|
12
12
|
import type { IDbAdapter } from './db-types.js';
|
|
13
13
|
import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn, RichTextToTextFn } from './field-types.js';
|
|
14
14
|
import type { SearchProvider } from './search-types.js';
|
|
@@ -17,14 +17,19 @@ export type DbAdapterFn = (args: {
|
|
|
17
17
|
connectionString: string;
|
|
18
18
|
}) => IDbAdapter;
|
|
19
19
|
/**
|
|
20
|
-
* URL
|
|
21
|
-
*
|
|
22
|
-
* resolved shape via `resolveRoutes()` which fills in `'/admin'` and `'/api'`
|
|
23
|
-
* defaults.
|
|
20
|
+
* Resolved client-safe URL paths for Byline-owned routes. All paths may contain
|
|
21
|
+
* multiple segments when their route trees remain separate.
|
|
24
22
|
*/
|
|
25
23
|
export interface RoutesConfig {
|
|
26
|
-
admin: string;
|
|
27
|
-
api: string;
|
|
24
|
+
readonly admin: string;
|
|
25
|
+
readonly api: string;
|
|
26
|
+
readonly signIn: string;
|
|
27
|
+
}
|
|
28
|
+
/** Partial route values accepted at configuration boundaries. */
|
|
29
|
+
export interface RoutesConfigInput {
|
|
30
|
+
admin?: string;
|
|
31
|
+
api?: string;
|
|
32
|
+
signIn?: string;
|
|
28
33
|
}
|
|
29
34
|
/**
|
|
30
35
|
* Common configuration shared by the server and client. Contains only
|
|
@@ -113,14 +118,12 @@ export interface BaseConfig {
|
|
|
113
118
|
*/
|
|
114
119
|
translations?: TranslationBundleShape;
|
|
115
120
|
};
|
|
116
|
-
collections: CollectionDefinition[];
|
|
121
|
+
collections: readonly CollectionDefinition[];
|
|
117
122
|
/**
|
|
118
|
-
*
|
|
119
|
-
* and
|
|
120
|
-
* to mount the admin or API at a non-default path. Consumers should
|
|
121
|
-
* read these via `resolveRoutes()` so the defaults are always applied.
|
|
123
|
+
* Client-safe admin, API, and sign-in route input. Registration applies
|
|
124
|
+
* defaults and canonicalization before exposing the config to consumers.
|
|
122
125
|
*/
|
|
123
|
-
routes?:
|
|
126
|
+
routes?: RoutesConfigInput;
|
|
124
127
|
}
|
|
125
128
|
/**
|
|
126
129
|
* Inline structural shape for the admin translation registry. Kept here
|
|
@@ -190,6 +193,31 @@ export interface ClientConfig extends BaseConfig {
|
|
|
190
193
|
};
|
|
191
194
|
};
|
|
192
195
|
}
|
|
196
|
+
/** Client config returned after boundary validation and canonicalization. */
|
|
197
|
+
export type ResolvedClientConfig = Omit<ClientConfig, 'routes'> & {
|
|
198
|
+
routes: RoutesConfig;
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Server-only lifecycle hook registry. Collection keys are collection paths;
|
|
202
|
+
* upload keys are `<collectionPath>.<canonical schema path>`. Canonical upload
|
|
203
|
+
* paths contain field names without runtime array indexes, and include a block
|
|
204
|
+
* type segment immediately after a blocks field.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```ts
|
|
208
|
+
* hooks: {
|
|
209
|
+
* collections: { docs: () => import('./collections/docs/hooks.js') },
|
|
210
|
+
* uploads: {
|
|
211
|
+
* 'documents.files.filesGroup.publicationFile': () => import('./collections/files/hooks.js'),
|
|
212
|
+
* 'pages.content.hero.backgroundImage': () => import('./collections/pages/hero-hooks.js'),
|
|
213
|
+
* },
|
|
214
|
+
* }
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export interface ServerHooksConfig {
|
|
218
|
+
collections?: Record<string, CollectionHooks | CollectionHooksLoader>;
|
|
219
|
+
uploads?: Record<string, UploadHooks | UploadHooksLoader>;
|
|
220
|
+
}
|
|
193
221
|
/**
|
|
194
222
|
* Server-side configuration. Extends BaseConfig with database and storage
|
|
195
223
|
* adapters. Deliberately does NOT extend ClientConfig — the server has no
|
|
@@ -204,6 +232,13 @@ export interface ClientConfig extends BaseConfig {
|
|
|
204
232
|
*/
|
|
205
233
|
export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
|
|
206
234
|
db: IDbAdapter;
|
|
235
|
+
/**
|
|
236
|
+
* Server-only collection and upload hooks, attached to schema definitions
|
|
237
|
+
* after initialization succeeds. Prefer this registry when hook modules
|
|
238
|
+
* import server-only code; portable schema modules stay host-agnostic and
|
|
239
|
+
* client-safe.
|
|
240
|
+
*/
|
|
241
|
+
hooks?: ServerHooksConfig;
|
|
207
242
|
/**
|
|
208
243
|
* Site-wide default storage provider for upload-capable image/file
|
|
209
244
|
* fields.
|
|
@@ -370,3 +405,7 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
|
|
|
370
405
|
*/
|
|
371
406
|
search?: SearchProvider;
|
|
372
407
|
}
|
|
408
|
+
/** Server config returned after boundary validation and canonicalization. */
|
|
409
|
+
export type ResolvedServerConfig<TAdminStore = unknown> = Omit<ServerConfig<TAdminStore>, 'routes'> & {
|
|
410
|
+
routes: RoutesConfig;
|
|
411
|
+
};
|
|
@@ -7,22 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { RequestContext } from '@byline/auth';
|
|
9
9
|
import { type CollectionDefinition } from '../@types/collection-types.js';
|
|
10
|
-
import type
|
|
10
|
+
import { type ParseContext } from '../query/parse-where.js';
|
|
11
|
+
import type { DocumentFilter, ReadContext } from '../@types/db-types.js';
|
|
11
12
|
import type { QueryPredicate } from '../@types/query-predicate.js';
|
|
12
13
|
/**
|
|
13
14
|
* Resolve the per-collection `beforeRead` hook predicate for the current
|
|
14
15
|
* request, with caching across populate fanout.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* collection is sufficient.
|
|
21
|
-
* - Each configured hook function runs in declaration order. Predicates
|
|
22
|
-
* returned by multiple hooks are combined with implicit AND. Hooks
|
|
23
|
-
* that return `void` / `undefined` are skipped.
|
|
24
|
-
* - The result is stored in the cache (including `null` for "ran with
|
|
25
|
-
* no scoping") so subsequent batches in the same request reuse it.
|
|
17
|
+
* Each configured hook function runs in declaration order. Predicates from
|
|
18
|
+
* multiple hooks are combined with implicit AND. Results are cached in
|
|
19
|
+
* module-private state bound to one request authority; caller-owned
|
|
20
|
+
* `ReadContext` properties are never consulted for authorization.
|
|
26
21
|
*
|
|
27
22
|
* Returns `null` when no hook is configured, or every hook returned
|
|
28
23
|
* void. Callers (`CollectionHandle`, `populateDocuments`) treat `null`
|
|
@@ -32,4 +27,21 @@ export declare function applyBeforeRead(params: {
|
|
|
32
27
|
definition: CollectionDefinition;
|
|
33
28
|
requestContext: RequestContext;
|
|
34
29
|
readContext: ReadContext;
|
|
30
|
+
/** Stable adapter/client identity. Defaults to the definition for direct callers. */
|
|
31
|
+
securityDomain?: object;
|
|
35
32
|
}): Promise<QueryPredicate | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve and strictly compile the security predicate once per logical read.
|
|
35
|
+
* Promise caching also prevents concurrent populate branches from compiling
|
|
36
|
+
* the same predicate or resolving its relation collection ids more than once.
|
|
37
|
+
*/
|
|
38
|
+
export declare function compileBeforeReadFilters(params: {
|
|
39
|
+
definition: CollectionDefinition;
|
|
40
|
+
requestContext: RequestContext;
|
|
41
|
+
readContext: ReadContext;
|
|
42
|
+
parseContext: ParseContext;
|
|
43
|
+
/** Stable identity shared by every fanout path using this adapter/client. */
|
|
44
|
+
securityDomain: object;
|
|
45
|
+
}): Promise<DocumentFilter[] | undefined>;
|
|
46
|
+
/** Bind a logical read to one immutable request authority. */
|
|
47
|
+
export declare function bindReadContextAuthority(readContext: ReadContext, requestContext: RequestContext): void;
|
|
@@ -6,60 +6,166 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { resolveHooks, } from '../@types/collection-types.js';
|
|
9
|
+
import { ERR_READ_RECURSION, ERR_VALIDATION } from '../lib/errors.js';
|
|
10
|
+
import { parsePredicateFilters } from '../query/parse-where.js';
|
|
11
|
+
import { createHookReadContext, getReadContextScope } from './read-context-scope.js';
|
|
12
|
+
const readSecurityStates = new WeakMap();
|
|
9
13
|
/**
|
|
10
14
|
* Resolve the per-collection `beforeRead` hook predicate for the current
|
|
11
15
|
* request, with caching across populate fanout.
|
|
12
16
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* collection is sufficient.
|
|
18
|
-
* - Each configured hook function runs in declaration order. Predicates
|
|
19
|
-
* returned by multiple hooks are combined with implicit AND. Hooks
|
|
20
|
-
* that return `void` / `undefined` are skipped.
|
|
21
|
-
* - The result is stored in the cache (including `null` for "ran with
|
|
22
|
-
* no scoping") so subsequent batches in the same request reuse it.
|
|
17
|
+
* Each configured hook function runs in declaration order. Predicates from
|
|
18
|
+
* multiple hooks are combined with implicit AND. Results are cached in
|
|
19
|
+
* module-private state bound to one request authority; caller-owned
|
|
20
|
+
* `ReadContext` properties are never consulted for authorization.
|
|
23
21
|
*
|
|
24
22
|
* Returns `null` when no hook is configured, or every hook returned
|
|
25
23
|
* void. Callers (`CollectionHandle`, `populateDocuments`) treat `null`
|
|
26
24
|
* the same as "no scoping" — they pass nothing extra to the adapter.
|
|
27
25
|
*/
|
|
28
26
|
export async function applyBeforeRead(params) {
|
|
29
|
-
const { definition, requestContext, readContext } = params;
|
|
27
|
+
const { definition, requestContext, readContext, securityDomain = definition } = params;
|
|
30
28
|
const collectionPath = definition.path;
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
const scope = getReadContextScope(readContext);
|
|
30
|
+
const entry = getBeforeReadCacheEntry(scope.root, requestContext, securityDomain, definition);
|
|
31
|
+
if (scope.ancestry.includes(entry)) {
|
|
32
|
+
throw ERR_READ_RECURSION({
|
|
33
|
+
message: `beforeRead recursion blocked for collection '${collectionPath}'`,
|
|
34
|
+
details: { collectionPath, readMode: requestContext.readMode ?? 'any' },
|
|
35
|
+
});
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
if (entry.predicate)
|
|
38
|
+
return entry.predicate;
|
|
39
|
+
const pending = (async () => {
|
|
40
|
+
const resolved = await resolveHooks(definition);
|
|
41
|
+
const hooks = normalizeBeforeReadHook(resolved?.beforeRead);
|
|
42
|
+
if (hooks.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
const predicates = [];
|
|
45
|
+
for (const hook of hooks) {
|
|
46
|
+
const result = await hook({
|
|
47
|
+
collectionPath,
|
|
48
|
+
requestContext,
|
|
49
|
+
readContext: createHookReadContext(scope, entry),
|
|
50
|
+
});
|
|
51
|
+
if (result != null)
|
|
52
|
+
predicates.push(result);
|
|
53
|
+
}
|
|
54
|
+
if (predicates.length === 0)
|
|
55
|
+
return null;
|
|
56
|
+
if (predicates.length === 1)
|
|
57
|
+
return predicates[0] ?? null;
|
|
58
|
+
return { $and: predicates };
|
|
59
|
+
})();
|
|
60
|
+
entry.predicate = pending;
|
|
61
|
+
return pending;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve and strictly compile the security predicate once per logical read.
|
|
65
|
+
* Promise caching also prevents concurrent populate branches from compiling
|
|
66
|
+
* the same predicate or resolving its relation collection ids more than once.
|
|
67
|
+
*/
|
|
68
|
+
export async function compileBeforeReadFilters(params) {
|
|
69
|
+
const { definition, requestContext, readContext, parseContext, securityDomain } = params;
|
|
70
|
+
const scope = getReadContextScope(readContext);
|
|
71
|
+
const entry = getBeforeReadCacheEntry(scope.root, requestContext, securityDomain, definition);
|
|
72
|
+
if (scope.ancestry.includes(entry)) {
|
|
73
|
+
throw ERR_READ_RECURSION({
|
|
74
|
+
message: `beforeRead recursion blocked for collection '${definition.path}'`,
|
|
75
|
+
details: {
|
|
76
|
+
collectionPath: definition.path,
|
|
77
|
+
readMode: requestContext.readMode ?? 'any',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
39
80
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
81
|
+
if (entry.compiledFilters)
|
|
82
|
+
return entry.compiledFilters;
|
|
83
|
+
const compiled = (async () => {
|
|
84
|
+
const predicate = await applyBeforeRead({
|
|
85
|
+
definition,
|
|
44
86
|
requestContext,
|
|
45
87
|
readContext,
|
|
88
|
+
securityDomain,
|
|
89
|
+
});
|
|
90
|
+
if (predicate == null)
|
|
91
|
+
return undefined;
|
|
92
|
+
const filters = await parsePredicateFilters(predicate, definition, parseContext, {
|
|
93
|
+
strict: true,
|
|
46
94
|
});
|
|
47
|
-
|
|
48
|
-
|
|
95
|
+
return filters.length > 0 ? filters : undefined;
|
|
96
|
+
})();
|
|
97
|
+
entry.compiledFilters = compiled;
|
|
98
|
+
return compiled;
|
|
99
|
+
}
|
|
100
|
+
/** Bind a logical read to one immutable request authority. */
|
|
101
|
+
export function bindReadContextAuthority(readContext, requestContext) {
|
|
102
|
+
getReadSecurityState(readContext, requestContext);
|
|
103
|
+
}
|
|
104
|
+
function getReadSecurityState(readContext, requestContext) {
|
|
105
|
+
const root = getReadContextScope(readContext).root;
|
|
106
|
+
const authorityToken = requestAuthorityToken(requestContext);
|
|
107
|
+
const existing = readSecurityStates.get(root);
|
|
108
|
+
if (existing) {
|
|
109
|
+
if (existing.authorityToken !== authorityToken) {
|
|
110
|
+
throw ERR_VALIDATION({
|
|
111
|
+
message: 'ReadContext cannot be reused across request authorities',
|
|
112
|
+
});
|
|
49
113
|
}
|
|
114
|
+
return existing;
|
|
115
|
+
}
|
|
116
|
+
const state = {
|
|
117
|
+
authorityToken,
|
|
118
|
+
domains: new WeakMap(),
|
|
119
|
+
};
|
|
120
|
+
readSecurityStates.set(root, state);
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
function getBeforeReadCacheEntry(readContext, requestContext, securityDomain, definition) {
|
|
124
|
+
const state = getReadSecurityState(readContext, requestContext);
|
|
125
|
+
let domain = state.domains.get(securityDomain);
|
|
126
|
+
if (!domain) {
|
|
127
|
+
domain = { definitions: new WeakMap() };
|
|
128
|
+
state.domains.set(securityDomain, domain);
|
|
50
129
|
}
|
|
51
|
-
let
|
|
52
|
-
if (
|
|
53
|
-
|
|
130
|
+
let definitionState = domain.definitions.get(definition);
|
|
131
|
+
if (!definitionState) {
|
|
132
|
+
definitionState = { modes: new Map() };
|
|
133
|
+
domain.definitions.set(definition, definitionState);
|
|
54
134
|
}
|
|
55
|
-
|
|
56
|
-
|
|
135
|
+
const mode = requestContext.readMode ?? 'any';
|
|
136
|
+
let entry = definitionState.modes.get(mode);
|
|
137
|
+
if (!entry) {
|
|
138
|
+
entry = {};
|
|
139
|
+
definitionState.modes.set(mode, entry);
|
|
57
140
|
}
|
|
58
|
-
|
|
59
|
-
|
|
141
|
+
return entry;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `requestId` stays in the token deliberately. A `ReadContext` is a
|
|
145
|
+
* per-logical-request object, and predicates cached on it may embed
|
|
146
|
+
* request-time state (embargo cutoffs, preview windows), so reusing one
|
|
147
|
+
* across requests must fail loudly even for the same actor. The flip side
|
|
148
|
+
* is a contract on host adapters: a `RequestContext` factory must return
|
|
149
|
+
* the same instance for every call within one logical request (see
|
|
150
|
+
* `oncePerRequest` in `@byline/host-tanstack-start`) — a factory that
|
|
151
|
+
* mints a fresh `requestId` per call makes any two reads sharing a
|
|
152
|
+
* `ReadContext` throw the cross-authority error above.
|
|
153
|
+
*/
|
|
154
|
+
function requestAuthorityToken(requestContext) {
|
|
155
|
+
const actor = requestContext.actor;
|
|
156
|
+
if (actor == null) {
|
|
157
|
+
return JSON.stringify([requestContext.requestId, requestContext.locale ?? null, 'anonymous']);
|
|
60
158
|
}
|
|
61
|
-
|
|
62
|
-
|
|
159
|
+
const realm = 'isSuperAdmin' in actor ? 'admin' : 'user';
|
|
160
|
+
const isSuperAdmin = 'isSuperAdmin' in actor ? actor.isSuperAdmin : false;
|
|
161
|
+
return JSON.stringify([
|
|
162
|
+
requestContext.requestId,
|
|
163
|
+
requestContext.locale ?? null,
|
|
164
|
+
realm,
|
|
165
|
+
actor.id,
|
|
166
|
+
isSuperAdmin,
|
|
167
|
+
Array.from(actor.abilities).sort(),
|
|
168
|
+
]);
|
|
63
169
|
}
|
|
64
170
|
/** Normalise a `beforeRead` slot (single function or array) into a flat array. */
|
|
65
171
|
function normalizeBeforeReadHook(slot) {
|