@byline/core 4.15.0 → 4.17.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/admin-types.d.ts +86 -31
- package/dist/@types/admin-types.js +12 -0
- package/dist/@types/collection-types.d.ts +211 -29
- package/dist/@types/collection-types.js +68 -8
- package/dist/@types/db-types.d.ts +50 -0
- package/dist/@types/form-admin-config.test.node.d.ts +6 -0
- package/dist/@types/form-admin-config.test.node.js +26 -0
- package/dist/@types/singleton-admin-config.test.node.js +66 -0
- package/dist/@types/singleton-definition.test.node.d.ts +8 -0
- package/dist/@types/singleton-definition.test.node.js +75 -0
- package/dist/@types/site-config.d.ts +9 -8
- package/dist/auth/apply-before-read.d.ts +2 -1
- package/dist/auth/apply-before-read.js +13 -3
- package/dist/auth/apply-before-read.test.node.js +44 -0
- package/dist/auth/assert-actor-can-perform.d.ts +4 -3
- package/dist/auth/assert-actor-can-perform.js +9 -7
- package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
- package/dist/auth/filter-readable-collections.d.ts +6 -6
- package/dist/auth/filter-readable-collections.js +8 -8
- package/dist/auth/filter-readable-collections.test.node.js +26 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/register-collection-abilities.d.ts +34 -30
- package/dist/auth/register-collection-abilities.js +81 -77
- package/dist/auth/register-collection-abilities.test.node.js +86 -8
- package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/index.js +32 -16
- package/dist/codegen/index.test.node.js +35 -2
- package/dist/config/attach-hooks.d.ts +3 -3
- package/dist/config/attach-hooks.js +8 -0
- package/dist/config/attach-hooks.test.node.js +59 -0
- package/dist/config/config-hooks.test.node.js +34 -1
- package/dist/config/config.d.ts +2 -1
- package/dist/config/config.js +9 -1
- package/dist/config/group-collections.d.ts +10 -9
- package/dist/config/group-collections.js +4 -4
- package/dist/config/group-collections.test.node.js +14 -0
- package/dist/config/validate-admin-configs.d.ts +4 -4
- package/dist/config/validate-admin-configs.js +40 -7
- package/dist/config/validate-admin-configs.test.node.js +44 -0
- package/dist/config/validate-collections.js +43 -0
- package/dist/config/validate-collections.test.node.js +75 -0
- package/dist/core.d.ts +2 -3
- package/dist/core.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/lib/errors.d.ts +5 -3
- package/dist/lib/errors.js +1 -0
- package/dist/services/collection-bootstrap.js +12 -0
- package/dist/services/collection-bootstrap.test.node.js +63 -6
- package/dist/services/discover-counter-groups.test.node.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
- package/dist/services/document-lifecycle/create.js +6 -11
- package/dist/services/document-lifecycle/delete-locale.js +1 -1
- package/dist/services/document-lifecycle/delete.js +1 -1
- package/dist/services/document-lifecycle/duplicate.js +7 -18
- package/dist/services/document-lifecycle/persistence.d.ts +29 -0
- package/dist/services/document-lifecycle/persistence.js +42 -0
- package/dist/services/document-lifecycle/restore.js +9 -17
- package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
- package/dist/services/document-lifecycle/status.js +3 -3
- package/dist/services/document-lifecycle/system-fields.js +1 -1
- package/dist/services/document-lifecycle/tree.js +2 -2
- package/dist/services/document-lifecycle/tree.test.node.js +6 -1
- package/dist/services/document-lifecycle/update.js +20 -36
- package/dist/services/document-lifecycle.test.node.js +35 -0
- package/dist/services/field-upload.d.ts +1 -1
- package/dist/services/field-upload.js +20 -6
- package/dist/services/field-upload.test.node.js +125 -9
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/populate.js +1 -1
- package/dist/services/populate.test.node.js +9 -0
- package/dist/services/richtext-populate.js +1 -1
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
- package/dist/services/singleton-lifecycle/index.d.ts +14 -0
- package/dist/services/singleton-lifecycle/index.js +11 -0
- package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
- package/dist/services/singleton-lifecycle/internals.js +118 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
- package/dist/services/singleton-lifecycle/restore.js +93 -0
- package/dist/services/singleton-lifecycle/update.d.ts +16 -0
- package/dist/services/singleton-lifecycle/update.js +107 -0
- package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
- package/dist/services/singleton-lifecycle.test.node.js +396 -0
- package/dist/storage/collection-fingerprint.js +18 -6
- package/dist/storage/collection-fingerprint.test.node.js +15 -0
- package/package.json +5 -5
- package/dist/config/resolve-item-view-columns.test.node.js +0 -30
- /package/dist/{config/resolve-item-view-columns.test.node.d.ts → @types/singleton-admin-config.test.node.d.ts} +0 -0
|
@@ -209,12 +209,16 @@ export interface IDbAdapter {
|
|
|
209
209
|
counters: ICounterCommands;
|
|
210
210
|
/** Append-only audit-log writes, committed atomically via `withTransaction`. */
|
|
211
211
|
audit: IAuditCommands;
|
|
212
|
+
/** Singleton slot → document mapping writes. */
|
|
213
|
+
singletons: ISingletonCommands;
|
|
212
214
|
};
|
|
213
215
|
queries: {
|
|
214
216
|
collections: ICollectionQueries;
|
|
215
217
|
documents: IDocumentQueries;
|
|
216
218
|
/** Audit-log reads — per-document history, system-wide report. */
|
|
217
219
|
audit: IAuditQueries;
|
|
220
|
+
/** Singleton slot → document mapping reads. */
|
|
221
|
+
singletons: ISingletonQueries;
|
|
218
222
|
};
|
|
219
223
|
/**
|
|
220
224
|
* Run `fn` inside a single database transaction so the
|
|
@@ -462,6 +466,50 @@ export interface ICollectionCommands {
|
|
|
462
466
|
}): Promise<any>;
|
|
463
467
|
delete(id: string): Promise<any>;
|
|
464
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Writes to the singleton slot → document mapping
|
|
471
|
+
* (`byline_singleton_documents`). The mapping enforces slot and document
|
|
472
|
+
* uniqueness after the lifecycle caller has verified that the registered
|
|
473
|
+
* definition is a singleton. A singleton's identity must not depend on a
|
|
474
|
+
* document `path` (locale-bearing and re-anchorable) or on a well-known
|
|
475
|
+
* generated UUID.
|
|
476
|
+
*
|
|
477
|
+
* `setMapping` is called inside the same transaction as the document create it
|
|
478
|
+
* accompanies, so the content version and the mapping commit together.
|
|
479
|
+
*/
|
|
480
|
+
export interface ISingletonCommands {
|
|
481
|
+
/**
|
|
482
|
+
* Lock the singleton's registered collection row as the slot mutex.
|
|
483
|
+
* Callers must invoke this inside `IDbAdapter.withTransaction`; the row
|
|
484
|
+
* lock is then held until that ambient transaction commits or rolls back.
|
|
485
|
+
* Locking the registration row works before a mapping exists and behaves
|
|
486
|
+
* identically on every canonical adapter. Canonical adapters reject calls
|
|
487
|
+
* made without an ambient transaction instead of taking an autocommit lock
|
|
488
|
+
* that would be released before the caller's critical section.
|
|
489
|
+
*/
|
|
490
|
+
lockSlot(collectionId: string): Promise<void>;
|
|
491
|
+
/**
|
|
492
|
+
* Record `documentId` as the singleton document for `collectionId`.
|
|
493
|
+
* The primary key on `collection_id` makes a competing concurrent insert
|
|
494
|
+
* fail rather than produce a second slot. This adapter primitive does not
|
|
495
|
+
* validate the registered collection kind; the singleton lifecycle must do
|
|
496
|
+
* that before calling it.
|
|
497
|
+
*/
|
|
498
|
+
setMapping(collectionId: string, documentId: string): Promise<void>;
|
|
499
|
+
/**
|
|
500
|
+
* Remove the mapping. Not part of the public singleton API — the supported
|
|
501
|
+
* surface has no delete. Present for internal tooling and test cleanup.
|
|
502
|
+
*/
|
|
503
|
+
clearMapping(collectionId: string): Promise<void>;
|
|
504
|
+
}
|
|
505
|
+
/** Reads of the singleton slot → document mapping. */
|
|
506
|
+
export interface ISingletonQueries {
|
|
507
|
+
/**
|
|
508
|
+
* The mapped document id, or `null` when the slot has never been saved.
|
|
509
|
+
* `null` is the normal pre-materialisation state, not an error.
|
|
510
|
+
*/
|
|
511
|
+
getMappedDocumentId(collectionId: string): Promise<string | null>;
|
|
512
|
+
}
|
|
465
513
|
/** Locked structural state returned by tree mutation commands. */
|
|
466
514
|
export interface TreePlacementState {
|
|
467
515
|
placed: boolean;
|
|
@@ -1040,6 +1088,8 @@ export interface IDocumentQueries {
|
|
|
1040
1088
|
}): Promise<any | null>;
|
|
1041
1089
|
getDocumentByVersion(params: {
|
|
1042
1090
|
document_version_id: string;
|
|
1091
|
+
/** Optional logical-document ownership gate for singleton historical reads. */
|
|
1092
|
+
document_id?: string;
|
|
1043
1093
|
locale?: string;
|
|
1044
1094
|
/** Optional collection ownership gate for client-facing historical reads. */
|
|
1045
1095
|
collection_id?: string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code Form 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 https://mozilla.org/MPL/2.0/.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
describe('FormAdminConfig', () => {
|
|
8
|
+
it('accepts a config carrying only the form-facing keys', () => {
|
|
9
|
+
const config = {
|
|
10
|
+
fields: {},
|
|
11
|
+
tabSets: [],
|
|
12
|
+
rows: [],
|
|
13
|
+
groups: [],
|
|
14
|
+
layout: { main: [] },
|
|
15
|
+
};
|
|
16
|
+
expect(Object.keys(config).sort()).toEqual(['fields', 'groups', 'layout', 'rows', 'tabSets']);
|
|
17
|
+
});
|
|
18
|
+
it('is satisfied by a full CollectionAdminConfig', () => {
|
|
19
|
+
const collection = {
|
|
20
|
+
slug: 'pages',
|
|
21
|
+
layout: { main: [] },
|
|
22
|
+
};
|
|
23
|
+
const asForm = collection;
|
|
24
|
+
expect(asForm.layout).toEqual({ main: [] });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
9
|
+
import { defineAdmin, defineSingletonAdmin } from './admin-types.js';
|
|
10
|
+
import { defineCollection, defineSingleton } from './collection-types.js';
|
|
11
|
+
const pages = defineCollection({
|
|
12
|
+
path: 'pages',
|
|
13
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
14
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
15
|
+
});
|
|
16
|
+
const settings = defineSingleton({
|
|
17
|
+
path: 'site-settings',
|
|
18
|
+
label: 'Site settings',
|
|
19
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
20
|
+
});
|
|
21
|
+
describe('singleton admin config', () => {
|
|
22
|
+
it('stamps explicit resource-kind discriminants in both factories', () => {
|
|
23
|
+
const collectionAdmin = defineAdmin(pages, {});
|
|
24
|
+
const singletonAdmin = defineSingletonAdmin(settings, {});
|
|
25
|
+
expect(collectionAdmin.singleton).toBe(false);
|
|
26
|
+
expect(singletonAdmin.singleton).toBe(true);
|
|
27
|
+
expect(singletonAdmin.slug).toBe(settings.path);
|
|
28
|
+
});
|
|
29
|
+
it('does not let untyped config input override factory-owned identity', () => {
|
|
30
|
+
const collectionAdmin = defineAdmin(pages, {
|
|
31
|
+
singleton: true,
|
|
32
|
+
slug: 'wrong-collection',
|
|
33
|
+
});
|
|
34
|
+
const singletonAdmin = defineSingletonAdmin(settings, {
|
|
35
|
+
singleton: false,
|
|
36
|
+
slug: 'wrong-singleton',
|
|
37
|
+
});
|
|
38
|
+
expect(collectionAdmin).toMatchObject({ singleton: false, slug: pages.path });
|
|
39
|
+
expect(singletonAdmin).toMatchObject({ singleton: true, slug: settings.path });
|
|
40
|
+
});
|
|
41
|
+
it('shares the reusable form composition contract', () => {
|
|
42
|
+
const singletonAdmin = defineSingletonAdmin(settings, {
|
|
43
|
+
fields: { title: {} },
|
|
44
|
+
layout: { main: ['title'] },
|
|
45
|
+
});
|
|
46
|
+
const asForm = singletonAdmin;
|
|
47
|
+
expect(asForm.layout).toEqual({ main: ['title'] });
|
|
48
|
+
});
|
|
49
|
+
it('rejects collection-only admin options at the definition site', () => {
|
|
50
|
+
defineSingletonAdmin(settings, {
|
|
51
|
+
// @ts-expect-error — list columns have no meaning for a singleton resource.
|
|
52
|
+
columns: [{ fieldName: 'title', label: 'Title' }],
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
it('keeps the singleton preview document pathless', () => {
|
|
56
|
+
defineSingletonAdmin(settings, {
|
|
57
|
+
preview: {
|
|
58
|
+
url: (document) => {
|
|
59
|
+
// @ts-expect-error — a singleton's internal generated path is never preview input.
|
|
60
|
+
expect(document.path).toBeUndefined();
|
|
61
|
+
return '/';
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -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 {};
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
9
|
+
import { defineCollection, defineSingleton, isSingleton } from './collection-types.js';
|
|
10
|
+
describe('defineSingleton', () => {
|
|
11
|
+
it('stamps the singleton discriminant', () => {
|
|
12
|
+
const def = defineSingleton({
|
|
13
|
+
path: 'site-settings',
|
|
14
|
+
label: 'Site settings',
|
|
15
|
+
fields: [{ name: 'name', label: 'Site name', type: 'text' }],
|
|
16
|
+
});
|
|
17
|
+
expect(def.singleton).toBe(true);
|
|
18
|
+
expect(def.path).toBe('site-settings');
|
|
19
|
+
});
|
|
20
|
+
it('narrows through isSingleton', () => {
|
|
21
|
+
const singleton = defineSingleton({
|
|
22
|
+
path: 'site-settings',
|
|
23
|
+
label: 'Site settings',
|
|
24
|
+
fields: [{ name: 'name', label: 'Site name', type: 'text' }],
|
|
25
|
+
});
|
|
26
|
+
const collection = defineCollection({
|
|
27
|
+
path: 'pages',
|
|
28
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
29
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
30
|
+
});
|
|
31
|
+
expect(isSingleton(singleton)).toBe(true);
|
|
32
|
+
expect(isSingleton(collection)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it('rejects a collection-only option at the definition site', () => {
|
|
35
|
+
defineSingleton({
|
|
36
|
+
path: 'site-settings',
|
|
37
|
+
label: 'Site settings',
|
|
38
|
+
// @ts-expect-error — `orderable` is `?: never` on SingletonDefinition.
|
|
39
|
+
// The directive suppresses only the NEXT line, so it must sit here and
|
|
40
|
+
// not above `defineSingleton(`. If it ever stops erroring, the union has
|
|
41
|
+
// gone soft and runtime validation is all that is left.
|
|
42
|
+
orderable: true,
|
|
43
|
+
fields: [{ name: 'name', label: 'Site name', type: 'text' }],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
it('keeps collection and singleton hook families disjoint at the definition site', () => {
|
|
47
|
+
defineSingleton({
|
|
48
|
+
path: 'site-settings',
|
|
49
|
+
label: 'Site settings',
|
|
50
|
+
fields: [{ name: 'name', label: 'Site name', type: 'text' }],
|
|
51
|
+
hooks: {
|
|
52
|
+
// @ts-expect-error — collection create hooks do not exist on a singleton.
|
|
53
|
+
beforeCreate: () => { },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
defineCollection({
|
|
57
|
+
path: 'pages',
|
|
58
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
59
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
60
|
+
hooks: {
|
|
61
|
+
// @ts-expect-error — singleton save hooks do not exist on a collection.
|
|
62
|
+
beforeSave: () => { },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
it('preserves literal path types for the generated registries', () => {
|
|
67
|
+
const def = defineSingleton({
|
|
68
|
+
path: 'site-settings',
|
|
69
|
+
label: 'Site settings',
|
|
70
|
+
fields: [{ name: 'name', label: 'Site name', type: 'text' }],
|
|
71
|
+
});
|
|
72
|
+
const path = def.path;
|
|
73
|
+
expect(path).toBe('site-settings');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -9,8 +9,8 @@ import type { SessionProvider } from '@byline/auth';
|
|
|
9
9
|
import type { RecurringTaskDefinition } from '../scheduler/types.js';
|
|
10
10
|
import type { SlugifierFn } from '../utils/slugify.js';
|
|
11
11
|
import type { FilenameSlugifierFn } from '../utils/slugify-filename.js';
|
|
12
|
-
import type {
|
|
13
|
-
import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, UploadHooks, UploadHooksLoader } from './collection-types.js';
|
|
12
|
+
import type { AdminResourceConfig, BlockAdminConfig, CollectionGroupDefinition } from './admin-types.js';
|
|
13
|
+
import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, SingletonHooks, SingletonHooksLoader, UploadHooks, UploadHooksLoader } from './collection-types.js';
|
|
14
14
|
import type { IDbAdapter } from './db-types.js';
|
|
15
15
|
import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn, RichTextToTextFn } from './field-types.js';
|
|
16
16
|
import type { SearchProvider } from './search-types.js';
|
|
@@ -145,12 +145,13 @@ export type TranslationBundleShape = Readonly<{
|
|
|
145
145
|
* Used by `defineAdminConfig()` and consumed by admin UI routes.
|
|
146
146
|
*/
|
|
147
147
|
export interface AdminConfig extends BaseConfig {
|
|
148
|
-
/** Admin UI configuration for collections (client-side only). */
|
|
149
|
-
admin?:
|
|
148
|
+
/** Admin UI configuration for collections and singleton resources (client-side only). */
|
|
149
|
+
admin?: AdminResourceConfig[];
|
|
150
150
|
/**
|
|
151
|
-
* Ordered registry of dashboard
|
|
152
|
-
* order. A collection joins a group
|
|
153
|
-
*
|
|
151
|
+
* Ordered registry of dashboard resource groups. Array order is display
|
|
152
|
+
* order. A collection or singleton joins a group through its admin config's
|
|
153
|
+
* `group` property. The property name remains `collectionGroups` for
|
|
154
|
+
* backwards compatibility.
|
|
154
155
|
*
|
|
155
156
|
* Omit entirely to keep the flat, ungrouped dashboard grid — this property is
|
|
156
157
|
* purely additive and changes nothing when absent.
|
|
@@ -239,7 +240,7 @@ export type ResolvedAdminConfig = Omit<AdminConfig, 'routes'> & {
|
|
|
239
240
|
* ```
|
|
240
241
|
*/
|
|
241
242
|
export interface ServerHooksConfig {
|
|
242
|
-
collections?: Record<string, CollectionHooks | CollectionHooksLoader>;
|
|
243
|
+
collections?: Record<string, CollectionHooks | CollectionHooksLoader | SingletonHooks | SingletonHooksLoader>;
|
|
243
244
|
uploads?: Record<string, UploadHooks | UploadHooksLoader>;
|
|
244
245
|
}
|
|
245
246
|
/** Installation-wide switch for Byline's scheduled-publication subsystem. */
|
|
@@ -14,7 +14,8 @@ import type { QueryPredicate } from '../@types/query-predicate.js';
|
|
|
14
14
|
* Resolve the per-collection `beforeRead` hook predicate for the current
|
|
15
15
|
* request, with caching across populate fanout.
|
|
16
16
|
*
|
|
17
|
-
* Each configured hook function runs in declaration order.
|
|
17
|
+
* Each configured hook function runs in declaration order. `false` is
|
|
18
|
+
* normalized to the documented always-false predicate, and predicates from
|
|
18
19
|
* multiple hooks are combined with implicit AND. Results are cached in
|
|
19
20
|
* module-private state bound to one request authority; caller-owned
|
|
20
21
|
* `ReadContext` properties are never consulted for authorization.
|
|
@@ -14,7 +14,8 @@ const readSecurityStates = new WeakMap();
|
|
|
14
14
|
* Resolve the per-collection `beforeRead` hook predicate for the current
|
|
15
15
|
* request, with caching across populate fanout.
|
|
16
16
|
*
|
|
17
|
-
* Each configured hook function runs in declaration order.
|
|
17
|
+
* Each configured hook function runs in declaration order. `false` is
|
|
18
|
+
* normalized to the documented always-false predicate, and predicates from
|
|
18
19
|
* multiple hooks are combined with implicit AND. Results are cached in
|
|
19
20
|
* module-private state bound to one request authority; caller-owned
|
|
20
21
|
* `ReadContext` properties are never consulted for authorization.
|
|
@@ -48,8 +49,9 @@ export async function applyBeforeRead(params) {
|
|
|
48
49
|
requestContext,
|
|
49
50
|
readContext: createHookReadContext(scope, entry),
|
|
50
51
|
});
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
const predicate = normalizeBeforeReadResult(result);
|
|
53
|
+
if (predicate !== undefined)
|
|
54
|
+
predicates.push(predicate);
|
|
53
55
|
}
|
|
54
56
|
if (predicates.length === 0)
|
|
55
57
|
return null;
|
|
@@ -173,3 +175,11 @@ function normalizeBeforeReadHook(slot) {
|
|
|
173
175
|
return [];
|
|
174
176
|
return Array.isArray(slot) ? slot : [slot];
|
|
175
177
|
}
|
|
178
|
+
/** Normalize the deny-all shorthand without teaching the predicate parser a sentinel value. */
|
|
179
|
+
function normalizeBeforeReadResult(result) {
|
|
180
|
+
if (result === false)
|
|
181
|
+
return { id: { $in: [] } };
|
|
182
|
+
if (result === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
@@ -48,6 +48,50 @@ describe('applyBeforeRead', () => {
|
|
|
48
48
|
});
|
|
49
49
|
expect(result).toBeNull();
|
|
50
50
|
});
|
|
51
|
+
it('normalizes false to the documented always-false predicate', async () => {
|
|
52
|
+
const hook = () => false;
|
|
53
|
+
const result = await applyBeforeRead({
|
|
54
|
+
definition: baseCollection(hook),
|
|
55
|
+
requestContext: createRequestContext(),
|
|
56
|
+
readContext: createReadContext(),
|
|
57
|
+
});
|
|
58
|
+
expect(result).toEqual({ id: { $in: [] } });
|
|
59
|
+
});
|
|
60
|
+
it('normalizes an async false result identically', async () => {
|
|
61
|
+
const hook = async () => {
|
|
62
|
+
await Promise.resolve();
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
const result = await applyBeforeRead({
|
|
66
|
+
definition: baseCollection(hook),
|
|
67
|
+
requestContext: createRequestContext(),
|
|
68
|
+
readContext: createReadContext(),
|
|
69
|
+
});
|
|
70
|
+
expect(result).toEqual({ id: { $in: [] } });
|
|
71
|
+
});
|
|
72
|
+
it('keeps false restrictive in either hook order', async () => {
|
|
73
|
+
const deny = () => false;
|
|
74
|
+
const permit = () => ({ status: 'published' });
|
|
75
|
+
const params = {
|
|
76
|
+
requestContext: createRequestContext(),
|
|
77
|
+
readContext: createReadContext(),
|
|
78
|
+
};
|
|
79
|
+
await expect(applyBeforeRead({ ...params, definition: baseCollection([deny, permit]) })).resolves.toEqual({
|
|
80
|
+
$and: [{ id: { $in: [] } }, { status: 'published' }],
|
|
81
|
+
});
|
|
82
|
+
await expect(applyBeforeRead({
|
|
83
|
+
...params,
|
|
84
|
+
definition: baseCollection([permit, deny]),
|
|
85
|
+
readContext: createReadContext(),
|
|
86
|
+
})).resolves.toEqual({
|
|
87
|
+
$and: [{ status: 'published' }, { id: { $in: [] } }],
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
it('does not admit true as an affirmative-allow result', () => {
|
|
91
|
+
// @ts-expect-error true is deliberately outside the beforeRead contract
|
|
92
|
+
const hook = () => true;
|
|
93
|
+
expect(hook).toBeTypeOf('function');
|
|
94
|
+
});
|
|
51
95
|
it('combines multiple hook predicates with implicit AND', async () => {
|
|
52
96
|
const a = () => ({ tenantId: 't-1' });
|
|
53
97
|
const b = () => ({ status: 'published' });
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { type RequestContext } from '@byline/auth';
|
|
9
|
-
import { type
|
|
9
|
+
import { type DocumentAbilityResource, type DocumentAbilityVerbFor } from './register-collection-abilities.js';
|
|
10
10
|
/**
|
|
11
11
|
* Central enforcement helper. Called from the service layer — at the top
|
|
12
12
|
* of each `document-lifecycle` function and at the top of each
|
|
@@ -25,7 +25,8 @@ import { type CollectionAbilityVerb } from './register-collection-abilities.js';
|
|
|
25
25
|
* default for unauthenticated consumers).
|
|
26
26
|
* - Any other combination → `ERR_UNAUTHENTICATED`.
|
|
27
27
|
*
|
|
28
|
-
* 3. **Actor present** →
|
|
28
|
+
* 3. **Actor present** → assert the kind-aware ability key derived from the
|
|
29
|
+
* resource descriptor.
|
|
29
30
|
* `AdminAuth` with `isSuperAdmin: true` short-circuits the check
|
|
30
31
|
* internally; any other actor must hold the specific ability.
|
|
31
32
|
*
|
|
@@ -35,4 +36,4 @@ import { type CollectionAbilityVerb } from './register-collection-abilities.js';
|
|
|
35
36
|
*
|
|
36
37
|
* See docs/07-auth-and-security/01-authn-authz.md.
|
|
37
38
|
*/
|
|
38
|
-
export declare function assertActorCanPerform(context: RequestContext | undefined,
|
|
39
|
+
export declare function assertActorCanPerform<Resource extends DocumentAbilityResource>(context: RequestContext | undefined, resource: Resource, verb: DocumentAbilityVerbFor<Resource>): void;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { ERR_UNAUTHENTICATED } from '@byline/auth';
|
|
9
|
-
import {
|
|
9
|
+
import { documentAbilityKey, } from './register-collection-abilities.js';
|
|
10
10
|
/**
|
|
11
11
|
* Central enforcement helper. Called from the service layer — at the top
|
|
12
12
|
* of each `document-lifecycle` function and at the top of each
|
|
@@ -25,7 +25,8 @@ import { collectionAbilityKey, } from './register-collection-abilities.js';
|
|
|
25
25
|
* default for unauthenticated consumers).
|
|
26
26
|
* - Any other combination → `ERR_UNAUTHENTICATED`.
|
|
27
27
|
*
|
|
28
|
-
* 3. **Actor present** →
|
|
28
|
+
* 3. **Actor present** → assert the kind-aware ability key derived from the
|
|
29
|
+
* resource descriptor.
|
|
29
30
|
* `AdminAuth` with `isSuperAdmin: true` short-circuits the check
|
|
30
31
|
* internally; any other actor must hold the specific ability.
|
|
31
32
|
*
|
|
@@ -35,10 +36,11 @@ import { collectionAbilityKey, } from './register-collection-abilities.js';
|
|
|
35
36
|
*
|
|
36
37
|
* See docs/07-auth-and-security/01-authn-authz.md.
|
|
37
38
|
*/
|
|
38
|
-
export function assertActorCanPerform(context,
|
|
39
|
+
export function assertActorCanPerform(context, resource, verb) {
|
|
40
|
+
const path = resource.path;
|
|
39
41
|
if (!context) {
|
|
40
42
|
throw ERR_UNAUTHENTICATED({
|
|
41
|
-
message: `missing requestContext on ${verb} '${
|
|
43
|
+
message: `missing requestContext on ${verb} '${path}'. Pass createSuperAdminContext() ` +
|
|
42
44
|
`from @byline/auth for scripts/tests, or construct a request-scoped context from your ` +
|
|
43
45
|
`session provider in the admin webapp.`,
|
|
44
46
|
});
|
|
@@ -47,17 +49,17 @@ export function assertActorCanPerform(context, collectionPath, verb) {
|
|
|
47
49
|
if (actor == null) {
|
|
48
50
|
if (verb !== 'read') {
|
|
49
51
|
throw ERR_UNAUTHENTICATED({
|
|
50
|
-
message: `anonymous request cannot ${verb} '${
|
|
52
|
+
message: `anonymous request cannot ${verb} '${path}': no actor in context`,
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
if (readMode !== 'published') {
|
|
54
56
|
throw ERR_UNAUTHENTICATED({
|
|
55
|
-
message: `anonymous read of '${
|
|
57
|
+
message: `anonymous read of '${path}' requires readMode: 'published' ` +
|
|
56
58
|
`(got readMode: ${readMode == null ? '<unset>' : `'${readMode}'`})`,
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
// Anonymous read of published content — permitted.
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
62
|
-
actor.assertAbility(
|
|
64
|
+
actor.assertAbility(documentAbilityKey(resource, verb));
|
|
63
65
|
}
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it } from 'vitest';
|
|
10
10
|
import { assertActorCanPerform } from './assert-actor-can-perform.js';
|
|
11
|
+
const pages = { kind: 'collection', path: 'pages' };
|
|
12
|
+
const news = { kind: 'collection', path: 'news' };
|
|
13
|
+
const siteSettings = { kind: 'singleton', path: 'site-settings' };
|
|
11
14
|
describe('assertActorCanPerform', () => {
|
|
12
15
|
describe('missing context', () => {
|
|
13
16
|
it('throws ERR_UNAUTHENTICATED when context is undefined', () => {
|
|
14
17
|
try {
|
|
15
|
-
assertActorCanPerform(undefined,
|
|
18
|
+
assertActorCanPerform(undefined, pages, 'read');
|
|
16
19
|
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
17
20
|
}
|
|
18
21
|
catch (err) {
|
|
@@ -24,12 +27,13 @@ describe('assertActorCanPerform', () => {
|
|
|
24
27
|
describe('null actor (anonymous)', () => {
|
|
25
28
|
it('permits read with readMode: published', () => {
|
|
26
29
|
const ctx = createRequestContext({ actor: null, readMode: 'published' });
|
|
27
|
-
expect(() => assertActorCanPerform(ctx,
|
|
30
|
+
expect(() => assertActorCanPerform(ctx, pages, 'read')).not.toThrow();
|
|
31
|
+
expect(() => assertActorCanPerform(ctx, siteSettings, 'read')).not.toThrow();
|
|
28
32
|
});
|
|
29
33
|
it('rejects read with readMode: any', () => {
|
|
30
34
|
const ctx = createRequestContext({ actor: null, readMode: 'any' });
|
|
31
35
|
try {
|
|
32
|
-
assertActorCanPerform(ctx,
|
|
36
|
+
assertActorCanPerform(ctx, pages, 'read');
|
|
33
37
|
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
34
38
|
}
|
|
35
39
|
catch (err) {
|
|
@@ -40,7 +44,7 @@ describe('assertActorCanPerform', () => {
|
|
|
40
44
|
it('rejects read when readMode is unset', () => {
|
|
41
45
|
const ctx = createRequestContext({ actor: null });
|
|
42
46
|
try {
|
|
43
|
-
assertActorCanPerform(ctx,
|
|
47
|
+
assertActorCanPerform(ctx, pages, 'read');
|
|
44
48
|
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
45
49
|
}
|
|
46
50
|
catch (err) {
|
|
@@ -51,7 +55,7 @@ describe('assertActorCanPerform', () => {
|
|
|
51
55
|
const ctx = createRequestContext({ actor: null, readMode: 'published' });
|
|
52
56
|
for (const verb of ['create', 'update', 'delete', 'publish', 'changeStatus']) {
|
|
53
57
|
try {
|
|
54
|
-
assertActorCanPerform(ctx,
|
|
58
|
+
assertActorCanPerform(ctx, pages, verb);
|
|
55
59
|
expect.fail(`expected ERR_UNAUTHENTICATED for ${verb}`);
|
|
56
60
|
}
|
|
57
61
|
catch (err) {
|
|
@@ -67,7 +71,7 @@ describe('assertActorCanPerform', () => {
|
|
|
67
71
|
abilities: ['collections.pages.update'],
|
|
68
72
|
});
|
|
69
73
|
const ctx = createRequestContext({ actor });
|
|
70
|
-
expect(() => assertActorCanPerform(ctx,
|
|
74
|
+
expect(() => assertActorCanPerform(ctx, pages, 'update')).not.toThrow();
|
|
71
75
|
});
|
|
72
76
|
it('throws ERR_FORBIDDEN when the specific ability is missing', () => {
|
|
73
77
|
const actor = new AdminAuth({
|
|
@@ -76,7 +80,7 @@ describe('assertActorCanPerform', () => {
|
|
|
76
80
|
});
|
|
77
81
|
const ctx = createRequestContext({ actor });
|
|
78
82
|
try {
|
|
79
|
-
assertActorCanPerform(ctx,
|
|
83
|
+
assertActorCanPerform(ctx, pages, 'publish');
|
|
80
84
|
expect.fail('expected ERR_FORBIDDEN');
|
|
81
85
|
}
|
|
82
86
|
catch (err) {
|
|
@@ -90,9 +94,9 @@ describe('assertActorCanPerform', () => {
|
|
|
90
94
|
abilities: ['collections.pages.update'], // has update for 'pages' only
|
|
91
95
|
});
|
|
92
96
|
const ctx = createRequestContext({ actor });
|
|
93
|
-
expect(() => assertActorCanPerform(ctx,
|
|
97
|
+
expect(() => assertActorCanPerform(ctx, pages, 'update')).not.toThrow();
|
|
94
98
|
try {
|
|
95
|
-
assertActorCanPerform(ctx,
|
|
99
|
+
assertActorCanPerform(ctx, news, 'update');
|
|
96
100
|
expect.fail('expected ERR_FORBIDDEN — wrong collection');
|
|
97
101
|
}
|
|
98
102
|
catch (err) {
|
|
@@ -100,6 +104,19 @@ describe('assertActorCanPerform', () => {
|
|
|
100
104
|
expect(err.message).toContain('collections.news.update');
|
|
101
105
|
}
|
|
102
106
|
});
|
|
107
|
+
it('keeps collection and singleton namespaces isolated', () => {
|
|
108
|
+
const singletonActor = new AdminAuth({
|
|
109
|
+
id: 'singleton-editor',
|
|
110
|
+
abilities: ['singletons.site-settings.update'],
|
|
111
|
+
});
|
|
112
|
+
expect(() => assertActorCanPerform(createRequestContext({ actor: singletonActor }), siteSettings, 'update')).not.toThrow();
|
|
113
|
+
const collectionActor = new AdminAuth({
|
|
114
|
+
id: 'editor-1',
|
|
115
|
+
abilities: ['collections.site-settings.update'],
|
|
116
|
+
});
|
|
117
|
+
const ctx = createRequestContext({ actor: collectionActor });
|
|
118
|
+
expect(() => assertActorCanPerform(ctx, siteSettings, 'update')).toThrow(/singletons\.site-settings\.update/);
|
|
119
|
+
});
|
|
103
120
|
});
|
|
104
121
|
describe('super-admin', () => {
|
|
105
122
|
it('passes every verb on every collection without explicit abilities', () => {
|
|
@@ -114,13 +131,17 @@ describe('assertActorCanPerform', () => {
|
|
|
114
131
|
'reindex',
|
|
115
132
|
];
|
|
116
133
|
for (const verb of verbs) {
|
|
117
|
-
expect(() => assertActorCanPerform(ctx, 'any-collection', verb)).not.toThrow();
|
|
134
|
+
expect(() => assertActorCanPerform(ctx, { kind: 'collection', path: 'any-collection' }, verb)).not.toThrow();
|
|
118
135
|
}
|
|
119
136
|
});
|
|
120
137
|
it('passes even when readMode would normally reject a null actor', () => {
|
|
121
138
|
// Super-admin doesn't care about readMode at this layer.
|
|
122
139
|
const ctx = createSuperAdminContext();
|
|
123
|
-
expect(() => assertActorCanPerform(ctx,
|
|
140
|
+
expect(() => assertActorCanPerform(ctx, pages, 'read')).not.toThrow();
|
|
124
141
|
});
|
|
125
142
|
});
|
|
143
|
+
it('rejects anonymous singleton updates', () => {
|
|
144
|
+
const ctx = createRequestContext({ actor: null, readMode: 'published' });
|
|
145
|
+
expect(() => assertActorCanPerform(ctx, siteSettings, 'update')).toThrowError(expect.objectContaining({ code: AuthErrorCodes.UNAUTHENTICATED }));
|
|
146
|
+
});
|
|
126
147
|
});
|
|
@@ -17,14 +17,14 @@ export interface ActorAbilitySnapshot {
|
|
|
17
17
|
abilities: readonly string[];
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* Narrow a
|
|
20
|
+
* Narrow a document-resource list to those the administrator can read.
|
|
21
21
|
*
|
|
22
22
|
* `read` is the gate because everything a dashboard card offers — the link to
|
|
23
|
-
*
|
|
24
|
-
* `collections.<path>.read`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* is genuinely empty.
|
|
23
|
+
* its editor/list view, and collection status counts — requires the resource's
|
|
24
|
+
* kind-aware `collections.<path>.read` or `singletons.<path>.read` ability and
|
|
25
|
+
* is rejected server-side without it. An administrator who cannot read a
|
|
26
|
+
* collection would otherwise see a card whose status tiles all read zero,
|
|
27
|
+
* which is indistinguishable from a collection that is genuinely empty.
|
|
28
28
|
*
|
|
29
29
|
* **Cosmetic only.** This is an affordance, never a security boundary.
|
|
30
30
|
* `assertActorCanPerform` remains the enforcement point on every read and write
|
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { documentAbilityKey } from './register-collection-abilities.js';
|
|
9
9
|
/**
|
|
10
|
-
* Narrow a
|
|
10
|
+
* Narrow a document-resource list to those the administrator can read.
|
|
11
11
|
*
|
|
12
12
|
* `read` is the gate because everything a dashboard card offers — the link to
|
|
13
|
-
*
|
|
14
|
-
* `collections.<path>.read`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* is genuinely empty.
|
|
13
|
+
* its editor/list view, and collection status counts — requires the resource's
|
|
14
|
+
* kind-aware `collections.<path>.read` or `singletons.<path>.read` ability and
|
|
15
|
+
* is rejected server-side without it. An administrator who cannot read a
|
|
16
|
+
* collection would otherwise see a card whose status tiles all read zero,
|
|
17
|
+
* which is indistinguishable from a collection that is genuinely empty.
|
|
18
18
|
*
|
|
19
19
|
* **Cosmetic only.** This is an affordance, never a security boundary.
|
|
20
20
|
* `assertActorCanPerform` remains the enforcement point on every read and write
|
|
@@ -28,5 +28,5 @@ export function filterReadableCollections(collections, snapshot) {
|
|
|
28
28
|
if (snapshot.isSuperAdmin)
|
|
29
29
|
return [...collections];
|
|
30
30
|
const held = new Set(snapshot.abilities);
|
|
31
|
-
return collections.filter((collection) => held.has(
|
|
31
|
+
return collections.filter((collection) => held.has(documentAbilityKey(collection, 'read')));
|
|
32
32
|
}
|