@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.
Files changed (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. 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 a
80
- * sentinel predicate that yields no rows (e.g. `{ id: '__none__' }`)
81
- * rather than throwing when the actor can read nothing empty results
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 *provider's* count. When the querying collection applies
207
- * `beforeRead` row scoping, unauthorized hits are dropped **after**
208
- * ranking (see `CollectionHandle.search`), so `total` — like facet
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 path segments for the admin and (future) public API routes.
21
- * Both values are optional on the input side — callers typically read the
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
- * URL segments for admin and API routes. Both keys default to `/admin`
119
- * and `/api` respectively installations only set this when they want
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?: Partial<RoutesConfig>;
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 { ReadContext } from '../@types/db-types.js';
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
- * Behaviour:
17
- * - Cache hit on `readContext.beforeReadCache` (keyed by collection
18
- * path) returns the cached value immediately. The actor is invariant
19
- * for the lifetime of one `ReadContext`, so a single key per
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
- * Behaviour:
14
- * - Cache hit on `readContext.beforeReadCache` (keyed by collection
15
- * path) returns the cached value immediately. The actor is invariant
16
- * for the lifetime of one `ReadContext`, so a single key per
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
- if (readContext.beforeReadCache.has(collectionPath)) {
32
- return readContext.beforeReadCache.get(collectionPath) ?? null;
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
- const resolved = await resolveHooks(definition);
35
- const hooks = normalizeBeforeReadHook(resolved?.beforeRead);
36
- if (hooks.length === 0) {
37
- readContext.beforeReadCache.set(collectionPath, null);
38
- return null;
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
- const predicates = [];
41
- for (const hook of hooks) {
42
- const result = await hook({
43
- collectionPath,
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
- if (result != null) {
48
- predicates.push(result);
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 combined;
52
- if (predicates.length === 0) {
53
- combined = null;
130
+ let definitionState = domain.definitions.get(definition);
131
+ if (!definitionState) {
132
+ definitionState = { modes: new Map() };
133
+ domain.definitions.set(definition, definitionState);
54
134
  }
55
- else if (predicates.length === 1) {
56
- combined = predicates[0];
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
- else {
59
- combined = { $and: predicates };
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
- readContext.beforeReadCache.set(collectionPath, combined);
62
- return combined;
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) {