@byline/core 3.8.0 → 3.10.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.
@@ -203,11 +203,39 @@ 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
  };
221
+ /**
222
+ * Optional capability: run `fn` inside a single database transaction so the
223
+ * writes it performs commit or roll back atomically. The adapter propagates
224
+ * the transaction to every `commands.*` call made within `fn` (see
225
+ * docs/TRANSACTIONS.md — AsyncLocalStorage propagation), so a service can
226
+ * compose multiple commands into one unit of work without threading a
227
+ * transaction handle through their signatures.
228
+ *
229
+ * **Loud-failure contract.** Optional because not every adapter can provide
230
+ * interactive transactions — a pure HTTP-gateway serverless driver (Neon
231
+ * HTTP, Cloudflare D1, …) cannot. An adapter that cannot **must omit this
232
+ * method** (or implement it to throw); a consumer that requires atomicity
233
+ * (e.g. the audit log) MUST assert its presence and throw — never silently
234
+ * run non-atomically, which would defeat the very guarantee it provides. See
235
+ * docs/TRANSACTIONS.md ("Serverless / HTTP-gateway databases — the contract
236
+ * seam").
237
+ */
238
+ withTransaction?: <T>(fn: () => Promise<T>) => Promise<T>;
211
239
  /**
212
240
  * Optional maintenance: stamp `source_locale` (the per-document content
213
241
  * anchor) on documents created before the column existed, setting NULL rows
@@ -221,6 +249,81 @@ export interface IDbAdapter {
221
249
  rowsUpdated: number;
222
250
  }>;
223
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
+ }
224
327
  /**
225
328
  * Adapter capability for the shared-pool counter mechanism backing the
226
329
  * `counter` field type. See `packages/core/src/@types/field-types.ts`
@@ -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;
@@ -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
- const deletedVersionCount = await db.commands.documents.softDeleteDocument({
96
- document_id: params.documentId,
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) {
@@ -17,10 +17,18 @@ import type { SlugifierFn } from '../../utils/slugify.js';
17
17
  import type { DocumentLifecycleContext } from './context.js';
18
18
  /**
19
19
  * The acting user's id for the version audit trail (`created_by` on
20
- * `byline_document_versions`). `undefined` for internal-tooling callers
21
- * without a `requestContext` (seeds, migrations — the documented escape
22
- * hatch), which persists as NULL. Both actor realms (`AdminAuth`,
23
- * `UserAuth`) carry `id`. See docs/AUDIT.md Workstream 1.
20
+ * `byline_document_versions`).
21
+ *
22
+ * Returns the id only when it is a real **persisted user id** — i.e. a UUID.
23
+ * Synthetic actors used by scripts, seeds, and tests (e.g.
24
+ * `createSuperAdminContext({ id: 'import-docs-script' })`, or the default
25
+ * `'super-admin'`) are **not** users: their non-UUID ids would be rejected by
26
+ * the `uuid` column outright, and the correct audit value for a system/tooling
27
+ * write is NULL regardless. So a non-UUID id — and a missing `requestContext`
28
+ * (the seeds/migrations escape hatch) — both yield `undefined` → NULL
29
+ * `created_by`, which the history strip renders as "unknown". Real
30
+ * `AdminAuth` / `UserAuth` actors always carry UUID ids, so their attribution
31
+ * is unaffected. See docs/AUDIT.md — Workstream 1.
24
32
  */
25
33
  export declare function actorId(ctx: DocumentLifecycleContext): string | undefined;
26
34
  /**
@@ -17,15 +17,29 @@ import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
17
17
  import { generateKeyBetween } from '../../lib/fractional-index.js';
18
18
  import { createReadContext } from '../populate.js';
19
19
  import { embedRichTextFields } from '../richtext-embed.js';
20
+ /**
21
+ * Matches a canonical UUID (any version). Real admin / end-user actors carry
22
+ * UUID ids (`uuidv7`); synthetic actors do not.
23
+ */
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
25
  /**
21
26
  * The acting user's id for the version audit trail (`created_by` on
22
- * `byline_document_versions`). `undefined` for internal-tooling callers
23
- * without a `requestContext` (seeds, migrations — the documented escape
24
- * hatch), which persists as NULL. Both actor realms (`AdminAuth`,
25
- * `UserAuth`) carry `id`. See docs/AUDIT.md Workstream 1.
27
+ * `byline_document_versions`).
28
+ *
29
+ * Returns the id only when it is a real **persisted user id** — i.e. a UUID.
30
+ * Synthetic actors used by scripts, seeds, and tests (e.g.
31
+ * `createSuperAdminContext({ id: 'import-docs-script' })`, or the default
32
+ * `'super-admin'`) are **not** users: their non-UUID ids would be rejected by
33
+ * the `uuid` column outright, and the correct audit value for a system/tooling
34
+ * write is NULL regardless. So a non-UUID id — and a missing `requestContext`
35
+ * (the seeds/migrations escape hatch) — both yield `undefined` → NULL
36
+ * `created_by`, which the history strip renders as "unknown". Real
37
+ * `AdminAuth` / `UserAuth` actors always carry UUID ids, so their attribution
38
+ * is unaffected. See docs/AUDIT.md — Workstream 1.
26
39
  */
27
40
  export function actorId(ctx) {
28
- return ctx.requestContext?.actor?.id;
41
+ const id = ctx.requestContext?.actor?.id;
42
+ return id != null && UUID_RE.test(id) ? id : undefined;
29
43
  }
30
44
  /**
31
45
  * Safely invoke an optional hook slot, awaiting the result if it returns a
@@ -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
- await db.commands.documents.setDocumentStatus({
90
- document_version_id: documentVersionId,
91
- status: params.nextStatus,
92
- });
93
- // 5. Auto-archive previous published versions.
94
- if (params.nextStatus === 'published') {
95
- await db.commands.documents.archivePublishedVersions({
96
- document_id: params.documentId,
97
- excludeVersionId: documentVersionId,
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 job of the (planned) document-grain audit log.
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 job of the (planned) document-grain audit log.
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
- if (pathForCommand !== undefined) {
76
- await db.commands.documents
77
- .updateDocumentPath({
78
- documentId: params.documentId,
79
- collectionId,
80
- locale: sourceLocale,
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
- if (params.availableLocales !== undefined) {
88
- await db.commands.documents.setDocumentAvailableLocales({
89
- documentId: params.documentId,
90
- collectionId,
91
- availableLocales: params.availableLocales,
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 = {
@@ -104,6 +113,10 @@ const noopLogger = {
104
113
  trace: vi.fn(),
105
114
  silent: vi.fn(),
106
115
  };
116
+ // A real persisted-user id is a UUID; `actorId()` only attributes UUIDs (see
117
+ // the regression note below). Use a valid UUID so the default context's
118
+ // writes are attributed.
119
+ const TEST_ACTOR_ID = '01901234-0000-7000-8000-000000000001';
107
120
  function buildCtx(db, definition = minimalCollection) {
108
121
  return {
109
122
  db,
@@ -117,7 +130,7 @@ function buildCtx(db, definition = minimalCollection) {
117
130
  // tests do not have to care about ability enforcement. The dedicated
118
131
  // "enforcement" block below covers the missing-context / missing-ability
119
132
  // negative cases.
120
- requestContext: createSuperAdminContext({ id: 'test-super-admin' }),
133
+ requestContext: createSuperAdminContext({ id: TEST_ACTOR_ID }),
121
134
  };
122
135
  }
123
136
  // ---------------------------------------------------------------------------
@@ -148,7 +161,18 @@ describe('Document lifecycle service', () => {
148
161
  });
149
162
  // Audit contract (docs/AUDIT.md — W1): every version row
150
163
  // records the actor that created it.
151
- expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe('test-super-admin');
164
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
165
+ });
166
+ it('writes NULL createdBy for a synthetic (non-UUID) script/seed actor', async () => {
167
+ // Regression guard (v3.8.0): a synthetic super-admin id such as
168
+ // `import-docs-script` is not a real user and is not a UUID — writing
169
+ // it into the `created_by` UUID column crashed every import/seed. Such
170
+ // system/tooling writes must attribute to NULL, not the synthetic id.
171
+ const { db, createDocumentVersion } = createMockDb();
172
+ const ctx = buildCtx(db);
173
+ ctx.requestContext = createSuperAdminContext({ id: 'import-docs-script' });
174
+ await createDocument(ctx, { data: { title: 'Hello' }, locale: 'en' });
175
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBeUndefined();
152
176
  });
153
177
  it('invokes beforeCreate and afterCreate hooks in order', async () => {
154
178
  const callOrder = [];
@@ -326,7 +350,7 @@ describe('Document lifecycle service', () => {
326
350
  documentId: 'doc-1',
327
351
  data: { title: 'New' },
328
352
  });
329
- expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe('test-super-admin');
353
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
330
354
  });
331
355
  it('fetches the original before calling hooks', async () => {
332
356
  const { db, getDocumentById, createDocumentVersion } = createMockDb();
@@ -668,6 +692,24 @@ describe('Document lifecycle service', () => {
668
692
  expect(result.previousStatus).toBe('draft');
669
693
  expect(result.newStatus).toBe('published');
670
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
+ });
671
713
  it('throws ERR_NOT_FOUND when document is missing', async () => {
672
714
  const { db, getCurrentVersionMetadata } = createMockDb();
673
715
  getCurrentVersionMetadata.mockResolvedValue(null);
@@ -859,6 +901,76 @@ describe('Document lifecycle service', () => {
859
901
  expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
860
902
  expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
861
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
+ });
862
974
  });
863
975
  // -----------------------------------------------------------------------
864
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.8.0",
5
+ "version": "3.10.0",
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.8.0"
82
+ "@byline/auth": "3.10.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",