@byline/core 3.9.0 → 3.10.1
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/db-types.d.ts +85 -0
- package/dist/lib/errors.d.ts +11 -0
- package/dist/lib/errors.js +11 -0
- package/dist/services/document-lifecycle/audit.d.ts +45 -0
- package/dist/services/document-lifecycle/audit.js +67 -0
- package/dist/services/document-lifecycle/delete.js +21 -3
- package/dist/services/document-lifecycle/status.js +28 -11
- package/dist/services/document-lifecycle/system-fields.d.ts +3 -1
- package/dist/services/document-lifecycle/system-fields.js +56 -19
- package/dist/services/document-lifecycle.test.node.js +100 -3
- package/package.json +2 -2
|
@@ -203,10 +203,20 @@ export interface IDbAdapter {
|
|
|
203
203
|
collections: ICollectionCommands;
|
|
204
204
|
documents: IDocumentCommands;
|
|
205
205
|
counters: ICounterCommands;
|
|
206
|
+
/**
|
|
207
|
+
* Append-only audit-log writes (docs/AUDIT.md — Workstream 2). Optional
|
|
208
|
+
* capability, paired with `withTransaction`: a consumer that records audit
|
|
209
|
+
* entries asserts both are present and throws otherwise (it must never
|
|
210
|
+
* silently skip the audit row). Adapters that model the audit log
|
|
211
|
+
* implement it; others omit it.
|
|
212
|
+
*/
|
|
213
|
+
audit?: IAuditCommands;
|
|
206
214
|
};
|
|
207
215
|
queries: {
|
|
208
216
|
collections: ICollectionQueries;
|
|
209
217
|
documents: IDocumentQueries;
|
|
218
|
+
/** Audit-log reads — per-document history, system-wide report. See docs/AUDIT.md. */
|
|
219
|
+
audit?: IAuditQueries;
|
|
210
220
|
};
|
|
211
221
|
/**
|
|
212
222
|
* Optional capability: run `fn` inside a single database transaction so the
|
|
@@ -239,6 +249,81 @@ export interface IDbAdapter {
|
|
|
239
249
|
rowsUpdated: number;
|
|
240
250
|
}>;
|
|
241
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* The realm of the actor that performed an audited change. `'admin'` for
|
|
254
|
+
* admin-user actions, `'user'` reserved for the end-user realm, `'system'`
|
|
255
|
+
* for deliberate internal-tooling writes. See docs/AUDIT.md.
|
|
256
|
+
*/
|
|
257
|
+
export type AuditActorRealm = 'admin' | 'user' | 'system';
|
|
258
|
+
/** Input to `IAuditCommands.append` — one audit-log row. */
|
|
259
|
+
export interface AuditLogAppendInput {
|
|
260
|
+
/** The document the change concerns; NULL for admin-realm (non-document) events. */
|
|
261
|
+
documentId?: string | null;
|
|
262
|
+
collectionId?: string | null;
|
|
263
|
+
/** The acting user id, only when it is a real persisted user (a UUID); NULL otherwise. */
|
|
264
|
+
actorId?: string | null;
|
|
265
|
+
actorRealm: AuditActorRealm;
|
|
266
|
+
/** Namespaced action, e.g. `document.path.changed`. */
|
|
267
|
+
action: string;
|
|
268
|
+
/** The changed field where meaningful (e.g. `path`); NULL for whole-entity events. */
|
|
269
|
+
field?: string | null;
|
|
270
|
+
/** Prior value (JSON-serialisable); NULL where not applicable. */
|
|
271
|
+
before?: unknown;
|
|
272
|
+
/** New value (JSON-serialisable); NULL where not applicable. */
|
|
273
|
+
after?: unknown;
|
|
274
|
+
}
|
|
275
|
+
/** A materialised audit-log row. */
|
|
276
|
+
export interface AuditLogEntry {
|
|
277
|
+
id: string;
|
|
278
|
+
documentId: string | null;
|
|
279
|
+
collectionId: string | null;
|
|
280
|
+
actorId: string | null;
|
|
281
|
+
actorRealm: string;
|
|
282
|
+
action: string;
|
|
283
|
+
field: string | null;
|
|
284
|
+
before: unknown;
|
|
285
|
+
after: unknown;
|
|
286
|
+
occurredAt: Date;
|
|
287
|
+
}
|
|
288
|
+
/** A page of audit-log entries with pagination metadata. */
|
|
289
|
+
export interface AuditLogPage {
|
|
290
|
+
entries: AuditLogEntry[];
|
|
291
|
+
meta: {
|
|
292
|
+
total: number;
|
|
293
|
+
page: number;
|
|
294
|
+
pageSize: number;
|
|
295
|
+
totalPages: number;
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Append-only audit-log writes. The companion read interface is
|
|
300
|
+
* `IAuditQueries`. See docs/AUDIT.md — Workstream 2.
|
|
301
|
+
*/
|
|
302
|
+
export interface IAuditCommands {
|
|
303
|
+
/**
|
|
304
|
+
* Append one immutable audit-log row. The adapter generates the row id
|
|
305
|
+
* (UUIDv7) and `occurred_at`. Runs on the ambient executor, so when called
|
|
306
|
+
* inside `withTransaction` it commits atomically with the mutation it
|
|
307
|
+
* records — the load-bearing guarantee of the audit log.
|
|
308
|
+
*/
|
|
309
|
+
append(input: AuditLogAppendInput): Promise<{
|
|
310
|
+
id: string;
|
|
311
|
+
}>;
|
|
312
|
+
}
|
|
313
|
+
/** Audit-log reads. See docs/AUDIT.md — Workstreams 3 & 4. */
|
|
314
|
+
export interface IAuditQueries {
|
|
315
|
+
/**
|
|
316
|
+
* The audit history for one document, newest first, paged. Backs the
|
|
317
|
+
* document-history view. The caller is responsible for the access gate
|
|
318
|
+
* (the document's own read pipeline) before reaching this — it does no
|
|
319
|
+
* scoping of its own.
|
|
320
|
+
*/
|
|
321
|
+
getDocumentAuditLog(params: {
|
|
322
|
+
document_id: string;
|
|
323
|
+
page?: number;
|
|
324
|
+
page_size?: number;
|
|
325
|
+
}): Promise<AuditLogPage>;
|
|
326
|
+
}
|
|
242
327
|
/**
|
|
243
328
|
* Adapter capability for the shared-pool counter mechanism backing the
|
|
244
329
|
* `counter` field type. See `packages/core/src/@types/field-types.ts`
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -86,6 +86,7 @@ export declare const ErrorCodes: {
|
|
|
86
86
|
readonly STORAGE: "ERR_STORAGE";
|
|
87
87
|
readonly READ_BUDGET_EXCEEDED: "ERR_READ_BUDGET_EXCEEDED";
|
|
88
88
|
readonly PATH_CONFLICT: "ERR_PATH_CONFLICT";
|
|
89
|
+
readonly AUDIT_UNSUPPORTED: "ERR_AUDIT_UNSUPPORTED";
|
|
89
90
|
};
|
|
90
91
|
export declare const ERR_UNHANDLED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
91
92
|
export declare const ERR_NOT_FOUND: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
@@ -103,3 +104,13 @@ export declare const ERR_READ_BUDGET_EXCEEDED: (opts: BylineErrorOptions, errorC
|
|
|
103
104
|
* `byline_document_paths(collection_id, locale, path)`.
|
|
104
105
|
*/
|
|
105
106
|
export declare const ERR_PATH_CONFLICT: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
|
107
|
+
/**
|
|
108
|
+
* Thrown when an audited write (a document-grain change that must be recorded
|
|
109
|
+
* atomically — path / available-locales / status / delete) runs against a db
|
|
110
|
+
* adapter that does not provide both the `withTransaction` capability and the
|
|
111
|
+
* `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
|
|
112
|
+
* user error: an auditability guarantee cannot be honoured non-atomically, so
|
|
113
|
+
* the write is refused loudly rather than recorded with a gap. See
|
|
114
|
+
* docs/TRANSACTIONS.md and docs/AUDIT.md.
|
|
115
|
+
*/
|
|
116
|
+
export declare const ERR_AUDIT_UNSUPPORTED: (opts: BylineErrorOptions, errorConstructor?: any) => BylineError;
|
package/dist/lib/errors.js
CHANGED
|
@@ -119,6 +119,7 @@ export const ErrorCodes = {
|
|
|
119
119
|
STORAGE: 'ERR_STORAGE',
|
|
120
120
|
READ_BUDGET_EXCEEDED: 'ERR_READ_BUDGET_EXCEEDED',
|
|
121
121
|
PATH_CONFLICT: 'ERR_PATH_CONFLICT',
|
|
122
|
+
AUDIT_UNSUPPORTED: 'ERR_AUDIT_UNSUPPORTED',
|
|
122
123
|
};
|
|
123
124
|
// ---------------------------------------------------------------------------
|
|
124
125
|
// Pre-instantiated factories
|
|
@@ -139,3 +140,13 @@ export const ERR_READ_BUDGET_EXCEEDED = createErrorType(ErrorCodes.READ_BUDGET_E
|
|
|
139
140
|
* `byline_document_paths(collection_id, locale, path)`.
|
|
140
141
|
*/
|
|
141
142
|
export const ERR_PATH_CONFLICT = createErrorType(ErrorCodes.PATH_CONFLICT, 'warn');
|
|
143
|
+
/**
|
|
144
|
+
* Thrown when an audited write (a document-grain change that must be recorded
|
|
145
|
+
* atomically — path / available-locales / status / delete) runs against a db
|
|
146
|
+
* adapter that does not provide both the `withTransaction` capability and the
|
|
147
|
+
* `commands.audit` / `queries.audit` surfaces. A misconfiguration, not a
|
|
148
|
+
* user error: an auditability guarantee cannot be honoured non-atomically, so
|
|
149
|
+
* the write is refused loudly rather than recorded with a gap. See
|
|
150
|
+
* docs/TRANSACTIONS.md and docs/AUDIT.md.
|
|
151
|
+
*/
|
|
152
|
+
export const ERR_AUDIT_UNSUPPORTED = createErrorType(ErrorCodes.AUDIT_UNSUPPORTED);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { AuditActorRealm, AuditLogAppendInput, IDbAdapter } from '../../@types/index.js';
|
|
9
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
10
|
+
/** Namespaced audit actions for document-grain changes. */
|
|
11
|
+
export declare const AUDIT_ACTIONS: {
|
|
12
|
+
readonly pathChanged: "document.path.changed";
|
|
13
|
+
readonly localesChanged: "document.locales.changed";
|
|
14
|
+
readonly statusChanged: "document.status.changed";
|
|
15
|
+
readonly deleted: "document.deleted";
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The actor id + realm for an audit-log row. Mirrors `actorId()`: a real
|
|
19
|
+
* persisted user carries a UUID id and is recorded with realm `'admin'`
|
|
20
|
+
* (these write-points are admin-gated, document-grain operations). A synthetic
|
|
21
|
+
* script/seed actor (non-UUID) or no actor is a system/tooling write — NULL id,
|
|
22
|
+
* realm `'system'`. (A future `UserAuth`-driven write-point would extend this
|
|
23
|
+
* to `'user'`.)
|
|
24
|
+
*/
|
|
25
|
+
export declare function auditActor(ctx: DocumentLifecycleContext): {
|
|
26
|
+
actorId: string | undefined;
|
|
27
|
+
actorRealm: AuditActorRealm;
|
|
28
|
+
};
|
|
29
|
+
/** A non-null audit capability resolved from an adapter that supports it. */
|
|
30
|
+
export interface AuditCapability {
|
|
31
|
+
withTransaction: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
32
|
+
append: (input: AuditLogAppendInput) => Promise<{
|
|
33
|
+
id: string;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Assert the adapter can record an audited write atomically — it must provide
|
|
38
|
+
* **both** `withTransaction` and `commands.audit`. Returns a non-null
|
|
39
|
+
* capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
|
|
40
|
+
* rather than silently skipping the audit row or running it non-atomically.
|
|
41
|
+
* See docs/TRANSACTIONS.md and docs/AUDIT.md.
|
|
42
|
+
*/
|
|
43
|
+
export declare function requireAuditCapability(db: IDbAdapter): AuditCapability;
|
|
44
|
+
/** Order-insensitive equality for the advertised-locale set. */
|
|
45
|
+
export declare function sameLocaleSet(a: readonly string[], b: readonly string[]): boolean;
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Audit-log write helpers for the document-grain lifecycle write-points
|
|
10
|
+
* (docs/AUDIT.md — Workstream 2). The audit log records the changes the
|
|
11
|
+
* immutable version stream does NOT capture an actor for: non-versioned
|
|
12
|
+
* system-field writes (path, available-locales), in-place status transitions,
|
|
13
|
+
* and deletions. Each such mutation and its audit row commit atomically inside
|
|
14
|
+
* `withTransaction` — a silently-unwritten audit row is the one unacceptable
|
|
15
|
+
* outcome (see docs/TRANSACTIONS.md).
|
|
16
|
+
*/
|
|
17
|
+
import { ERR_AUDIT_UNSUPPORTED } from '../../lib/errors.js';
|
|
18
|
+
import { actorId } from './internals.js';
|
|
19
|
+
/** Namespaced audit actions for document-grain changes. */
|
|
20
|
+
export const AUDIT_ACTIONS = {
|
|
21
|
+
pathChanged: 'document.path.changed',
|
|
22
|
+
localesChanged: 'document.locales.changed',
|
|
23
|
+
statusChanged: 'document.status.changed',
|
|
24
|
+
deleted: 'document.deleted',
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The actor id + realm for an audit-log row. Mirrors `actorId()`: a real
|
|
28
|
+
* persisted user carries a UUID id and is recorded with realm `'admin'`
|
|
29
|
+
* (these write-points are admin-gated, document-grain operations). A synthetic
|
|
30
|
+
* script/seed actor (non-UUID) or no actor is a system/tooling write — NULL id,
|
|
31
|
+
* realm `'system'`. (A future `UserAuth`-driven write-point would extend this
|
|
32
|
+
* to `'user'`.)
|
|
33
|
+
*/
|
|
34
|
+
export function auditActor(ctx) {
|
|
35
|
+
const id = actorId(ctx);
|
|
36
|
+
return id != null
|
|
37
|
+
? { actorId: id, actorRealm: 'admin' }
|
|
38
|
+
: { actorId: undefined, actorRealm: 'system' };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Assert the adapter can record an audited write atomically — it must provide
|
|
42
|
+
* **both** `withTransaction` and `commands.audit`. Returns a non-null
|
|
43
|
+
* capability the caller composes; throws `ERR_AUDIT_UNSUPPORTED` otherwise,
|
|
44
|
+
* rather than silently skipping the audit row or running it non-atomically.
|
|
45
|
+
* See docs/TRANSACTIONS.md and docs/AUDIT.md.
|
|
46
|
+
*/
|
|
47
|
+
export function requireAuditCapability(db) {
|
|
48
|
+
const withTransaction = db.withTransaction;
|
|
49
|
+
const audit = db.commands.audit;
|
|
50
|
+
if (withTransaction == null || audit == null) {
|
|
51
|
+
throw ERR_AUDIT_UNSUPPORTED({
|
|
52
|
+
message: 'audited write requires a db adapter with withTransaction + commands.audit support',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
withTransaction: (fn) => withTransaction(fn),
|
|
57
|
+
append: (input) => audit.append(input),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Order-insensitive equality for the advertised-locale set. */
|
|
61
|
+
export function sameLocaleSet(a, b) {
|
|
62
|
+
if (a.length !== b.length)
|
|
63
|
+
return false;
|
|
64
|
+
const sa = [...a].sort();
|
|
65
|
+
const sb = [...b].sort();
|
|
66
|
+
return sa.every((v, i) => v === sb[i]);
|
|
67
|
+
}
|
|
@@ -10,6 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getUploadFields } from '../../utils/storage-utils.js';
|
|
13
|
+
import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
|
|
13
14
|
import { invokeHook } from './internals.js';
|
|
14
15
|
/**
|
|
15
16
|
* Soft-delete a document.
|
|
@@ -91,9 +92,26 @@ export async function deleteDocument(ctx, params) {
|
|
|
91
92
|
};
|
|
92
93
|
// 2. beforeDelete hook.
|
|
93
94
|
await invokeHook(hooks?.beforeDelete, hookCtx);
|
|
94
|
-
// 3. Soft-delete all versions.
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// 3. Soft-delete all versions, atomically with the audit record. A
|
|
96
|
+
// whole-document delete mints no new version, so the version stream
|
|
97
|
+
// never records it — the audit log is the only place a deletion is
|
|
98
|
+
// accountable (docs/AUDIT.md). Storage-file cleanup (step 4) is a
|
|
99
|
+
// DB↔external side-effect and stays OUTSIDE the transaction — it is
|
|
100
|
+
// post-commit, best-effort compensation (docs/TRANSACTIONS.md).
|
|
101
|
+
const audit = requireAuditCapability(db);
|
|
102
|
+
const actor = auditActor(ctx);
|
|
103
|
+
let deletedVersionCount = 0;
|
|
104
|
+
await audit.withTransaction(async () => {
|
|
105
|
+
deletedVersionCount = await db.commands.documents.softDeleteDocument({
|
|
106
|
+
document_id: params.documentId,
|
|
107
|
+
});
|
|
108
|
+
await audit.append({
|
|
109
|
+
documentId: params.documentId,
|
|
110
|
+
collectionId: ctx.collectionId,
|
|
111
|
+
actorId: actor.actorId,
|
|
112
|
+
actorRealm: actor.actorRealm,
|
|
113
|
+
action: AUDIT_ACTIONS.deleted,
|
|
114
|
+
});
|
|
97
115
|
});
|
|
98
116
|
// 4. Clean up storage files. Non-fatal: logs errors but does not throw.
|
|
99
117
|
if (ctx.storage && storagePathsToDelete.length > 0) {
|
|
@@ -10,6 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
|
|
13
|
+
import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
|
|
13
14
|
import { invokeHook } from './internals.js';
|
|
14
15
|
/**
|
|
15
16
|
* Change a document's workflow status.
|
|
@@ -85,18 +86,34 @@ export async function changeDocumentStatus(ctx, params) {
|
|
|
85
86
|
};
|
|
86
87
|
// 3. beforeStatusChange hook.
|
|
87
88
|
await invokeHook(hooks?.beforeStatusChange, hookCtx);
|
|
88
|
-
// 4. Mutate status in-place
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
await db.commands.documents.
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
// 4–5. Mutate status in-place + auto-archive, atomically with the audit
|
|
90
|
+
// record. Status mutates the version row rather than minting a new
|
|
91
|
+
// version, so the version stream never captures *who* changed it —
|
|
92
|
+
// the audit log is its only accountability home (docs/AUDIT.md).
|
|
93
|
+
const audit = requireAuditCapability(db);
|
|
94
|
+
const actor = auditActor(ctx);
|
|
95
|
+
await audit.withTransaction(async () => {
|
|
96
|
+
await db.commands.documents.setDocumentStatus({
|
|
97
|
+
document_version_id: documentVersionId,
|
|
98
|
+
status: params.nextStatus,
|
|
98
99
|
});
|
|
99
|
-
|
|
100
|
+
if (params.nextStatus === 'published') {
|
|
101
|
+
await db.commands.documents.archivePublishedVersions({
|
|
102
|
+
document_id: params.documentId,
|
|
103
|
+
excludeVersionId: documentVersionId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
await audit.append({
|
|
107
|
+
documentId: params.documentId,
|
|
108
|
+
collectionId,
|
|
109
|
+
actorId: actor.actorId,
|
|
110
|
+
actorRealm: actor.actorRealm,
|
|
111
|
+
action: AUDIT_ACTIONS.statusChanged,
|
|
112
|
+
field: 'status',
|
|
113
|
+
before: currentStatus,
|
|
114
|
+
after: params.nextStatus,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
100
117
|
// 6. afterStatusChange hook.
|
|
101
118
|
await invokeHook(hooks?.afterStatusChange, hookCtx);
|
|
102
119
|
return { previousStatus: currentStatus, newStatus: params.nextStatus };
|
|
@@ -38,7 +38,9 @@ export interface UpdateDocumentSystemFieldsResult {
|
|
|
38
38
|
* `setDocumentAvailableLocales`.
|
|
39
39
|
*
|
|
40
40
|
* No content hooks fire — these are not content writes. Accountability for
|
|
41
|
-
* these mutations is the
|
|
41
|
+
* these mutations is the document-grain audit log: each field that actually
|
|
42
|
+
* changes records a `document.path.changed` / `document.locales.changed` row
|
|
43
|
+
* atomically with the write (docs/AUDIT.md — Workstream 2).
|
|
42
44
|
*
|
|
43
45
|
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
44
46
|
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
9
9
|
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
10
10
|
import { withLogContext } from '../../lib/logger.js';
|
|
11
|
+
import { AUDIT_ACTIONS, auditActor, requireAuditCapability, sameLocaleSet } from './audit.js';
|
|
11
12
|
import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
12
13
|
/**
|
|
13
14
|
* Write a document's system-managed, document-grain fields — `path` and the
|
|
@@ -34,7 +35,9 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
|
34
35
|
* `setDocumentAvailableLocales`.
|
|
35
36
|
*
|
|
36
37
|
* No content hooks fire — these are not content writes. Accountability for
|
|
37
|
-
* these mutations is the
|
|
38
|
+
* these mutations is the document-grain audit log: each field that actually
|
|
39
|
+
* changes records a `document.path.changed` / `document.locales.changed` row
|
|
40
|
+
* atomically with the write (docs/AUDIT.md — Workstream 2).
|
|
38
41
|
*
|
|
39
42
|
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
40
43
|
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
@@ -72,25 +75,59 @@ export async function updateDocumentSystemFields(ctx, params) {
|
|
|
72
75
|
documentId: params.documentId,
|
|
73
76
|
logger: ctx.logger,
|
|
74
77
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
path: pathForCommand,
|
|
82
|
-
})
|
|
83
|
-
.catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
|
|
84
|
-
}
|
|
85
|
-
// Advertised locales: rewrite the document-grain set wholesale.
|
|
78
|
+
// Both document-grain writes and their audit rows commit atomically.
|
|
79
|
+
// These fields are non-versioned, so the version stream never records
|
|
80
|
+
// them — the audit log is their only accountability home. One audit row
|
|
81
|
+
// per field that actually changed (docs/AUDIT.md).
|
|
82
|
+
const currentPath = originalData.path;
|
|
83
|
+
const currentLocales = originalData.availableLocales ?? [];
|
|
86
84
|
const availableLocalesWritten = params.availableLocales !== undefined;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
const audit = requireAuditCapability(db);
|
|
86
|
+
const actor = auditActor(ctx);
|
|
87
|
+
await audit.withTransaction(async () => {
|
|
88
|
+
if (pathForCommand !== undefined) {
|
|
89
|
+
await db.commands.documents
|
|
90
|
+
.updateDocumentPath({
|
|
91
|
+
documentId: params.documentId,
|
|
92
|
+
collectionId,
|
|
93
|
+
locale: sourceLocale,
|
|
94
|
+
path: pathForCommand,
|
|
95
|
+
})
|
|
96
|
+
.catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
|
|
97
|
+
if (pathForCommand !== currentPath) {
|
|
98
|
+
await audit.append({
|
|
99
|
+
documentId: params.documentId,
|
|
100
|
+
collectionId,
|
|
101
|
+
actorId: actor.actorId,
|
|
102
|
+
actorRealm: actor.actorRealm,
|
|
103
|
+
action: AUDIT_ACTIONS.pathChanged,
|
|
104
|
+
field: 'path',
|
|
105
|
+
before: currentPath ?? null,
|
|
106
|
+
after: pathForCommand,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Advertised locales: rewrite the document-grain set wholesale.
|
|
111
|
+
if (params.availableLocales !== undefined) {
|
|
112
|
+
await db.commands.documents.setDocumentAvailableLocales({
|
|
113
|
+
documentId: params.documentId,
|
|
114
|
+
collectionId,
|
|
115
|
+
availableLocales: params.availableLocales,
|
|
116
|
+
});
|
|
117
|
+
if (!sameLocaleSet(currentLocales, params.availableLocales)) {
|
|
118
|
+
await audit.append({
|
|
119
|
+
documentId: params.documentId,
|
|
120
|
+
collectionId,
|
|
121
|
+
actorId: actor.actorId,
|
|
122
|
+
actorRealm: actor.actorRealm,
|
|
123
|
+
action: AUDIT_ACTIONS.localesChanged,
|
|
124
|
+
field: 'availableLocales',
|
|
125
|
+
before: currentLocales,
|
|
126
|
+
after: params.availableLocales,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
94
131
|
return {
|
|
95
132
|
documentId: params.documentId,
|
|
96
133
|
path: pathForCommand,
|
|
@@ -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, ERR_PATH_CONFLICT, ErrorCodes } from '../lib/errors.js';
|
|
11
|
-
import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle/index.js';
|
|
11
|
+
import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Fixtures / Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +36,11 @@ function createMockDb() {
|
|
|
36
36
|
const getDocumentById = vi.fn().mockResolvedValue(null);
|
|
37
37
|
const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
|
|
38
38
|
const getCurrentPath = vi.fn().mockResolvedValue('current-path');
|
|
39
|
+
// Audit capability (docs/AUDIT.md — W2). `withTransaction` is a passthrough
|
|
40
|
+
// in unit tests (runs the unit of work immediately, no real tx); `append`
|
|
41
|
+
// records the calls so write-point tests can assert the audit rows emitted.
|
|
42
|
+
const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
|
|
43
|
+
const withTransaction = vi.fn(async (fn) => fn());
|
|
39
44
|
const db = {
|
|
40
45
|
commands: {
|
|
41
46
|
collections: {
|
|
@@ -45,8 +50,8 @@ function createMockDb() {
|
|
|
45
50
|
},
|
|
46
51
|
documents: {
|
|
47
52
|
createDocumentVersion,
|
|
48
|
-
updateDocumentPath: vi.fn(),
|
|
49
|
-
setDocumentAvailableLocales: vi.fn(),
|
|
53
|
+
updateDocumentPath: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
setDocumentAvailableLocales: vi.fn().mockResolvedValue(undefined),
|
|
50
55
|
setDocumentStatus,
|
|
51
56
|
archivePublishedVersions,
|
|
52
57
|
softDeleteDocument,
|
|
@@ -57,7 +62,9 @@ function createMockDb() {
|
|
|
57
62
|
ensureCounterGroup: vi.fn(),
|
|
58
63
|
nextCounterValue: vi.fn(),
|
|
59
64
|
},
|
|
65
|
+
audit: { append: auditAppend },
|
|
60
66
|
},
|
|
67
|
+
withTransaction: withTransaction,
|
|
61
68
|
queries: {
|
|
62
69
|
collections: {
|
|
63
70
|
getAllCollections: vi.fn(),
|
|
@@ -92,6 +99,8 @@ function createMockDb() {
|
|
|
92
99
|
getDocumentById,
|
|
93
100
|
getCurrentVersionMetadata,
|
|
94
101
|
getCurrentPath,
|
|
102
|
+
auditAppend,
|
|
103
|
+
withTransaction,
|
|
95
104
|
};
|
|
96
105
|
}
|
|
97
106
|
const noopLogger = {
|
|
@@ -683,6 +692,24 @@ describe('Document lifecycle service', () => {
|
|
|
683
692
|
expect(result.previousStatus).toBe('draft');
|
|
684
693
|
expect(result.newStatus).toBe('published');
|
|
685
694
|
});
|
|
695
|
+
it('records a document.status.changed audit row atomically (from → to)', async () => {
|
|
696
|
+
const { db, getCurrentVersionMetadata, auditAppend, withTransaction } = createMockDb();
|
|
697
|
+
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
698
|
+
const ctx = buildCtx(db);
|
|
699
|
+
await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
|
|
700
|
+
// The mutation + audit row run inside one withTransaction (docs/AUDIT.md).
|
|
701
|
+
expect(withTransaction).toHaveBeenCalledOnce();
|
|
702
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
703
|
+
documentId: 'doc-1',
|
|
704
|
+
collectionId: 'col-1',
|
|
705
|
+
actorId: TEST_ACTOR_ID,
|
|
706
|
+
actorRealm: 'admin',
|
|
707
|
+
action: 'document.status.changed',
|
|
708
|
+
field: 'status',
|
|
709
|
+
before: 'draft',
|
|
710
|
+
after: 'published',
|
|
711
|
+
}));
|
|
712
|
+
});
|
|
686
713
|
it('throws ERR_NOT_FOUND when document is missing', async () => {
|
|
687
714
|
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
688
715
|
getCurrentVersionMetadata.mockResolvedValue(null);
|
|
@@ -874,6 +901,76 @@ describe('Document lifecycle service', () => {
|
|
|
874
901
|
expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
875
902
|
expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
876
903
|
});
|
|
904
|
+
it('records a document.deleted audit row atomically with the soft-delete', async () => {
|
|
905
|
+
const { db, getDocumentById, softDeleteDocument, auditAppend, withTransaction } = createMockDb();
|
|
906
|
+
getDocumentById.mockResolvedValue({
|
|
907
|
+
document_version_id: 'ver-1',
|
|
908
|
+
document_id: 'doc-1',
|
|
909
|
+
path: 'doc-to-delete',
|
|
910
|
+
fields: {},
|
|
911
|
+
});
|
|
912
|
+
const ctx = buildCtx(db);
|
|
913
|
+
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
914
|
+
expect(withTransaction).toHaveBeenCalledOnce();
|
|
915
|
+
expect(softDeleteDocument).toHaveBeenCalledWith({ document_id: 'doc-1' });
|
|
916
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
917
|
+
documentId: 'doc-1',
|
|
918
|
+
collectionId: 'col-1',
|
|
919
|
+
actorRealm: 'admin',
|
|
920
|
+
action: 'document.deleted',
|
|
921
|
+
}));
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
// -----------------------------------------------------------------------
|
|
925
|
+
// updateDocumentSystemFields (audited, non-versioned)
|
|
926
|
+
// -----------------------------------------------------------------------
|
|
927
|
+
describe('updateDocumentSystemFields', () => {
|
|
928
|
+
function setupDoc(getDocumentById, overrides) {
|
|
929
|
+
getDocumentById.mockResolvedValue({
|
|
930
|
+
document_version_id: 'ver-1',
|
|
931
|
+
document_id: 'doc-1',
|
|
932
|
+
path: 'old-slug',
|
|
933
|
+
source_locale: 'en',
|
|
934
|
+
availableLocales: ['en'],
|
|
935
|
+
fields: {},
|
|
936
|
+
...overrides,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
it('records document.path.changed when the path actually changes', async () => {
|
|
940
|
+
const { db, getDocumentById, auditAppend, withTransaction } = createMockDb();
|
|
941
|
+
setupDoc(getDocumentById);
|
|
942
|
+
const ctx = buildCtx(db);
|
|
943
|
+
await updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'new-slug' });
|
|
944
|
+
expect(withTransaction).toHaveBeenCalledOnce();
|
|
945
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
946
|
+
action: 'document.path.changed',
|
|
947
|
+
field: 'path',
|
|
948
|
+
before: 'old-slug',
|
|
949
|
+
after: 'new-slug',
|
|
950
|
+
}));
|
|
951
|
+
});
|
|
952
|
+
it('records no audit row when the path is unchanged', async () => {
|
|
953
|
+
const { db, getDocumentById, auditAppend } = createMockDb();
|
|
954
|
+
setupDoc(getDocumentById);
|
|
955
|
+
const ctx = buildCtx(db);
|
|
956
|
+
await updateDocumentSystemFields(ctx, { documentId: 'doc-1', path: 'old-slug' });
|
|
957
|
+
expect(auditAppend).not.toHaveBeenCalled();
|
|
958
|
+
});
|
|
959
|
+
it('records document.locales.changed with before/after sets', async () => {
|
|
960
|
+
const { db, getDocumentById, auditAppend } = createMockDb();
|
|
961
|
+
setupDoc(getDocumentById);
|
|
962
|
+
const ctx = buildCtx(db);
|
|
963
|
+
await updateDocumentSystemFields(ctx, {
|
|
964
|
+
documentId: 'doc-1',
|
|
965
|
+
availableLocales: ['en', 'fr'],
|
|
966
|
+
});
|
|
967
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
968
|
+
action: 'document.locales.changed',
|
|
969
|
+
field: 'availableLocales',
|
|
970
|
+
before: ['en'],
|
|
971
|
+
after: ['en', 'fr'],
|
|
972
|
+
}));
|
|
973
|
+
});
|
|
877
974
|
});
|
|
878
975
|
// -----------------------------------------------------------------------
|
|
879
976
|
// restoreDocumentVersion
|
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": "3.
|
|
5
|
+
"version": "3.10.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"sharp": "^0.34.5",
|
|
80
80
|
"uuid": "^14.0.0",
|
|
81
81
|
"zod": "^4.4.3",
|
|
82
|
-
"@byline/auth": "3.
|
|
82
|
+
"@byline/auth": "3.10.1"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|