@byline/core 4.16.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.
Files changed (92) hide show
  1. package/dist/@types/admin-types.d.ts +86 -31
  2. package/dist/@types/admin-types.js +12 -0
  3. package/dist/@types/collection-types.d.ts +211 -29
  4. package/dist/@types/collection-types.js +68 -8
  5. package/dist/@types/db-types.d.ts +50 -0
  6. package/dist/@types/form-admin-config.test.node.d.ts +6 -0
  7. package/dist/@types/form-admin-config.test.node.js +26 -0
  8. package/dist/@types/singleton-admin-config.test.node.js +66 -0
  9. package/dist/@types/singleton-definition.test.node.d.ts +8 -0
  10. package/dist/@types/singleton-definition.test.node.js +75 -0
  11. package/dist/@types/site-config.d.ts +9 -8
  12. package/dist/auth/apply-before-read.d.ts +2 -1
  13. package/dist/auth/apply-before-read.js +13 -3
  14. package/dist/auth/apply-before-read.test.node.js +44 -0
  15. package/dist/auth/assert-actor-can-perform.d.ts +4 -3
  16. package/dist/auth/assert-actor-can-perform.js +9 -7
  17. package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
  18. package/dist/auth/filter-readable-collections.d.ts +6 -6
  19. package/dist/auth/filter-readable-collections.js +8 -8
  20. package/dist/auth/filter-readable-collections.test.node.js +26 -1
  21. package/dist/auth/index.d.ts +1 -1
  22. package/dist/auth/index.js +1 -1
  23. package/dist/auth/register-collection-abilities.d.ts +34 -30
  24. package/dist/auth/register-collection-abilities.js +81 -77
  25. package/dist/auth/register-collection-abilities.test.node.js +86 -8
  26. package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
  27. package/dist/codegen/index.d.ts +1 -1
  28. package/dist/codegen/index.js +32 -16
  29. package/dist/codegen/index.test.node.js +35 -2
  30. package/dist/config/attach-hooks.d.ts +3 -3
  31. package/dist/config/attach-hooks.js +8 -0
  32. package/dist/config/attach-hooks.test.node.js +59 -0
  33. package/dist/config/config-hooks.test.node.js +34 -1
  34. package/dist/config/config.d.ts +2 -1
  35. package/dist/config/config.js +9 -1
  36. package/dist/config/group-collections.d.ts +10 -9
  37. package/dist/config/group-collections.js +4 -4
  38. package/dist/config/group-collections.test.node.js +14 -0
  39. package/dist/config/validate-admin-configs.d.ts +4 -4
  40. package/dist/config/validate-admin-configs.js +40 -7
  41. package/dist/config/validate-admin-configs.test.node.js +44 -0
  42. package/dist/config/validate-collections.js +43 -0
  43. package/dist/config/validate-collections.test.node.js +75 -0
  44. package/dist/core.d.ts +2 -3
  45. package/dist/core.js +3 -3
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +2 -2
  48. package/dist/lib/errors.d.ts +5 -3
  49. package/dist/lib/errors.js +1 -0
  50. package/dist/services/collection-bootstrap.js +12 -0
  51. package/dist/services/collection-bootstrap.test.node.js +63 -6
  52. package/dist/services/discover-counter-groups.test.node.js +6 -0
  53. package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
  54. package/dist/services/document-lifecycle/create.js +6 -11
  55. package/dist/services/document-lifecycle/delete-locale.js +1 -1
  56. package/dist/services/document-lifecycle/delete.js +1 -1
  57. package/dist/services/document-lifecycle/duplicate.js +7 -18
  58. package/dist/services/document-lifecycle/persistence.d.ts +29 -0
  59. package/dist/services/document-lifecycle/persistence.js +42 -0
  60. package/dist/services/document-lifecycle/restore.js +9 -17
  61. package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
  62. package/dist/services/document-lifecycle/status.js +3 -3
  63. package/dist/services/document-lifecycle/system-fields.js +1 -1
  64. package/dist/services/document-lifecycle/tree.js +2 -2
  65. package/dist/services/document-lifecycle/tree.test.node.js +6 -1
  66. package/dist/services/document-lifecycle/update.js +20 -36
  67. package/dist/services/document-lifecycle.test.node.js +35 -0
  68. package/dist/services/field-upload.d.ts +1 -1
  69. package/dist/services/field-upload.js +20 -6
  70. package/dist/services/field-upload.test.node.js +125 -9
  71. package/dist/services/index.d.ts +1 -0
  72. package/dist/services/index.js +1 -0
  73. package/dist/services/populate.js +1 -1
  74. package/dist/services/populate.test.node.js +9 -0
  75. package/dist/services/richtext-populate.js +1 -1
  76. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
  77. package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
  78. package/dist/services/singleton-lifecycle/index.d.ts +14 -0
  79. package/dist/services/singleton-lifecycle/index.js +11 -0
  80. package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
  81. package/dist/services/singleton-lifecycle/internals.js +118 -0
  82. package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
  83. package/dist/services/singleton-lifecycle/restore.js +93 -0
  84. package/dist/services/singleton-lifecycle/update.d.ts +16 -0
  85. package/dist/services/singleton-lifecycle/update.js +107 -0
  86. package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
  87. package/dist/services/singleton-lifecycle.test.node.js +396 -0
  88. package/dist/storage/collection-fingerprint.js +18 -6
  89. package/dist/storage/collection-fingerprint.test.node.js +15 -0
  90. package/package.json +5 -5
  91. package/dist/config/resolve-item-view-columns.test.node.js +0 -30
  92. /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,6 @@
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
+ export {};
@@ -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 { BlockAdminConfig, CollectionAdminConfig, CollectionGroupDefinition } from './admin-types.js';
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?: CollectionAdminConfig[];
148
+ /** Admin UI configuration for collections and singleton resources (client-side only). */
149
+ admin?: AdminResourceConfig[];
150
150
  /**
151
- * Ordered registry of dashboard collection groups. Array order is display
152
- * order. A collection joins a group by setting `CollectionAdminConfig.group`
153
- * to an entry's `name`.
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. Predicates from
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. Predicates from
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
- if (result != null)
52
- predicates.push(result);
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 CollectionAbilityVerb } from './register-collection-abilities.js';
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** → `actor.assertAbility('collections.<path>.<verb>')`.
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, collectionPath: string, verb: CollectionAbilityVerb): void;
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 { collectionAbilityKey, } from './register-collection-abilities.js';
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** → `actor.assertAbility('collections.<path>.<verb>')`.
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, collectionPath, verb) {
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} '${collectionPath}'. Pass createSuperAdminContext() ` +
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} '${collectionPath}': no actor in context`,
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 '${collectionPath}' requires readMode: 'published' ` +
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(collectionAbilityKey(collectionPath, verb));
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, 'pages', 'read');
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, 'pages', 'read')).not.toThrow();
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, 'pages', 'read');
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, 'pages', 'read');
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, 'pages', verb);
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, 'pages', 'update')).not.toThrow();
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, 'pages', 'publish');
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, 'pages', 'update')).not.toThrow();
97
+ expect(() => assertActorCanPerform(ctx, pages, 'update')).not.toThrow();
94
98
  try {
95
- assertActorCanPerform(ctx, 'news', 'update');
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, 'pages', 'read')).not.toThrow();
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 collection list to those the administrator can read.
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
- * the list view, and the per-status counts — requires
24
- * `collections.<path>.read` and is rejected server-side without it. An
25
- * administrator who cannot read a collection would otherwise see a card whose
26
- * status tiles all read zero, which is indistinguishable from a collection that
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 { collectionAbilityKey } from './register-collection-abilities.js';
8
+ import { documentAbilityKey } from './register-collection-abilities.js';
9
9
  /**
10
- * Narrow a collection list to those the administrator can read.
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
- * the list view, and the per-status counts — requires
14
- * `collections.<path>.read` and is rejected server-side without it. An
15
- * administrator who cannot read a collection would otherwise see a card whose
16
- * status tiles all read zero, which is indistinguishable from a collection that
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(collectionAbilityKey(collection.path, 'read')));
31
+ return collections.filter((collection) => held.has(documentAbilityKey(collection, 'read')));
32
32
  }