@byline/core 3.21.0 → 4.1.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 +66 -31
- package/dist/@types/db-types.d.ts +93 -47
- package/dist/@types/field-types.d.ts +25 -1
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +51 -12
- 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/index.js +34 -3
- package/dist/codegen/index.test.node.js +20 -2
- 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 +2 -2
- 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/host/host-request-bridge.d.ts +62 -0
- package/dist/host/host-request-bridge.js +38 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +14 -0
- package/dist/query/parse-where.d.ts +9 -0
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -1
- package/dist/services/collection-bootstrap.test.node.js +30 -59
- 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.js +13 -6
- 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.js +2 -1
- package/dist/services/document-lifecycle.test.node.js +360 -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 +2 -2
- package/dist/services/index.js +2 -2
- package/dist/services/populate.d.ts +7 -3
- 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 +20 -2
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- package/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- package/package.json +2 -2
|
@@ -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) {
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import { createRequestContext } from '@byline/auth';
|
|
9
|
-
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { AdminAuth, createRequestContext } from '@byline/auth';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { defineCollection, defineWorkflow, } from '../@types/collection-types.js';
|
|
11
11
|
import { createReadContext } from '../services/populate.js';
|
|
12
|
-
import { applyBeforeRead } from './apply-before-read.js';
|
|
12
|
+
import { applyBeforeRead, compileBeforeReadFilters } from './apply-before-read.js';
|
|
13
|
+
import { resolveReadContextRoot } from './read-context-scope.js';
|
|
13
14
|
const baseCollection = (hook) => defineCollection({
|
|
14
15
|
path: 'posts',
|
|
15
16
|
labels: { singular: 'Post', plural: 'Posts' },
|
|
@@ -101,6 +102,55 @@ describe('applyBeforeRead', () => {
|
|
|
101
102
|
expect(second).toBeNull();
|
|
102
103
|
expect(callCount).toBe(1);
|
|
103
104
|
});
|
|
105
|
+
it('ignores a caller-preseeded public cache entry', async () => {
|
|
106
|
+
const hook = vi.fn(() => ({ title: 'allowed' }));
|
|
107
|
+
const readContext = createReadContext({
|
|
108
|
+
beforeReadCache: new Map([['posts:any', null]]),
|
|
109
|
+
});
|
|
110
|
+
const result = await applyBeforeRead({
|
|
111
|
+
definition: baseCollection(hook),
|
|
112
|
+
requestContext: createRequestContext(),
|
|
113
|
+
readContext,
|
|
114
|
+
});
|
|
115
|
+
expect(hook).toHaveBeenCalledOnce();
|
|
116
|
+
expect(result).toEqual({ title: 'allowed' });
|
|
117
|
+
});
|
|
118
|
+
it('ignores a caller-preseeded public cache entry on the compiled-filter path', async () => {
|
|
119
|
+
const hook = vi.fn(() => ({ title: 'allowed' }));
|
|
120
|
+
const readContext = createReadContext({
|
|
121
|
+
beforeReadCache: new Map([['posts:any', null]]),
|
|
122
|
+
});
|
|
123
|
+
const filters = await compileBeforeReadFilters({
|
|
124
|
+
definition: baseCollection(hook),
|
|
125
|
+
requestContext: createRequestContext(),
|
|
126
|
+
readContext,
|
|
127
|
+
securityDomain: {},
|
|
128
|
+
parseContext: { collections: [], resolveCollectionId: vi.fn() },
|
|
129
|
+
});
|
|
130
|
+
expect(hook).toHaveBeenCalledOnce();
|
|
131
|
+
expect(filters).toEqual([
|
|
132
|
+
expect.objectContaining({ kind: 'field', fieldName: 'title', value: 'allowed' }),
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
it('rejects reuse under a different actor authority', async () => {
|
|
136
|
+
const hook = vi.fn(({ requestContext }) => ({
|
|
137
|
+
title: requestContext.actor?.id,
|
|
138
|
+
}));
|
|
139
|
+
const definition = baseCollection(hook);
|
|
140
|
+
const readContext = createReadContext();
|
|
141
|
+
const requestId = 'same-logical-request';
|
|
142
|
+
const alice = createRequestContext({
|
|
143
|
+
actor: new AdminAuth({ id: 'alice', abilities: ['collections.posts.read'] }),
|
|
144
|
+
requestId,
|
|
145
|
+
});
|
|
146
|
+
const bob = createRequestContext({
|
|
147
|
+
actor: new AdminAuth({ id: 'bob', abilities: ['collections.posts.read'] }),
|
|
148
|
+
requestId,
|
|
149
|
+
});
|
|
150
|
+
await applyBeforeRead({ definition, requestContext: alice, readContext });
|
|
151
|
+
await expect(applyBeforeRead({ definition, requestContext: bob, readContext })).rejects.toThrow('cannot be reused across request authorities');
|
|
152
|
+
expect(hook).toHaveBeenCalledOnce();
|
|
153
|
+
});
|
|
104
154
|
it('runs the hook again on a fresh ReadContext', async () => {
|
|
105
155
|
let callCount = 0;
|
|
106
156
|
const hook = () => {
|
|
@@ -113,6 +163,32 @@ describe('applyBeforeRead', () => {
|
|
|
113
163
|
await applyBeforeRead({ definition: def, requestContext, readContext: createReadContext() });
|
|
114
164
|
expect(callCount).toBe(2);
|
|
115
165
|
});
|
|
166
|
+
it('caches independently by effective read mode', async () => {
|
|
167
|
+
const modes = [];
|
|
168
|
+
const hook = ({ requestContext }) => {
|
|
169
|
+
modes.push(requestContext.readMode);
|
|
170
|
+
return { status: requestContext.readMode === 'published' ? 'published' : 'draft' };
|
|
171
|
+
};
|
|
172
|
+
const definition = baseCollection(hook);
|
|
173
|
+
const readContext = createReadContext();
|
|
174
|
+
const base = createRequestContext();
|
|
175
|
+
await applyBeforeRead({
|
|
176
|
+
definition,
|
|
177
|
+
requestContext: { ...base, readMode: 'published' },
|
|
178
|
+
readContext,
|
|
179
|
+
});
|
|
180
|
+
await applyBeforeRead({
|
|
181
|
+
definition,
|
|
182
|
+
requestContext: { ...base, readMode: 'any' },
|
|
183
|
+
readContext,
|
|
184
|
+
});
|
|
185
|
+
await applyBeforeRead({
|
|
186
|
+
definition,
|
|
187
|
+
requestContext: { ...base, readMode: 'published' },
|
|
188
|
+
readContext,
|
|
189
|
+
});
|
|
190
|
+
expect(modes).toEqual(['published', 'any']);
|
|
191
|
+
});
|
|
116
192
|
it('passes collectionPath, requestContext, and readContext through to the hook', async () => {
|
|
117
193
|
let received;
|
|
118
194
|
const hook = (ctx) => {
|
|
@@ -125,8 +201,40 @@ describe('applyBeforeRead', () => {
|
|
|
125
201
|
expect(received).toMatchObject({
|
|
126
202
|
collectionPath: 'posts',
|
|
127
203
|
requestContext,
|
|
204
|
+
});
|
|
205
|
+
expect(received.readContext.visited).toBe(readContext.visited);
|
|
206
|
+
});
|
|
207
|
+
it('delegates scoped read-budget state to the logical root context', async () => {
|
|
208
|
+
const readContext = createReadContext({ maxReads: 4 });
|
|
209
|
+
const hook = ({ readContext: scoped }) => {
|
|
210
|
+
scoped.visited.add('posts:nested');
|
|
211
|
+
scoped.readCount += 1;
|
|
212
|
+
scoped.maxReads = 3;
|
|
213
|
+
};
|
|
214
|
+
await applyBeforeRead({
|
|
215
|
+
definition: baseCollection(hook),
|
|
216
|
+
requestContext: createRequestContext(),
|
|
217
|
+
readContext,
|
|
218
|
+
});
|
|
219
|
+
expect(readContext.visited).toContain('posts:nested');
|
|
220
|
+
expect(readContext.readCount).toBe(1);
|
|
221
|
+
expect(readContext.maxReads).toBe(3);
|
|
222
|
+
});
|
|
223
|
+
it('resolves scoped hook contexts to their logical root', async () => {
|
|
224
|
+
const readContext = createReadContext();
|
|
225
|
+
let scopedContext;
|
|
226
|
+
const hook = ({ readContext: scoped }) => {
|
|
227
|
+
scopedContext = scoped;
|
|
228
|
+
};
|
|
229
|
+
await applyBeforeRead({
|
|
230
|
+
definition: baseCollection(hook),
|
|
231
|
+
requestContext: createRequestContext(),
|
|
128
232
|
readContext,
|
|
129
233
|
});
|
|
234
|
+
if (!scopedContext)
|
|
235
|
+
throw new Error('beforeRead did not receive a scoped ReadContext');
|
|
236
|
+
expect(resolveReadContextRoot(scopedContext)).toBe(readContext);
|
|
237
|
+
expect(resolveReadContextRoot(readContext)).toBe(readContext);
|
|
130
238
|
});
|
|
131
239
|
it('supports an async hook', async () => {
|
|
132
240
|
const hook = async () => {
|
|
@@ -140,4 +248,134 @@ describe('applyBeforeRead', () => {
|
|
|
140
248
|
});
|
|
141
249
|
expect(result).toEqual({ tenantId: 't-1' });
|
|
142
250
|
});
|
|
251
|
+
it('compiles security filters and resolves relation collection ids once per read context', async () => {
|
|
252
|
+
const categories = defineCollection({
|
|
253
|
+
path: 'categories',
|
|
254
|
+
labels: { singular: 'Category', plural: 'Categories' },
|
|
255
|
+
fields: [{ name: 'tenant', type: 'text', label: 'Tenant' }],
|
|
256
|
+
});
|
|
257
|
+
const definition = defineCollection({
|
|
258
|
+
path: 'posts',
|
|
259
|
+
labels: { singular: 'Post', plural: 'Posts' },
|
|
260
|
+
fields: [
|
|
261
|
+
{
|
|
262
|
+
name: 'category',
|
|
263
|
+
type: 'relation',
|
|
264
|
+
label: 'Category',
|
|
265
|
+
targetCollection: 'categories',
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
hooks: { beforeRead: () => ({ category: { tenant: 'alice' } }) },
|
|
269
|
+
});
|
|
270
|
+
const readContext = createReadContext();
|
|
271
|
+
const requestContext = createRequestContext({ readMode: 'published' });
|
|
272
|
+
const resolveCollectionId = vi.fn(async () => 'categories-id');
|
|
273
|
+
const params = {
|
|
274
|
+
definition,
|
|
275
|
+
requestContext,
|
|
276
|
+
readContext,
|
|
277
|
+
securityDomain: {},
|
|
278
|
+
parseContext: { collections: [definition, categories], resolveCollectionId },
|
|
279
|
+
};
|
|
280
|
+
const [first, second] = await Promise.all([
|
|
281
|
+
compileBeforeReadFilters(params),
|
|
282
|
+
compileBeforeReadFilters(params),
|
|
283
|
+
]);
|
|
284
|
+
expect(second).toBe(first);
|
|
285
|
+
expect(resolveCollectionId).toHaveBeenCalledOnce();
|
|
286
|
+
});
|
|
287
|
+
it('compiles the same definition independently for different security domains', async () => {
|
|
288
|
+
const categories = defineCollection({
|
|
289
|
+
path: 'categories',
|
|
290
|
+
labels: { singular: 'Category', plural: 'Categories' },
|
|
291
|
+
fields: [{ name: 'tenant', type: 'text', label: 'Tenant' }],
|
|
292
|
+
});
|
|
293
|
+
const hook = vi.fn(() => ({ category: { tenant: 'alice' } }));
|
|
294
|
+
const definition = defineCollection({
|
|
295
|
+
path: 'posts',
|
|
296
|
+
labels: { singular: 'Post', plural: 'Posts' },
|
|
297
|
+
fields: [
|
|
298
|
+
{
|
|
299
|
+
name: 'category',
|
|
300
|
+
type: 'relation',
|
|
301
|
+
label: 'Category',
|
|
302
|
+
targetCollection: 'categories',
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
hooks: { beforeRead: hook },
|
|
306
|
+
});
|
|
307
|
+
const readContext = createReadContext();
|
|
308
|
+
const requestContext = createRequestContext({ readMode: 'published' });
|
|
309
|
+
const resolveA = vi.fn(async () => 'categories-a');
|
|
310
|
+
const resolveB = vi.fn(async () => 'categories-b');
|
|
311
|
+
await compileBeforeReadFilters({
|
|
312
|
+
definition,
|
|
313
|
+
requestContext,
|
|
314
|
+
readContext,
|
|
315
|
+
securityDomain: {},
|
|
316
|
+
parseContext: { collections: [definition, categories], resolveCollectionId: resolveA },
|
|
317
|
+
});
|
|
318
|
+
await compileBeforeReadFilters({
|
|
319
|
+
definition,
|
|
320
|
+
requestContext,
|
|
321
|
+
readContext,
|
|
322
|
+
securityDomain: {},
|
|
323
|
+
parseContext: { collections: [definition, categories], resolveCollectionId: resolveB },
|
|
324
|
+
});
|
|
325
|
+
expect(hook).toHaveBeenCalledTimes(2);
|
|
326
|
+
expect(resolveA).toHaveBeenCalledOnce();
|
|
327
|
+
expect(resolveB).toHaveBeenCalledOnce();
|
|
328
|
+
});
|
|
329
|
+
it('fails closed on a recursive beforeRead for the same collection', async () => {
|
|
330
|
+
const readContext = createReadContext();
|
|
331
|
+
const requestContext = createRequestContext();
|
|
332
|
+
let definition;
|
|
333
|
+
const hook = async (ctx) => {
|
|
334
|
+
await applyBeforeRead({
|
|
335
|
+
definition,
|
|
336
|
+
requestContext: ctx.requestContext,
|
|
337
|
+
readContext: ctx.readContext,
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
definition = baseCollection(hook);
|
|
341
|
+
await expect(applyBeforeRead({ definition, requestContext, readContext })).rejects.toMatchObject({ code: 'ERR_READ_RECURSION' });
|
|
342
|
+
});
|
|
343
|
+
it('fails closed on an A to B to A beforeRead cycle', async () => {
|
|
344
|
+
const readContext = createReadContext();
|
|
345
|
+
const requestContext = createRequestContext();
|
|
346
|
+
const securityDomain = {};
|
|
347
|
+
let a;
|
|
348
|
+
let b;
|
|
349
|
+
a = defineCollection({
|
|
350
|
+
path: 'a',
|
|
351
|
+
labels: { singular: 'A', plural: 'As' },
|
|
352
|
+
fields: [{ name: 'title', type: 'text', label: 'Title' }],
|
|
353
|
+
hooks: {
|
|
354
|
+
beforeRead: async (ctx) => {
|
|
355
|
+
await applyBeforeRead({
|
|
356
|
+
definition: b,
|
|
357
|
+
requestContext: ctx.requestContext,
|
|
358
|
+
readContext: ctx.readContext,
|
|
359
|
+
securityDomain,
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
b = defineCollection({
|
|
365
|
+
path: 'b',
|
|
366
|
+
labels: { singular: 'B', plural: 'Bs' },
|
|
367
|
+
fields: [{ name: 'title', type: 'text', label: 'Title' }],
|
|
368
|
+
hooks: {
|
|
369
|
+
beforeRead: async (ctx) => {
|
|
370
|
+
await applyBeforeRead({
|
|
371
|
+
definition: a,
|
|
372
|
+
requestContext: ctx.requestContext,
|
|
373
|
+
readContext: ctx.readContext,
|
|
374
|
+
securityDomain,
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
await expect(applyBeforeRead({ definition: a, requestContext, readContext, securityDomain })).rejects.toMatchObject({ code: 'ERR_READ_RECURSION' });
|
|
380
|
+
});
|
|
143
381
|
});
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
export { applyBeforeRead } from './apply-before-read.js';
|
|
8
|
+
export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
|
|
9
9
|
export { assertActorCanPerform } from './assert-actor-can-perform.js';
|
|
10
10
|
export { COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
|
package/dist/auth/index.js
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
export { applyBeforeRead } from './apply-before-read.js';
|
|
8
|
+
export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
|
|
9
9
|
export { assertActorCanPerform } from './assert-actor-can-perform.js';
|
|
10
10
|
export { COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
import type { ReadContext } from '../@types/db-types.js';
|
|
9
|
+
export interface ReadContextScope {
|
|
10
|
+
root: ReadContext;
|
|
11
|
+
ancestry: readonly object[];
|
|
12
|
+
}
|
|
13
|
+
export declare function getReadContextScope(readContext: ReadContext): ReadContextScope;
|
|
14
|
+
/** Resolve a scoped hook context to the ReadContext for its logical request. */
|
|
15
|
+
export declare function resolveReadContextRoot(readContext: ReadContext): ReadContext;
|
|
16
|
+
/**
|
|
17
|
+
* Carry hook ancestry by object identity, not by a forgeable public property.
|
|
18
|
+
* All mutable read-budget state still delegates to the logical root context.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createHookReadContext(parent: ReadContextScope, entry: object): ReadContext;
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
const readContextScopes = new WeakMap();
|
|
9
|
+
export function getReadContextScope(readContext) {
|
|
10
|
+
return readContextScopes.get(readContext) ?? { root: readContext, ancestry: [] };
|
|
11
|
+
}
|
|
12
|
+
/** Resolve a scoped hook context to the ReadContext for its logical request. */
|
|
13
|
+
export function resolveReadContextRoot(readContext) {
|
|
14
|
+
return getReadContextScope(readContext).root;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Carry hook ancestry by object identity, not by a forgeable public property.
|
|
18
|
+
* All mutable read-budget state still delegates to the logical root context.
|
|
19
|
+
*/
|
|
20
|
+
export function createHookReadContext(parent, entry) {
|
|
21
|
+
const root = parent.root;
|
|
22
|
+
const scoped = {};
|
|
23
|
+
Object.defineProperties(scoped, {
|
|
24
|
+
visited: { enumerable: true, get: () => root.visited, set: (value) => (root.visited = value) },
|
|
25
|
+
beforeReadCache: {
|
|
26
|
+
enumerable: true,
|
|
27
|
+
get: () => root.beforeReadCache,
|
|
28
|
+
set: (value) => (root.beforeReadCache = value),
|
|
29
|
+
},
|
|
30
|
+
readCount: {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
get: () => root.readCount,
|
|
33
|
+
set: (value) => (root.readCount = value),
|
|
34
|
+
},
|
|
35
|
+
maxReads: {
|
|
36
|
+
enumerable: true,
|
|
37
|
+
get: () => root.maxReads,
|
|
38
|
+
set: (value) => (root.maxReads = value),
|
|
39
|
+
},
|
|
40
|
+
maxDepth: {
|
|
41
|
+
enumerable: true,
|
|
42
|
+
get: () => root.maxDepth,
|
|
43
|
+
set: (value) => (root.maxDepth = value),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
readContextScopes.set(scoped, { root, ancestry: [...parent.ancestry, entry] });
|
|
47
|
+
return scoped;
|
|
48
|
+
}
|
package/dist/codegen/index.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
-
const FORMAT_VERSION =
|
|
10
|
-
const HASH_DOMAIN = '@byline/core/codegen:collection-types:
|
|
9
|
+
const FORMAT_VERSION = 2;
|
|
10
|
+
const HASH_DOMAIN = '@byline/core/codegen:collection-types:v2\n';
|
|
11
11
|
const IMPORT_ORDER = ['JsonObject', 'JsonValue', 'RelatedDocumentValue', 'StoredFileValue'];
|
|
12
12
|
function compareStrings(a, b) {
|
|
13
13
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
@@ -411,7 +411,38 @@ function emitBody(analysis) {
|
|
|
411
411
|
const importLines = usedImports.length === 0
|
|
412
412
|
? []
|
|
413
413
|
: ['import type {', ...usedImports.map((name) => ` ${name},`), "} from '@byline/core'", ''];
|
|
414
|
-
|
|
414
|
+
// Every type declaration lives inside the `@byline/generated-types`
|
|
415
|
+
// declaration-merge block, so the published stub package is the one
|
|
416
|
+
// canonical import path for app collection types
|
|
417
|
+
// (`import type { NewsFields } from '@byline/generated-types'`). The
|
|
418
|
+
// file's top-level `@byline/core` imports remain visible inside the
|
|
419
|
+
// block (module augmentation is lexically scoped).
|
|
420
|
+
const declarationLines = declarations
|
|
421
|
+
.join('\n')
|
|
422
|
+
.replace(/\n+$/, '')
|
|
423
|
+
.split('\n')
|
|
424
|
+
.map((line) => (line.length === 0 ? line : ` ${line}`));
|
|
425
|
+
// Register the app's collection registry with @byline/client (a second
|
|
426
|
+
// declaration merge). Every bare `BylineClient` in the app's program —
|
|
427
|
+
// including the `@byline/client/server` getters — then resolves to
|
|
428
|
+
// `BylineClient<CollectionFieldsByPath>`. Both `@byline/generated-types`
|
|
429
|
+
// and `@byline/client` must be resolvable in the consuming program; apps
|
|
430
|
+
// that generate types depend on both.
|
|
431
|
+
const registerLines = [
|
|
432
|
+
"declare module '@byline/client' {",
|
|
433
|
+
' interface Register {',
|
|
434
|
+
" collections: import('@byline/generated-types').CollectionFieldsByPath",
|
|
435
|
+
' }',
|
|
436
|
+
'}',
|
|
437
|
+
];
|
|
438
|
+
return `${[
|
|
439
|
+
...importLines,
|
|
440
|
+
"declare module '@byline/generated-types' {",
|
|
441
|
+
...declarationLines,
|
|
442
|
+
'}',
|
|
443
|
+
'',
|
|
444
|
+
...registerLines,
|
|
445
|
+
].join('\n')}\n`;
|
|
415
446
|
}
|
|
416
447
|
/**
|
|
417
448
|
* Emit deterministic application collection types from evaluated runtime definitions.
|