@byline/core 3.18.0 → 3.19.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.
@@ -132,6 +132,52 @@ export interface UploadConfig {
132
132
  * @see UploadHooks
133
133
  */
134
134
  hooks?: UploadHooks | UploadHooksLoader;
135
+ /**
136
+ * Form-value paths the admin upload executor resolves at submit time and
137
+ * posts alongside the file, so server-side `beforeStore` / `afterStore`
138
+ * hooks receive document context in `ctx.fields` (always strings —
139
+ * multipart form values arrive untyped).
140
+ *
141
+ * Paths use filesystem-style resolution relative to the upload field's
142
+ * containing scope. For a field at `files[2].filesGroup.publicationFile`:
143
+ *
144
+ * - `'language'` (bare / `./`) → sibling: `files[2].filesGroup.language`
145
+ * - `'../label'` → one level up: `files[2].label`
146
+ * - `'/serialNumber'` → document root: `serialNumber`
147
+ *
148
+ * Each resolved value is posted under the path's **leaf name** (`language`,
149
+ * `serialNumber`); when two context paths share a leaf name the later
150
+ * declaration wins. Serialisation: strings pass through; numbers/booleans
151
+ * are stringified; relation envelopes post their `targetDocumentId`
152
+ * (hasMany: comma-joined); `null`/`undefined` values are omitted; anything
153
+ * else is JSON-stringified.
154
+ *
155
+ * Independent of `context`, the executor always posts `documentId` (edit
156
+ * mode only — absent while the document is unsaved) and `fieldPath` (the
157
+ * full form path of the upload field, e.g.
158
+ * `files[2].filesGroup.publicationFile`).
159
+ *
160
+ * Hooks must treat all of these as **client-supplied claims** and re-verify
161
+ * anything security- or integrity-relevant server-side (e.g. fetch the
162
+ * document by `documentId` rather than trusting a posted serial).
163
+ */
164
+ context?: string[];
165
+ /**
166
+ * When `true`, the admin renders a "save this document first" notice in
167
+ * place of the upload widget until the document has been persisted (i.e.
168
+ * the form is in edit mode with a document id). Existing stored values
169
+ * still render normally — only the *upload* affordance is gated.
170
+ *
171
+ * Use this when server-side upload hooks depend on state that exists only
172
+ * after the first save — allocator-assigned `counter` fields, the document
173
+ * id itself — e.g. a `beforeStore` hook that derives the storage key from
174
+ * the document's serial number.
175
+ *
176
+ * Admin-side UX only: API callers can still upload without a document.
177
+ * Enforce the invariant server-side in a `beforeStore` hook (reject with
178
+ * `{ error }` when the required context is missing).
179
+ */
180
+ requireSavedDocument?: boolean;
135
181
  }
136
182
  /**
137
183
  * The three status names that every workflow must contain.
@@ -520,7 +566,19 @@ export interface TreeChangeContext {
520
566
  *
521
567
  * - Rename the file by returning a string or `{ filename }`. The
522
568
  * override is threaded into `storage.upload(...)`, so generated
523
- * image variants automatically inherit the new prefix.
569
+ * image variants automatically inherit the new prefix. The storage
570
+ * provider still derives the final key (e.g. local storage prefixes
571
+ * `<collection>/<uuid>-` for collision avoidance).
572
+ * - Take **full control of the storage key** by returning
573
+ * `{ storagePath }` — a fully-qualified, POSIX-style path (no
574
+ * leading slash) that is threaded into `storage.upload(...)` as
575
+ * `targetStoragePath` and written **verbatim**: no UUID prefix, no
576
+ * collection namespace, no provider rewriting. The hook assumes
577
+ * responsibility for sanitisation and collision avoidance (see
578
+ * `UploadFileOptions.targetStoragePath`). Generated image variants
579
+ * derive their sibling paths from the custom original. When
580
+ * `storagePath` is returned without `filename`, the stored
581
+ * `filename` defaults to the path's basename.
524
582
  * - Reject the upload by returning `{ error }`. Surfaces as
525
583
  * `ERR_VALIDATION` with the supplied message; no file is written,
526
584
  * no variants are generated, no document is created, no later
@@ -529,7 +587,8 @@ export interface TreeChangeContext {
529
587
  *
530
588
  * When configured as an array, hooks fold: each function receives the
531
589
  * filename returned by the previous function (or the original sanitised
532
- * filename if the previous returned `void`).
590
+ * filename if the previous returned `void`), and `ctx.storagePath`
591
+ * carries the most recent explicit storage-path override (if any).
533
592
  */
534
593
  export interface BeforeStoreContext {
535
594
  /** Name of the image/file field receiving this upload. */
@@ -538,6 +597,12 @@ export interface BeforeStoreContext {
538
597
  field: ImageField | FileField;
539
598
  /** Sanitised default filename. Hooks may override. */
540
599
  filename: string;
600
+ /**
601
+ * Explicit storage-path override set by an earlier hook in the chain
602
+ * (via `{ storagePath }`), if any. `undefined` means the storage
603
+ * provider will derive the key itself from `filename` + `collection`.
604
+ */
605
+ storagePath?: string;
541
606
  mimeType: string;
542
607
  fileSize: number;
543
608
  /**
@@ -550,22 +615,37 @@ export interface BeforeStoreContext {
550
615
  collectionPath: string;
551
616
  /** Authenticated request context. `actor.id`, `actor.tenantId`, etc. for prefixing. */
552
617
  requestContext: RequestContext;
618
+ /**
619
+ * The storage provider this upload will be written to (the field's
620
+ * `upload.storage` or the site-wide default). Lets hooks feature-detect
621
+ * and use the optional provider capabilities — e.g. `storage.exists?.(key)`
622
+ * to collision-check an explicit `{ storagePath }` before claiming it.
623
+ */
624
+ storage: IStorageProvider;
553
625
  }
554
626
  /**
555
627
  * Result returned by a `beforeStore` hook.
556
628
  *
557
629
  * - `string` → override filename (shorthand).
558
- * - `{ filename }` → override filename (object form).
630
+ * - `{ filename }` → override filename (object form). The storage
631
+ * provider still derives the final key.
632
+ * - `{ storagePath }` → take full control of the storage key: written
633
+ * verbatim via `UploadFileOptions.targetStoragePath`
634
+ * (no UUID prefix / provider rewriting). May be
635
+ * combined with `filename`; without it, the stored
636
+ * filename defaults to the path's basename.
559
637
  * - `{ error }` → reject the upload; surfaces as
560
638
  * `ERR_VALIDATION`. Short-circuits the chain.
561
639
  * - `void` / undefined → keep current defaults.
562
640
  */
563
641
  export type BeforeStoreResult = string | {
564
642
  filename?: string;
643
+ storagePath?: string;
565
644
  error?: undefined;
566
645
  } | {
567
646
  error: string;
568
647
  filename?: undefined;
648
+ storagePath?: undefined;
569
649
  } | void;
570
650
  /**
571
651
  * A `beforeStore` hook function. Async-capable.
@@ -595,6 +675,11 @@ export interface AfterStoreContext {
595
675
  fields: Record<string, string>;
596
676
  collectionPath: string;
597
677
  requestContext: RequestContext;
678
+ /**
679
+ * The storage provider the file was written to. See
680
+ * {@link BeforeStoreContext.storage}.
681
+ */
682
+ storage: IStorageProvider;
598
683
  }
599
684
  /** An `afterStore` hook function. Async-capable. */
600
685
  export type AfterStoreHookFn = (ctx: AfterStoreContext) => void | Promise<void>;
@@ -424,6 +424,32 @@ export interface ICounterCommands {
424
424
  * and deletes. The facet-URL use case does not require gapless IDs.
425
425
  */
426
426
  nextCounterValue(groupName: string): Promise<number>;
427
+ /**
428
+ * Atomically allocate the next value from a **runtime-scoped** counter
429
+ * group, self-registering the group on first use.
430
+ *
431
+ * The schema-declared counter mechanism above is deliberately static:
432
+ * groups come from `CounterField.group` strings, are discovered and
433
+ * registered once at boot, and `nextCounterValue` throws for anything
434
+ * unregistered — that honesty catches configuration bugs. But some
435
+ * sequences are keyed on *data* that only exists at runtime — e.g. a
436
+ * per-document sub-sequence like "files attached to publication X",
437
+ * where each value must be unique and never reused within that scope
438
+ * (`publications:<documentId>:files` → 1, 2, 3, …). Those scopes cannot
439
+ * be known at boot, so this method performs an idempotent
440
+ * ensure-then-allocate instead of throwing on the first use.
441
+ *
442
+ * Semantics are otherwise identical to `nextCounterValue`: values are
443
+ * monotonic per scope and never reused (a Postgres SEQUENCE per scope
444
+ * in the Postgres adapter); gaps are expected on rolled-back or
445
+ * abandoned work.
446
+ *
447
+ * Callers own the scope-name namespace. Use a stable, collision-proof
448
+ * convention — `'<collectionPath>:<documentId>:<purpose>'` is
449
+ * recommended — and never derive scope names from mutable data
450
+ * (renaming a scope starts a fresh sequence at 1).
451
+ */
452
+ nextScopedCounterValue(scopeName: string): Promise<number>;
427
453
  }
428
454
  export interface ICollectionCommands {
429
455
  /**
@@ -97,4 +97,25 @@ export interface IStorageProvider {
97
97
  * signing logic here.
98
98
  */
99
99
  getUrl(storagePath: string): string;
100
+ /**
101
+ * Move (re-key) a previously stored file from one `storage_path` to
102
+ * another, returning the new location metadata. The destination path is
103
+ * written verbatim — same contract as `UploadFileOptions.targetStoragePath`
104
+ * (POSIX-style, no leading slash; the caller owns sanitisation and
105
+ * collision avoidance). Throws if the source does not exist.
106
+ *
107
+ * **Optional capability** — both first-party providers (`storage-local`,
108
+ * `storage-s3`) implement it, but custom providers may not. Callers must
109
+ * feature-detect (`if (storage.move) …`) before relying on it.
110
+ */
111
+ move?(fromPath: string, toPath: string): Promise<StoredFileLocation>;
112
+ /**
113
+ * Report whether a file exists at the given `storage_path`. Useful for
114
+ * collision checks when callers (e.g. `beforeStore` hooks assigning
115
+ * explicit storage keys via `{ storagePath }`) need to guarantee a key
116
+ * is free before claiming it.
117
+ *
118
+ * **Optional capability** — see `move`.
119
+ */
120
+ exists?(storagePath: string): Promise<boolean>;
100
121
  }
@@ -20,6 +20,7 @@ function makeCounters(start = 1) {
20
20
  calls.push(group);
21
21
  return next++;
22
22
  }),
23
+ nextScopedCounterValue: vi.fn(),
23
24
  },
24
25
  calls,
25
26
  };
@@ -52,6 +52,7 @@ function createMockDb(options) {
52
52
  counters: {
53
53
  ensureCounterGroup: vi.fn(fail),
54
54
  nextCounterValue: vi.fn(fail),
55
+ nextScopedCounterValue: vi.fn(fail),
55
56
  },
56
57
  },
57
58
  queries: {
@@ -203,6 +204,7 @@ describe('ensureCollections', () => {
203
204
  counters: {
204
205
  ensureCounterGroup: vi.fn(),
205
206
  nextCounterValue: vi.fn(),
207
+ nextScopedCounterValue: vi.fn(),
206
208
  },
207
209
  },
208
210
  queries: {
@@ -37,6 +37,7 @@ function makeAdapter(options) {
37
37
  counters: {
38
38
  ensureCounterGroup,
39
39
  nextCounterValue: vi.fn(fail),
40
+ nextScopedCounterValue: vi.fn(fail),
40
41
  },
41
42
  },
42
43
  queries: {
@@ -63,6 +63,7 @@ function createMockDb() {
63
63
  counters: {
64
64
  ensureCounterGroup: vi.fn(),
65
65
  nextCounterValue: vi.fn(),
66
+ nextScopedCounterValue: vi.fn(),
66
67
  },
67
68
  audit: { append: auditAppend },
68
69
  },
@@ -69,18 +69,22 @@ function normalizeUploadHook(hook) {
69
69
  }
70
70
  /**
71
71
  * Run the `beforeStore` chain. Each function receives the previous
72
- * function's filename override (fold). A function may:
72
+ * function's filename / storage-path overrides (fold). A function may:
73
73
  *
74
74
  * - return a string or `{ filename }` to substitute a new filename;
75
+ * - return `{ storagePath }` to take full control of the storage key
76
+ * (bypasses provider key derivation — no UUID prefix). When set
77
+ * without `filename`, the filename defaults to the path's basename;
75
78
  * - return `{ error }` to short-circuit with `ERR_VALIDATION`;
76
- * - return `void` / `undefined` to leave the filename unchanged.
79
+ * - return `void` / `undefined` to leave current values unchanged.
77
80
  *
78
- * Returns the resolved filename.
81
+ * Returns the resolved filename and optional storage path.
79
82
  */
80
83
  async function runBeforeStoreChain(hooks, ctx, logger) {
81
84
  let effective = ctx.filename;
85
+ let effectiveStoragePath;
82
86
  for (const fn of hooks) {
83
- const result = await fn({ ...ctx, filename: effective });
87
+ const result = await fn({ ...ctx, filename: effective, storagePath: effectiveStoragePath });
84
88
  if (result == null)
85
89
  continue;
86
90
  if (typeof result === 'string') {
@@ -96,14 +100,35 @@ async function runBeforeStoreChain(hooks, ctx, logger) {
96
100
  details: { collectionPath: ctx.collectionPath, fieldName: ctx.fieldName },
97
101
  }, runBeforeStoreChain).log(logger);
98
102
  }
103
+ let filenameOverridden = false;
99
104
  if ('filename' in result && typeof result.filename === 'string') {
100
105
  const trimmed = result.filename.trim();
101
- if (trimmed)
106
+ if (trimmed) {
102
107
  effective = trimmed;
108
+ filenameOverridden = true;
109
+ }
110
+ }
111
+ if ('storagePath' in result && typeof result.storagePath === 'string') {
112
+ // Normalise: POSIX-style, no leading slash (matches the
113
+ // UploadFileOptions.targetStoragePath contract).
114
+ const trimmed = result.storagePath.trim().replace(/^\/+/, '');
115
+ if (trimmed) {
116
+ effectiveStoragePath = trimmed;
117
+ // Keep the stored filename in sync with the explicit key
118
+ // unless the hook also set filename itself.
119
+ if (!filenameOverridden) {
120
+ effective = posixBasename(trimmed);
121
+ }
122
+ }
103
123
  }
104
124
  }
105
125
  }
106
- return effective;
126
+ return { filename: effective, storagePath: effectiveStoragePath };
127
+ }
128
+ /** Last path segment of a POSIX-style storage path. */
129
+ function posixBasename(storagePath) {
130
+ const idx = storagePath.lastIndexOf('/');
131
+ return idx === -1 ? storagePath : storagePath.slice(idx + 1);
107
132
  }
108
133
  export async function uploadField(ctx, params) {
109
134
  return withLogContext({ domain: 'services', module: 'upload', function: 'uploadField' }, async () => {
@@ -163,7 +188,7 @@ export async function uploadField(ctx, params) {
163
188
  // graphs out of the client bundle; the inline form returns as-is.
164
189
  const uploadHooks = await resolveUploadHooks(upload.hooks);
165
190
  const beforeStoreHooks = normalizeUploadHook(uploadHooks?.beforeStore);
166
- const effectiveFilename = await runBeforeStoreChain(beforeStoreHooks, {
191
+ const { filename: effectiveFilename, storagePath: effectiveStoragePath } = await runBeforeStoreChain(beforeStoreHooks, {
167
192
  fieldName,
168
193
  field,
169
194
  filename: sanitised,
@@ -176,9 +201,14 @@ export async function uploadField(ctx, params) {
176
201
  requestId: '',
177
202
  readMode: 'any',
178
203
  },
204
+ storage,
179
205
  }, logger);
180
206
  // -- Storage write. Filename is the post-hook value, so generated
181
- // variants automatically inherit the new prefix.
207
+ // variants automatically inherit the new prefix. An explicit
208
+ // `storagePath` from the chain is threaded through as
209
+ // `targetStoragePath`, which providers write verbatim (no UUID
210
+ // prefix / key derivation) — variants still derive sibling paths
211
+ // from the resulting `storedFile.storagePath`.
182
212
  let storedFile;
183
213
  try {
184
214
  storedFile = await storage.upload(buffer, {
@@ -186,6 +216,7 @@ export async function uploadField(ctx, params) {
186
216
  mimeType,
187
217
  size: fileSize,
188
218
  collection: collectionPath,
219
+ ...(effectiveStoragePath !== undefined && { targetStoragePath: effectiveStoragePath }),
189
220
  });
190
221
  }
191
222
  catch (err) {
@@ -261,6 +292,7 @@ export async function uploadField(ctx, params) {
261
292
  requestId: '',
262
293
  readMode: 'any',
263
294
  },
295
+ storage,
264
296
  };
265
297
  await fn(afterCtx);
266
298
  }
@@ -66,6 +66,7 @@ function createMockDb() {
66
66
  counters: {
67
67
  ensureCounterGroup: vi.fn(),
68
68
  nextCounterValue: vi.fn(),
69
+ nextScopedCounterValue: vi.fn(),
69
70
  },
70
71
  },
71
72
  queries: {
@@ -248,6 +249,84 @@ describe('uploadField service', () => {
248
249
  });
249
250
  expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({ filename: 'PUB-42-tenant-hero.png' }));
250
251
  });
252
+ it('beforeStore { storagePath } threads targetStoragePath verbatim and derives the filename from its basename', async () => {
253
+ const definition = withFieldUpload(uploadCollection, 'image', (f) => {
254
+ f.upload.hooks = {
255
+ beforeStore: () => ({ storagePath: 'publications/forru-0000447-0001-en.png' }),
256
+ };
257
+ });
258
+ const { ctx, upload } = buildCtx({ definition });
259
+ const result = await uploadField(ctx, {
260
+ buffer: Buffer.from('png'),
261
+ originalFilename: 'hero.png',
262
+ mimeType: 'image/png',
263
+ fileSize: 3,
264
+ shouldCreateDocument: false,
265
+ });
266
+ // Explicit key is handed to the provider verbatim — no UUID prefix,
267
+ // no collection-derived key.
268
+ expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
269
+ targetStoragePath: 'publications/forru-0000447-0001-en.png',
270
+ filename: 'forru-0000447-0001-en.png',
271
+ }));
272
+ // Stored filename follows the explicit key's basename.
273
+ expect(result.storedFile.filename).toBe('forru-0000447-0001-en.png');
274
+ });
275
+ it('beforeStore { storagePath, filename } honours both; a leading slash is stripped from the key', async () => {
276
+ const definition = withFieldUpload(uploadCollection, 'image', (f) => {
277
+ f.upload.hooks = {
278
+ beforeStore: () => ({
279
+ storagePath: '/publications/forru-0000447-0002-en.png',
280
+ filename: 'display-name.png',
281
+ }),
282
+ };
283
+ });
284
+ const { ctx, upload } = buildCtx({ definition });
285
+ const result = await uploadField(ctx, {
286
+ buffer: Buffer.from('png'),
287
+ originalFilename: 'hero.png',
288
+ mimeType: 'image/png',
289
+ fileSize: 3,
290
+ shouldCreateDocument: false,
291
+ });
292
+ expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({
293
+ targetStoragePath: 'publications/forru-0000447-0002-en.png',
294
+ filename: 'display-name.png',
295
+ }));
296
+ expect(result.storedFile.filename).toBe('display-name.png');
297
+ });
298
+ it('folds storagePath through a chain — later hooks see ctx.storagePath and may override it', async () => {
299
+ const secondHook = vi.fn(({ storagePath }) => ({
300
+ storagePath: storagePath.replace('-draft', ''),
301
+ }));
302
+ const definition = withFieldUpload(uploadCollection, 'image', (f) => {
303
+ f.upload.hooks = {
304
+ beforeStore: [() => ({ storagePath: 'publications/report-draft.png' }), secondHook],
305
+ };
306
+ });
307
+ const { ctx, upload } = buildCtx({ definition });
308
+ await uploadField(ctx, {
309
+ buffer: Buffer.from('png'),
310
+ originalFilename: 'hero.png',
311
+ mimeType: 'image/png',
312
+ fileSize: 3,
313
+ shouldCreateDocument: false,
314
+ });
315
+ expect(secondHook).toHaveBeenCalledWith(expect.objectContaining({ storagePath: 'publications/report-draft.png' }));
316
+ expect(upload).toHaveBeenCalledWith(expect.any(Buffer), expect.objectContaining({ targetStoragePath: 'publications/report.png' }));
317
+ });
318
+ it('no storagePath override → storage.upload receives no targetStoragePath', async () => {
319
+ const { ctx, upload } = buildCtx();
320
+ await uploadField(ctx, {
321
+ buffer: Buffer.from('png'),
322
+ originalFilename: 'hero.png',
323
+ mimeType: 'image/png',
324
+ fileSize: 3,
325
+ shouldCreateDocument: false,
326
+ });
327
+ const options = upload.mock.calls[0]?.[1];
328
+ expect(options).not.toHaveProperty('targetStoragePath');
329
+ });
251
330
  it('beforeStore { error } short-circuits — no storage write, no variants, no afterStore', async () => {
252
331
  const afterStore = vi.fn();
253
332
  const definition = withFieldUpload(uploadCollection, 'image', (f) => {
@@ -154,6 +154,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
154
154
  counters: {
155
155
  ensureCounterGroup: vi.fn(),
156
156
  nextCounterValue: vi.fn(),
157
+ nextScopedCounterValue: vi.fn(),
157
158
  },
158
159
  },
159
160
  queries: {
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.18.0",
5
+ "version": "3.19.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -76,7 +76,7 @@
76
76
  "pino": "^10.3.1",
77
77
  "sharp": "^0.35.3",
78
78
  "zod": "^4.4.3",
79
- "@byline/auth": "3.18.0"
79
+ "@byline/auth": "3.19.0"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@biomejs/biome": "2.5.2",