@byline/core 1.0.0 → 1.2.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.
|
@@ -320,21 +320,32 @@ export interface AfterCreateContext {
|
|
|
320
320
|
documentVersionId: string;
|
|
321
321
|
}
|
|
322
322
|
/**
|
|
323
|
-
* Context passed to `beforeUpdate` hooks —
|
|
323
|
+
* Context passed to `beforeUpdate` hooks — PUT, patch, and restore flows.
|
|
324
324
|
*
|
|
325
325
|
* `data` is the next version (mutable). `originalData` is the previous
|
|
326
326
|
* version as reconstructed from storage.
|
|
327
|
+
*
|
|
328
|
+
* `restore` is set only when the update originates from a "make current"
|
|
329
|
+
* action against a historical version. Userland hooks that need to react
|
|
330
|
+
* differently (e.g. tag the audit entry, skip search re-index) can branch
|
|
331
|
+
* on its presence.
|
|
327
332
|
*/
|
|
328
333
|
export interface BeforeUpdateContext {
|
|
329
334
|
data: Record<string, any>;
|
|
330
335
|
originalData: Record<string, any>;
|
|
331
336
|
collectionPath: string;
|
|
337
|
+
restore?: {
|
|
338
|
+
sourceVersionId: string;
|
|
339
|
+
};
|
|
332
340
|
}
|
|
333
341
|
/**
|
|
334
342
|
* Context passed to `afterUpdate` hooks.
|
|
335
343
|
*
|
|
336
344
|
* Includes the `documentId` and `documentVersionId` of the newly created
|
|
337
345
|
* version so the hook can reference the persisted document.
|
|
346
|
+
*
|
|
347
|
+
* `restore` mirrors `BeforeUpdateContext.restore` — present only when the
|
|
348
|
+
* update was triggered by restoring a historical version.
|
|
338
349
|
*/
|
|
339
350
|
export interface AfterUpdateContext {
|
|
340
351
|
data: Record<string, any>;
|
|
@@ -342,6 +353,9 @@ export interface AfterUpdateContext {
|
|
|
342
353
|
collectionPath: string;
|
|
343
354
|
documentId: string;
|
|
344
355
|
documentVersionId: string;
|
|
356
|
+
restore?: {
|
|
357
|
+
sourceVersionId: string;
|
|
358
|
+
};
|
|
345
359
|
}
|
|
346
360
|
/**
|
|
347
361
|
* Context passed to `beforeStatusChange` / `afterStatusChange` hooks.
|
|
@@ -112,6 +112,11 @@ export interface UnpublishResult {
|
|
|
112
112
|
export interface DeleteDocumentResult {
|
|
113
113
|
deletedVersionCount: number;
|
|
114
114
|
}
|
|
115
|
+
export interface RestoreVersionResult {
|
|
116
|
+
documentId: string;
|
|
117
|
+
documentVersionId: string;
|
|
118
|
+
sourceVersionId: string;
|
|
119
|
+
}
|
|
115
120
|
/**
|
|
116
121
|
* Create a new document.
|
|
117
122
|
*
|
|
@@ -216,6 +221,50 @@ export declare function changeDocumentStatus(ctx: DocumentLifecycleContext, para
|
|
|
216
221
|
export declare function unpublishDocument(ctx: DocumentLifecycleContext, params: {
|
|
217
222
|
documentId: string;
|
|
218
223
|
}): Promise<UnpublishResult>;
|
|
224
|
+
/**
|
|
225
|
+
* Restore a historical document version as the new current version.
|
|
226
|
+
*
|
|
227
|
+
* Reads the source version with `locale: 'all'` so the entire multi-locale
|
|
228
|
+
* field tree (with `_id` / `_type` meta inlined onto blocks and array items
|
|
229
|
+
* by `reconstructFromUnifiedRows`) is captured. That tree is then re-emitted
|
|
230
|
+
* through `createDocumentVersion` with `locale: 'all'`, which produces a
|
|
231
|
+
* fresh version row with a new UUIDv7 id and the latest `created_at`. The
|
|
232
|
+
* `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
|
|
233
|
+
* ORDER BY created_at DESC`) automatically promotes the new row to current.
|
|
234
|
+
*
|
|
235
|
+
* Status is hard-defaulted to the workflow's first status — restoring an
|
|
236
|
+
* old `published` version must never silently re-publish content. The user
|
|
237
|
+
* runs the restored draft through the normal workflow.
|
|
238
|
+
*
|
|
239
|
+
* `path` is sticky from the previous current version (not from the source),
|
|
240
|
+
* matching the semantics of `updateDocument`. A path change made between
|
|
241
|
+
* the source and now should not be undone by the restore.
|
|
242
|
+
*
|
|
243
|
+
* Auth reuses the `update` ability — restore is conceptually an edit
|
|
244
|
+
* against an existing document.
|
|
245
|
+
*
|
|
246
|
+
* Hooks: fires `beforeUpdate` / `afterUpdate` with a `restore: { sourceVersionId }`
|
|
247
|
+
* field on the context. Userland hooks that need to react differently
|
|
248
|
+
* (e.g. tag the audit entry, skip search re-index) can branch on its
|
|
249
|
+
* presence.
|
|
250
|
+
*
|
|
251
|
+
* Flow:
|
|
252
|
+
* 1. Auth: `update` ability.
|
|
253
|
+
* 2. Read source version with `locale: 'all'`.
|
|
254
|
+
* 3. Validate source belongs to `documentId` (defence against forged
|
|
255
|
+
* cross-document version ids).
|
|
256
|
+
* 4. Read current version metadata; reject if the source IS already the
|
|
257
|
+
* current version (nothing to restore).
|
|
258
|
+
* 5. Read current document with reconstruction for hook `originalData`.
|
|
259
|
+
* 6. `hooks.beforeUpdate({ data, originalData, collectionPath, restore })`
|
|
260
|
+
* 7. `db.commands.documents.createDocumentVersion(...)` with
|
|
261
|
+
* `action: 'restore'`, `locale: 'all'`, sticky path, default status.
|
|
262
|
+
* 8. `hooks.afterUpdate({ ..., restore })`
|
|
263
|
+
*/
|
|
264
|
+
export declare function restoreDocumentVersion(ctx: DocumentLifecycleContext, params: {
|
|
265
|
+
documentId: string;
|
|
266
|
+
sourceVersionId: string;
|
|
267
|
+
}): Promise<RestoreVersionResult>;
|
|
219
268
|
/**
|
|
220
269
|
* Soft-delete a document.
|
|
221
270
|
*
|
|
@@ -383,6 +383,146 @@ export async function unpublishDocument(ctx, params) {
|
|
|
383
383
|
return { archivedCount };
|
|
384
384
|
});
|
|
385
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* Restore a historical document version as the new current version.
|
|
388
|
+
*
|
|
389
|
+
* Reads the source version with `locale: 'all'` so the entire multi-locale
|
|
390
|
+
* field tree (with `_id` / `_type` meta inlined onto blocks and array items
|
|
391
|
+
* by `reconstructFromUnifiedRows`) is captured. That tree is then re-emitted
|
|
392
|
+
* through `createDocumentVersion` with `locale: 'all'`, which produces a
|
|
393
|
+
* fresh version row with a new UUIDv7 id and the latest `created_at`. The
|
|
394
|
+
* `current_documents` view (`ROW_NUMBER() OVER PARTITION BY document_id
|
|
395
|
+
* ORDER BY created_at DESC`) automatically promotes the new row to current.
|
|
396
|
+
*
|
|
397
|
+
* Status is hard-defaulted to the workflow's first status — restoring an
|
|
398
|
+
* old `published` version must never silently re-publish content. The user
|
|
399
|
+
* runs the restored draft through the normal workflow.
|
|
400
|
+
*
|
|
401
|
+
* `path` is sticky from the previous current version (not from the source),
|
|
402
|
+
* matching the semantics of `updateDocument`. A path change made between
|
|
403
|
+
* the source and now should not be undone by the restore.
|
|
404
|
+
*
|
|
405
|
+
* Auth reuses the `update` ability — restore is conceptually an edit
|
|
406
|
+
* against an existing document.
|
|
407
|
+
*
|
|
408
|
+
* Hooks: fires `beforeUpdate` / `afterUpdate` with a `restore: { sourceVersionId }`
|
|
409
|
+
* field on the context. Userland hooks that need to react differently
|
|
410
|
+
* (e.g. tag the audit entry, skip search re-index) can branch on its
|
|
411
|
+
* presence.
|
|
412
|
+
*
|
|
413
|
+
* Flow:
|
|
414
|
+
* 1. Auth: `update` ability.
|
|
415
|
+
* 2. Read source version with `locale: 'all'`.
|
|
416
|
+
* 3. Validate source belongs to `documentId` (defence against forged
|
|
417
|
+
* cross-document version ids).
|
|
418
|
+
* 4. Read current version metadata; reject if the source IS already the
|
|
419
|
+
* current version (nothing to restore).
|
|
420
|
+
* 5. Read current document with reconstruction for hook `originalData`.
|
|
421
|
+
* 6. `hooks.beforeUpdate({ data, originalData, collectionPath, restore })`
|
|
422
|
+
* 7. `db.commands.documents.createDocumentVersion(...)` with
|
|
423
|
+
* `action: 'restore'`, `locale: 'all'`, sticky path, default status.
|
|
424
|
+
* 8. `hooks.afterUpdate({ ..., restore })`
|
|
425
|
+
*/
|
|
426
|
+
export async function restoreDocumentVersion(ctx, params) {
|
|
427
|
+
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
|
|
428
|
+
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
429
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
430
|
+
const hooks = definition.hooks;
|
|
431
|
+
// 1. Read source version (full multi-locale tree).
|
|
432
|
+
const source = await db.queries.documents.getDocumentByVersion({
|
|
433
|
+
document_version_id: params.sourceVersionId,
|
|
434
|
+
locale: 'all',
|
|
435
|
+
});
|
|
436
|
+
if (source == null) {
|
|
437
|
+
throw ERR_NOT_FOUND({
|
|
438
|
+
message: 'source version not found',
|
|
439
|
+
details: { sourceVersionId: params.sourceVersionId },
|
|
440
|
+
}).log(ctx.logger);
|
|
441
|
+
}
|
|
442
|
+
// 2. Cross-document forgery check.
|
|
443
|
+
if (source.document_id !== params.documentId) {
|
|
444
|
+
throw ERR_VALIDATION({
|
|
445
|
+
message: 'source version does not belong to the target document',
|
|
446
|
+
details: {
|
|
447
|
+
documentId: params.documentId,
|
|
448
|
+
sourceVersionId: params.sourceVersionId,
|
|
449
|
+
sourceDocumentId: source.document_id,
|
|
450
|
+
},
|
|
451
|
+
}).log(ctx.logger);
|
|
452
|
+
}
|
|
453
|
+
// 3. Current version metadata — used both for the already-current
|
|
454
|
+
// guard and for the sticky path resolution.
|
|
455
|
+
const currentMeta = await db.queries.documents.getCurrentVersionMetadata({
|
|
456
|
+
collection_id: collectionId,
|
|
457
|
+
document_id: params.documentId,
|
|
458
|
+
});
|
|
459
|
+
if (currentMeta == null) {
|
|
460
|
+
throw ERR_NOT_FOUND({
|
|
461
|
+
message: 'document not found',
|
|
462
|
+
details: { documentId: params.documentId },
|
|
463
|
+
}).log(ctx.logger);
|
|
464
|
+
}
|
|
465
|
+
if (currentMeta.document_version_id === params.sourceVersionId) {
|
|
466
|
+
throw ERR_INVALID_TRANSITION({
|
|
467
|
+
message: 'source version is already the current version of this document',
|
|
468
|
+
details: {
|
|
469
|
+
documentId: params.documentId,
|
|
470
|
+
sourceVersionId: params.sourceVersionId,
|
|
471
|
+
},
|
|
472
|
+
}).log(ctx.logger);
|
|
473
|
+
}
|
|
474
|
+
// 4. originalData for hooks: full reconstruction of the current
|
|
475
|
+
// version (locale-scoped, matching updateDocument's semantics).
|
|
476
|
+
const latest = await db.queries.documents.getDocumentById({
|
|
477
|
+
collection_id: collectionId,
|
|
478
|
+
document_id: params.documentId,
|
|
479
|
+
locale: defaultLocale,
|
|
480
|
+
reconstruct: true,
|
|
481
|
+
});
|
|
482
|
+
const originalData = latest ?? {};
|
|
483
|
+
const sourceFields = source.fields ?? {};
|
|
484
|
+
const restoreContext = { sourceVersionId: params.sourceVersionId };
|
|
485
|
+
// 5. beforeUpdate.
|
|
486
|
+
await invokeHook(hooks?.beforeUpdate, {
|
|
487
|
+
data: sourceFields,
|
|
488
|
+
originalData,
|
|
489
|
+
collectionPath,
|
|
490
|
+
restore: restoreContext,
|
|
491
|
+
});
|
|
492
|
+
// 6. Persist new version. locale: 'all' carries every locale row in
|
|
493
|
+
// the source tree forward in a single flatten pass — the
|
|
494
|
+
// cross-locale carry-forward branch in createDocumentVersion does
|
|
495
|
+
// not fire when locale === 'all'.
|
|
496
|
+
const result = await db.commands.documents.createDocumentVersion({
|
|
497
|
+
documentId: params.documentId,
|
|
498
|
+
collectionId,
|
|
499
|
+
collectionVersion: ctx.collectionVersion,
|
|
500
|
+
collectionConfig: definition,
|
|
501
|
+
action: 'restore',
|
|
502
|
+
documentData: sourceFields,
|
|
503
|
+
path: currentMeta.path,
|
|
504
|
+
status: getDefaultStatus(definition),
|
|
505
|
+
locale: 'all',
|
|
506
|
+
previousVersionId: currentMeta.document_version_id,
|
|
507
|
+
});
|
|
508
|
+
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
509
|
+
const documentVersionId = extractVersionId(result.document);
|
|
510
|
+
// 7. afterUpdate.
|
|
511
|
+
await invokeHook(hooks?.afterUpdate, {
|
|
512
|
+
data: sourceFields,
|
|
513
|
+
originalData,
|
|
514
|
+
collectionPath,
|
|
515
|
+
documentId,
|
|
516
|
+
documentVersionId,
|
|
517
|
+
restore: restoreContext,
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
documentId,
|
|
521
|
+
documentVersionId,
|
|
522
|
+
sourceVersionId: params.sourceVersionId,
|
|
523
|
+
};
|
|
524
|
+
});
|
|
525
|
+
}
|
|
386
526
|
/**
|
|
387
527
|
* Soft-delete a document.
|
|
388
528
|
*
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { BylineError, ErrorCodes } from '../lib/errors.js';
|
|
11
|
-
import { changeDocumentStatus, createDocument, deleteDocument, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
|
|
11
|
+
import { changeDocumentStatus, createDocument, deleteDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Fixtures / Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -648,6 +648,180 @@ describe('Document lifecycle service', () => {
|
|
|
648
648
|
});
|
|
649
649
|
});
|
|
650
650
|
// -----------------------------------------------------------------------
|
|
651
|
+
// restoreDocumentVersion
|
|
652
|
+
// -----------------------------------------------------------------------
|
|
653
|
+
describe('restoreDocumentVersion', () => {
|
|
654
|
+
function setupRestore(opts) {
|
|
655
|
+
const sourceDocumentId = opts?.sourceDocumentId ?? 'doc-1';
|
|
656
|
+
const currentVersionId = opts?.currentVersionId ?? 'ver-current';
|
|
657
|
+
const sourceVersionId = 'ver-source';
|
|
658
|
+
const currentPath = opts?.currentPath ?? 'sticky-path';
|
|
659
|
+
const sourceFields = opts?.sourceFields ?? {
|
|
660
|
+
title: { en: 'Old EN', fr: 'Vieux FR' },
|
|
661
|
+
};
|
|
662
|
+
const mocks = createMockDb();
|
|
663
|
+
const { db } = mocks;
|
|
664
|
+
db.queries.documents.getDocumentByVersion.mockResolvedValue({
|
|
665
|
+
document_version_id: sourceVersionId,
|
|
666
|
+
document_id: sourceDocumentId,
|
|
667
|
+
path: 'long-ago-path',
|
|
668
|
+
status: 'archived',
|
|
669
|
+
fields: sourceFields,
|
|
670
|
+
});
|
|
671
|
+
mocks.getCurrentVersionMetadata.mockResolvedValue({
|
|
672
|
+
document_version_id: currentVersionId,
|
|
673
|
+
document_id: 'doc-1',
|
|
674
|
+
collection_id: 'col-1',
|
|
675
|
+
path: currentPath,
|
|
676
|
+
status: 'published',
|
|
677
|
+
created_at: new Date(),
|
|
678
|
+
updated_at: new Date(),
|
|
679
|
+
});
|
|
680
|
+
mocks.getDocumentById.mockResolvedValue({
|
|
681
|
+
document_version_id: currentVersionId,
|
|
682
|
+
path: currentPath,
|
|
683
|
+
status: 'published',
|
|
684
|
+
fields: { title: 'Currently Published' },
|
|
685
|
+
});
|
|
686
|
+
mocks.createDocumentVersion.mockResolvedValue({
|
|
687
|
+
document: { id: 'ver-restored', document_id: 'doc-1' },
|
|
688
|
+
fieldCount: 5,
|
|
689
|
+
});
|
|
690
|
+
return { ...mocks, sourceVersionId, currentVersionId, sourceFields, currentPath };
|
|
691
|
+
}
|
|
692
|
+
it('reads the source with locale: "all" and re-emits via createDocumentVersion', async () => {
|
|
693
|
+
const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
|
|
694
|
+
const ctx = buildCtx(db);
|
|
695
|
+
const result = await restoreDocumentVersion(ctx, {
|
|
696
|
+
documentId: 'doc-1',
|
|
697
|
+
sourceVersionId,
|
|
698
|
+
});
|
|
699
|
+
expect(db.queries.documents.getDocumentByVersion).toHaveBeenCalledWith({
|
|
700
|
+
document_version_id: sourceVersionId,
|
|
701
|
+
locale: 'all',
|
|
702
|
+
});
|
|
703
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
704
|
+
const call = createDocumentVersion.mock.calls[0]?.[0];
|
|
705
|
+
expect(call.action).toBe('restore');
|
|
706
|
+
expect(call.locale).toBe('all');
|
|
707
|
+
expect(call.documentData).toEqual(sourceFields);
|
|
708
|
+
expect(call.previousVersionId).toBe(currentVersionId);
|
|
709
|
+
expect(result).toEqual({
|
|
710
|
+
documentId: 'doc-1',
|
|
711
|
+
documentVersionId: 'ver-restored',
|
|
712
|
+
sourceVersionId,
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
|
|
716
|
+
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
717
|
+
const ctx = buildCtx(db);
|
|
718
|
+
await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
|
|
719
|
+
// Source version was 'archived'; default status for minimalCollection is 'draft'.
|
|
720
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
|
|
721
|
+
});
|
|
722
|
+
it('keeps path sticky from the current version, not the source', async () => {
|
|
723
|
+
const { db, createDocumentVersion, sourceVersionId } = setupRestore({
|
|
724
|
+
currentPath: 'sticky-path',
|
|
725
|
+
});
|
|
726
|
+
const ctx = buildCtx(db);
|
|
727
|
+
await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
|
|
728
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('sticky-path');
|
|
729
|
+
});
|
|
730
|
+
it('rejects when the source version belongs to a different document', async () => {
|
|
731
|
+
const { db, sourceVersionId, createDocumentVersion } = setupRestore({
|
|
732
|
+
sourceDocumentId: 'doc-OTHER',
|
|
733
|
+
});
|
|
734
|
+
const ctx = buildCtx(db);
|
|
735
|
+
try {
|
|
736
|
+
await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
|
|
737
|
+
expect.fail('expected ERR_VALIDATION');
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
expect(err).toBeInstanceOf(BylineError);
|
|
741
|
+
expect(err.code).toBe(ErrorCodes.VALIDATION);
|
|
742
|
+
}
|
|
743
|
+
expect(createDocumentVersion).not.toHaveBeenCalled();
|
|
744
|
+
});
|
|
745
|
+
it('rejects when the source is already the current version', async () => {
|
|
746
|
+
const sourceVersionId = 'ver-source';
|
|
747
|
+
const mocks = createMockDb();
|
|
748
|
+
const { db } = mocks;
|
|
749
|
+
db.queries.documents.getDocumentByVersion.mockResolvedValue({
|
|
750
|
+
document_version_id: sourceVersionId,
|
|
751
|
+
document_id: 'doc-1',
|
|
752
|
+
path: 'p',
|
|
753
|
+
status: 'draft',
|
|
754
|
+
fields: {},
|
|
755
|
+
});
|
|
756
|
+
mocks.getCurrentVersionMetadata.mockResolvedValue({
|
|
757
|
+
document_version_id: sourceVersionId, // SAME as source
|
|
758
|
+
document_id: 'doc-1',
|
|
759
|
+
collection_id: 'col-1',
|
|
760
|
+
path: 'p',
|
|
761
|
+
status: 'draft',
|
|
762
|
+
created_at: new Date(),
|
|
763
|
+
updated_at: new Date(),
|
|
764
|
+
});
|
|
765
|
+
const ctx = buildCtx(db);
|
|
766
|
+
try {
|
|
767
|
+
await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
|
|
768
|
+
expect.fail('expected ERR_INVALID_TRANSITION');
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
expect(err.code).toBe(ErrorCodes.INVALID_TRANSITION);
|
|
772
|
+
}
|
|
773
|
+
expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
|
|
774
|
+
});
|
|
775
|
+
it('fires beforeUpdate / afterUpdate with restore: { sourceVersionId } context', async () => {
|
|
776
|
+
const beforeUpdate = vi.fn();
|
|
777
|
+
const afterUpdate = vi.fn();
|
|
778
|
+
const { db, sourceVersionId } = setupRestore();
|
|
779
|
+
const definition = { ...minimalCollection, hooks: { beforeUpdate, afterUpdate } };
|
|
780
|
+
const ctx = buildCtx(db, definition);
|
|
781
|
+
await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
|
|
782
|
+
expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
783
|
+
collectionPath: 'articles',
|
|
784
|
+
restore: { sourceVersionId },
|
|
785
|
+
originalData: expect.objectContaining({
|
|
786
|
+
fields: expect.objectContaining({ title: 'Currently Published' }),
|
|
787
|
+
}),
|
|
788
|
+
}));
|
|
789
|
+
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
790
|
+
documentId: 'doc-1',
|
|
791
|
+
documentVersionId: 'ver-restored',
|
|
792
|
+
restore: { sourceVersionId },
|
|
793
|
+
}));
|
|
794
|
+
});
|
|
795
|
+
it('throws ERR_FORBIDDEN when actor lacks collections.<path>.update', async () => {
|
|
796
|
+
const { db, sourceVersionId } = setupRestore();
|
|
797
|
+
const ctx = buildCtx(db);
|
|
798
|
+
const actor = new AdminAuth({
|
|
799
|
+
id: 'editor',
|
|
800
|
+
abilities: ['collections.articles.read'],
|
|
801
|
+
});
|
|
802
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
803
|
+
try {
|
|
804
|
+
await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
|
|
805
|
+
expect.fail('expected ERR_FORBIDDEN');
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
|
|
809
|
+
expect(err.message).toContain('collections.articles.update');
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
it('permits restore when actor holds only collections.<path>.update (no separate restore verb needed)', async () => {
|
|
813
|
+
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
814
|
+
const ctx = buildCtx(db);
|
|
815
|
+
const actor = new AdminAuth({
|
|
816
|
+
id: 'editor',
|
|
817
|
+
abilities: ['collections.articles.update', 'collections.articles.read'],
|
|
818
|
+
});
|
|
819
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
820
|
+
await restoreDocumentVersion(ctx, { documentId: 'doc-1', sourceVersionId });
|
|
821
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
// -----------------------------------------------------------------------
|
|
651
825
|
// Ability enforcement (Phase 4)
|
|
652
826
|
// -----------------------------------------------------------------------
|
|
653
827
|
describe('ability enforcement', () => {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.2.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"pino": "^10.3.1",
|
|
74
74
|
"uuid": "^14.0.0",
|
|
75
75
|
"zod": "^4.4.2",
|
|
76
|
-
"@byline/auth": "1.
|
|
76
|
+
"@byline/auth": "1.2.0"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@biomejs/biome": "2.4.14",
|