@byline/client 6.6.0 → 6.7.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.
package/dist/client.d.ts CHANGED
@@ -48,6 +48,9 @@ export declare class BylineClient<TCollections extends CollectionRegistry = Regi
48
48
  * across every collection indexed into the named zone. Collections the
49
49
  * actor cannot `read` are excluded; `beforeRead` row scoping applies per
50
50
  * collection; `hydrate: true` attaches a shaped `ClientDocument` per hit.
51
+ * A public search (the default `status: 'published'`) also re-checks each
52
+ * hit's locale against the advertised-locale policy, and reports a
53
+ * page-local `total` with no facets, as `CollectionHandle.search()` does.
51
54
  * For homogeneous, single-collection search use
52
55
  * `client.collection(path).search()`.
53
56
  */
package/dist/client.js CHANGED
@@ -122,6 +122,9 @@ export class BylineClient {
122
122
  * across every collection indexed into the named zone. Collections the
123
123
  * actor cannot `read` are excluded; `beforeRead` row scoping applies per
124
124
  * collection; `hydrate: true` attaches a shaped `ClientDocument` per hit.
125
+ * A public search (the default `status: 'published'`) also re-checks each
126
+ * hit's locale against the advertised-locale policy, and reports a
127
+ * page-local `total` with no facets, as `CollectionHandle.search()` does.
125
128
  * For homogeneous, single-collection search use
126
129
  * `client.collection(path).search()`.
127
130
  */
@@ -45,25 +45,30 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
45
45
  * Asserts the collection `read` ability first (same gate as the other
46
46
  * reads), and defaults `status` to `'published'`, so a public viewer only
47
47
  * sees published content — which is also all the index holds, since
48
- * indexing is published-only.
48
+ * indexing writes only publicly deliverable locales of the published
49
+ * version.
49
50
  *
50
- * **Row-level authorization** — "rank in the provider, authorise in core":
51
- * when the collection configures a `beforeRead` hook, the provider's
52
- * candidate hits are re-resolved through the normal read path (the same
53
- * predicate merge + SQL compile every other read uses) and hits whose
54
- * document doesn't survive the scoping are dropped. Collections without a
55
- * hook skip the second query entirely. `hydrate: true` batch-reads the
56
- * hits into shaped `ClientDocument`s in the same query (authorisation
57
- * comes free) and attaches them as `hit.document`. Two consequences to be
58
- * aware of:
51
+ * **Authorization after ranking** — "rank in the provider, authorise in
52
+ * core": the provider's candidate hits are re-resolved through the normal
53
+ * read path (the same predicate merge + SQL compile every other read uses)
54
+ * whenever the search is public (`status: 'published'`, the default) or
55
+ * the collection configures a `beforeRead` hook. A public search re-checks
56
+ * every hit's locale against the advertised-locale policy even without a
57
+ * hook, so a stale index slice for an unchecked translation is dropped;
58
+ * one batched read per page performs the check. `hydrate: true` reads the
59
+ * hits into shaped `ClientDocument`s in their own locale (no source
60
+ * substitution) and attaches them as `hit.document`. Consequences:
59
61
  *
60
- * - under row scoping, `total` is the authorized hit count for this page
61
- * and facets are omitted rather than leaking provider-wide aggregates.
62
+ * - for every public search, and under row scoping, `total` is the
63
+ * surviving hit count for this page and facets are omitted rather than
64
+ * leaking provider-wide aggregates;
65
+ * - only an editorial search (`status: 'any'`) with no `beforeRead` hook
66
+ * passes the provider `total` and facets through unchanged;
62
67
  * - a page of hits can come back shorter than `limit` when candidates
63
68
  * are dropped; paginate on `offset`, not on received length.
64
69
  *
65
70
  * `_bypassBeforeRead: true` is the same system-operation escape hatch the
66
- * read methods take.
71
+ * read methods take; it skips `beforeRead` scoping, never the locale check.
67
72
  *
68
73
  * Throws `ERR_VALIDATION` when no provider is registered.
69
74
  */
@@ -309,6 +314,17 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
309
314
  * Returns the resolved context so callers can thread `readMode` and
310
315
  * other per-request state into the adapter without re-resolving.
311
316
  */
317
+ /**
318
+ * Resolve the request context for a read and authorize it: the collection
319
+ * read ability (`assertActorCanPerform`) and the read's locale visibility
320
+ * (`assertLocaleVisibility`). Visibility defaults from `readMode`, so the
321
+ * editing, history and version reads (`readMode: 'any'`) are editorial.
322
+ *
323
+ * The visibility check validates the *effective* locale, the value every
324
+ * read method sends to storage (`locale ?? client.defaultLocale`), so a
325
+ * client configured with `defaultLocale: 'all'` cannot turn an omitted
326
+ * locale into a public all-locale read.
327
+ */
312
328
  private resolveAndAssertRead;
313
329
  /**
314
330
  * Guard that this collection opts into the document-tree primitive. The
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { applyAfterRead, assertActorCanPerform, buildSearchDocument, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, createDocument, createReadContext, createRichTextDocumentReader, deleteDocument, documentAbilityKey, documentRevisionFromDatabase, ERR_VALIDATION, getDocumentScheduledPublish, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, resolveIdentityField, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, } from '@byline/core';
8
+ import { applyAfterRead, assertActorCanPerform, assertLocaleVisibility, buildSearchDocument, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, createDocument, createReadContext, createRichTextDocumentReader, deleteDocument, documentAbilityKey, documentRevisionFromDatabase, ERR_VALIDATION, getDocumentScheduledPublish, parseSort, parseWhere, placeTreeNode as placeTreeNodeLifecycle, populateDocuments, populateRichTextFields, removeFromTree as removeFromTreeLifecycle, resolveIdentityField, resolveLocaleVisibility, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, } from '@byline/core';
9
9
  import { getReadSecurityDomain, resolveReadRequestContext, resolveReadSecurityFilters, } from './read-context.js';
10
10
  import { expectedDocumentId, readSingletonForEdit, } from './read-internals.js';
11
11
  import { shapeDocument, shapePopulatedInPlace } from './response.js';
@@ -39,7 +39,8 @@ export class CollectionHandle {
39
39
  async find(options = {}) {
40
40
  const readMode = resolveReadMode(options.status);
41
41
  const readCtx = options._readContext ?? createReadContext();
42
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
42
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
43
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
43
44
  const { where, select, sort, locale = this.client.defaultLocale, page = 1, pageSize = 20, } = options;
44
45
  const securityFilters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
45
46
  const parsedWhere = await parseWhere(where, this.definition, {
@@ -66,12 +67,13 @@ export class CollectionHandle {
66
67
  fields: select,
67
68
  readMode,
68
69
  onMissingLocale: options.onMissingLocale ?? 'fallback',
70
+ localeVisibility,
69
71
  });
70
72
  await this.populateIfRequested(collectionId, result.documents, locale, readMode, requestContext, {
71
73
  ...options,
72
74
  _readContext: readCtx,
73
75
  });
74
- await this.finishReadDocuments(result.documents, readCtx, requestContext, locale, readMode, options._bypassBeforeRead, select, readMaterialization(options.populate, options.depth));
76
+ await this.finishReadDocuments(result.documents, readCtx, requestContext, locale, readMode, options._bypassBeforeRead, select, readMaterialization(options.populate, options.depth), localeVisibility);
75
77
  return {
76
78
  docs: result.documents.map((d) => this.shapeWithPopulated(d)),
77
79
  meta: {
@@ -147,6 +149,7 @@ export class CollectionHandle {
147
149
  populate: options.populate,
148
150
  depth: options.depth,
149
151
  status: options.status,
152
+ localeVisibility: options.localeVisibility,
150
153
  onMissingLocale: options.onMissingLocale,
151
154
  _readContext: options._readContext,
152
155
  _bypassBeforeRead: options._bypassBeforeRead,
@@ -163,25 +166,30 @@ export class CollectionHandle {
163
166
  * Asserts the collection `read` ability first (same gate as the other
164
167
  * reads), and defaults `status` to `'published'`, so a public viewer only
165
168
  * sees published content — which is also all the index holds, since
166
- * indexing is published-only.
169
+ * indexing writes only publicly deliverable locales of the published
170
+ * version.
167
171
  *
168
- * **Row-level authorization** — "rank in the provider, authorise in core":
169
- * when the collection configures a `beforeRead` hook, the provider's
170
- * candidate hits are re-resolved through the normal read path (the same
171
- * predicate merge + SQL compile every other read uses) and hits whose
172
- * document doesn't survive the scoping are dropped. Collections without a
173
- * hook skip the second query entirely. `hydrate: true` batch-reads the
174
- * hits into shaped `ClientDocument`s in the same query (authorisation
175
- * comes free) and attaches them as `hit.document`. Two consequences to be
176
- * aware of:
172
+ * **Authorization after ranking** — "rank in the provider, authorise in
173
+ * core": the provider's candidate hits are re-resolved through the normal
174
+ * read path (the same predicate merge + SQL compile every other read uses)
175
+ * whenever the search is public (`status: 'published'`, the default) or
176
+ * the collection configures a `beforeRead` hook. A public search re-checks
177
+ * every hit's locale against the advertised-locale policy even without a
178
+ * hook, so a stale index slice for an unchecked translation is dropped;
179
+ * one batched read per page performs the check. `hydrate: true` reads the
180
+ * hits into shaped `ClientDocument`s in their own locale (no source
181
+ * substitution) and attaches them as `hit.document`. Consequences:
177
182
  *
178
- * - under row scoping, `total` is the authorized hit count for this page
179
- * and facets are omitted rather than leaking provider-wide aggregates.
183
+ * - for every public search, and under row scoping, `total` is the
184
+ * surviving hit count for this page and facets are omitted rather than
185
+ * leaking provider-wide aggregates;
186
+ * - only an editorial search (`status: 'any'`) with no `beforeRead` hook
187
+ * passes the provider `total` and facets through unchanged;
180
188
  * - a page of hits can come back shorter than `limit` when candidates
181
189
  * are dropped; paginate on `offset`, not on received length.
182
190
  *
183
191
  * `_bypassBeforeRead: true` is the same system-operation escape hatch the
184
- * read methods take.
192
+ * read methods take; it skips `beforeRead` scoping, never the locale check.
185
193
  *
186
194
  * Throws `ERR_VALIDATION` when no provider is registered.
187
195
  */
@@ -212,10 +220,12 @@ export class CollectionHandle {
212
220
  limit: options.limit,
213
221
  offset: options.offset,
214
222
  });
215
- // Row-level authorization (+ optional hydration) — the shared finishing
216
- // pipeline the zone entry point uses too. Collections without a
217
- // beforeRead predicate and no hydrate request pass through untouched.
218
- const hits = await finalizeSearchHits({
223
+ // Row-level authorization, exact-locale eligibility (+ optional
224
+ // hydration) — the shared finishing pipeline the zone entry point uses
225
+ // too. Public searches always re-check every hit and always follow the
226
+ // restricted-result convention; only an editorial search without a
227
+ // beforeRead predicate or hydration passes hits through untouched.
228
+ const { hits, eligibilityRestricted } = await finalizeSearchHits({
219
229
  client: this.client,
220
230
  requestContext,
221
231
  hits: results.hits,
@@ -223,9 +233,10 @@ export class CollectionHandle {
223
233
  status: options.status,
224
234
  hydrate: options.hydrate,
225
235
  bypassBeforeRead: options._bypassBeforeRead,
236
+ localeVisibility: resolveLocaleVisibility(readMode),
226
237
  readContext: readCtx,
227
238
  });
228
- return aggregateRestricted
239
+ return aggregateRestricted || eligibilityRestricted
229
240
  ? { hits, total: hits.length }
230
241
  : { hits, total: results.total, facets: results.facets };
231
242
  }
@@ -251,9 +262,13 @@ export class CollectionHandle {
251
262
  return;
252
263
  const populate = this.buildSearchFacetPopulateMap();
253
264
  for (const locale of this.client.contentLocales) {
265
+ // Public delivery, pinned explicitly: the system client indexing here
266
+ // is authenticated, but only publicly eligible locale slices (source,
267
+ // or checked and complete) may enter the index.
254
268
  const view = await this.findById(documentId, {
255
269
  locale,
256
270
  status: 'published',
271
+ localeVisibility: 'public',
257
272
  onMissingLocale: 'omit',
258
273
  populate,
259
274
  _bypassBeforeRead: true,
@@ -360,7 +375,8 @@ export class CollectionHandle {
360
375
  async findById(documentId, options = {}) {
361
376
  const readMode = resolveReadMode(options.status);
362
377
  const readCtx = options._readContext ?? createReadContext();
363
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
378
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
379
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
364
380
  const { locale = this.client.defaultLocale } = options;
365
381
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
366
382
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -373,6 +389,7 @@ export class CollectionHandle {
373
389
  filters,
374
390
  lenient: options.lenient,
375
391
  onMissingLocale: options.onMissingLocale ?? 'fallback',
392
+ localeVisibility,
376
393
  });
377
394
  if (raw == null)
378
395
  return null;
@@ -385,7 +402,7 @@ export class CollectionHandle {
385
402
  ...options,
386
403
  _readContext: readCtx,
387
404
  });
388
- await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
405
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth), localeVisibility);
389
406
  return this.shapeWithPopulated(raw);
390
407
  }
391
408
  async findByIdForEdit(documentId, options = {}) {
@@ -453,7 +470,8 @@ export class CollectionHandle {
453
470
  async findByPath(path, options = {}) {
454
471
  const readMode = resolveReadMode(options.status);
455
472
  const readCtx = options._readContext ?? createReadContext();
456
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
473
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
474
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
457
475
  const { locale = this.client.defaultLocale } = options;
458
476
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
459
477
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -465,6 +483,7 @@ export class CollectionHandle {
465
483
  readMode,
466
484
  filters,
467
485
  onMissingLocale: options.onMissingLocale ?? 'fallback',
486
+ localeVisibility,
468
487
  });
469
488
  if (raw == null)
470
489
  return null;
@@ -475,7 +494,7 @@ export class CollectionHandle {
475
494
  ...options,
476
495
  _readContext: readCtx,
477
496
  });
478
- await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
497
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth), localeVisibility);
479
498
  return this.shapeWithPopulated(raw);
480
499
  }
481
500
  // -------------------------------------------------------------------------
@@ -817,7 +836,8 @@ export class CollectionHandle {
817
836
  this.assertTreeCollection();
818
837
  const readMode = resolveReadMode(options.status);
819
838
  const readCtx = options._readContext ?? createReadContext();
820
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
839
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
840
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
821
841
  const locale = options.locale ?? this.client.defaultLocale;
822
842
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
823
843
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -828,10 +848,11 @@ export class CollectionHandle {
828
848
  readMode,
829
849
  locale,
830
850
  filters,
851
+ localeVisibility,
831
852
  });
832
853
  if (structure.length === 0)
833
854
  return [];
834
- const shapedById = await this.hydrateTreeNodes(collectionId, structure.map((n) => n.document_id), locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead);
855
+ const shapedById = await this.hydrateTreeNodes(collectionId, structure.map((n) => n.document_id), locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead, localeVisibility);
835
856
  // Assemble the nested forest. Rows arrive pre-order, so a parent is always
836
857
  // materialised before its children; a row whose parent is not in the result
837
858
  // set (null parent, or the requested root's own parent) is a top-level node.
@@ -960,7 +981,8 @@ export class CollectionHandle {
960
981
  this.assertTreeCollection();
961
982
  const readMode = resolveReadMode(options.status);
962
983
  const readCtx = options._readContext ?? createReadContext();
963
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
984
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
985
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
964
986
  const locale = options.locale ?? this.client.defaultLocale;
965
987
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
966
988
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -969,11 +991,12 @@ export class CollectionHandle {
969
991
  readMode,
970
992
  locale,
971
993
  filters,
994
+ localeVisibility,
972
995
  });
973
996
  if (ancestors.length === 0)
974
997
  return [];
975
998
  const ids = ancestors.map((a) => a.document_id);
976
- const shapedById = await this.hydrateTreeNodes(collectionId, ids, locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead);
999
+ const shapedById = await this.hydrateTreeNodes(collectionId, ids, locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead, localeVisibility);
977
1000
  // A hydration-time miss truncates the edge rather than compacting past a
978
1001
  // newly-hidden ancestor.
979
1002
  const visible = [];
@@ -1001,13 +1024,15 @@ export class CollectionHandle {
1001
1024
  this.assertTreeCollection();
1002
1025
  const readMode = resolveReadMode(options.status);
1003
1026
  const readCtx = options._readContext ?? createReadContext();
1004
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
1027
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
1028
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
1005
1029
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
1006
1030
  const result = await this.client.db.queries.documents.getTreeParent({
1007
1031
  document_id: documentId,
1008
1032
  readMode,
1009
1033
  locale: options.locale ?? this.client.defaultLocale,
1010
1034
  filters,
1035
+ localeVisibility,
1011
1036
  });
1012
1037
  return {
1013
1038
  placed: result.placed,
@@ -1085,9 +1110,21 @@ export class CollectionHandle {
1085
1110
  * Returns the resolved context so callers can thread `readMode` and
1086
1111
  * other per-request state into the adapter without re-resolving.
1087
1112
  */
1088
- async resolveAndAssertRead(readMode, readContext) {
1113
+ /**
1114
+ * Resolve the request context for a read and authorize it: the collection
1115
+ * read ability (`assertActorCanPerform`) and the read's locale visibility
1116
+ * (`assertLocaleVisibility`). Visibility defaults from `readMode`, so the
1117
+ * editing, history and version reads (`readMode: 'any'`) are editorial.
1118
+ *
1119
+ * The visibility check validates the *effective* locale, the value every
1120
+ * read method sends to storage (`locale ?? client.defaultLocale`), so a
1121
+ * client configured with `defaultLocale: 'all'` cannot turn an omitted
1122
+ * locale into a public all-locale read.
1123
+ */
1124
+ async resolveAndAssertRead(readMode, readContext, localeVisibility = resolveLocaleVisibility(readMode), locale) {
1089
1125
  const requestContext = await resolveReadRequestContext(this.client, readContext, readMode);
1090
1126
  assertActorCanPerform(requestContext, this.definition, 'read');
1127
+ assertLocaleVisibility(requestContext, this.definition.path, localeVisibility, locale ?? this.client.defaultLocale);
1091
1128
  return requestContext;
1092
1129
  }
1093
1130
  /**
@@ -1113,7 +1150,7 @@ export class CollectionHandle {
1113
1150
  * `afterRead` per node (deduped within one shared `ReadContext`) to stay
1114
1151
  * consistent with the other client read paths.
1115
1152
  */
1116
- async hydrateTreeNodes(collectionId, documentIds, locale, readMode, select, filters, readContext, requestContext, bypassBeforeRead) {
1153
+ async hydrateTreeNodes(collectionId, documentIds, locale, readMode, select, filters, readContext, requestContext, bypassBeforeRead, localeVisibility = resolveLocaleVisibility(readMode)) {
1117
1154
  const shapedById = new Map();
1118
1155
  if (documentIds.length === 0)
1119
1156
  return shapedById;
@@ -1124,8 +1161,9 @@ export class CollectionHandle {
1124
1161
  readMode,
1125
1162
  fields: select,
1126
1163
  filters,
1164
+ localeVisibility,
1127
1165
  });
1128
- await this.finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, select, 'tree');
1166
+ await this.finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, select, 'tree', localeVisibility);
1129
1167
  for (const raw of rawDocs) {
1130
1168
  const doc = raw;
1131
1169
  shapedById.set(doc.document_id, this.shapeWithPopulated(doc));
@@ -1153,6 +1191,9 @@ export class CollectionHandle {
1153
1191
  depth: options.depth,
1154
1192
  locale,
1155
1193
  readMode,
1194
+ // Equals the entry point's resolved visibility: an explicit option wins,
1195
+ // otherwise the default follows the same status that selected `readMode`.
1196
+ localeVisibility: options.localeVisibility ?? resolveLocaleVisibility(readMode),
1156
1197
  readContext: options._readContext,
1157
1198
  requestContext,
1158
1199
  securityDomain: getReadSecurityDomain(this.client),
@@ -1169,7 +1210,7 @@ export class CollectionHandle {
1169
1210
  * `populateRelationsOnRead` inside the service. No-ops when no adapter
1170
1211
  * is registered.
1171
1212
  */
1172
- async richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead) {
1213
+ async richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, localeVisibility) {
1173
1214
  const populate = this.client.richTextPopulate;
1174
1215
  if (!populate || rawDocs.length === 0)
1175
1216
  return;
@@ -1187,6 +1228,7 @@ export class CollectionHandle {
1187
1228
  requestContext,
1188
1229
  readContext,
1189
1230
  readMode,
1231
+ localeVisibility,
1190
1232
  locale,
1191
1233
  bypassBeforeRead,
1192
1234
  richTextPopulate: populate,
@@ -1195,10 +1237,10 @@ export class CollectionHandle {
1195
1237
  });
1196
1238
  }
1197
1239
  /** Complete the common post-reconstruction pipeline in security order. */
1198
- async finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, projection, materialization) {
1240
+ async finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, projection, materialization, localeVisibility = resolveLocaleVisibility(readMode)) {
1199
1241
  // Rich-text targets must be authorised and refreshed before user hooks see
1200
1242
  // the document, regardless of which read entry point materialised it.
1201
- await this.richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead);
1243
+ await this.richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, localeVisibility);
1202
1244
  for (const doc of rawDocs) {
1203
1245
  await applyAfterRead({
1204
1246
  doc,
package/dist/response.js CHANGED
@@ -39,6 +39,13 @@ export function shapeDocument(raw) {
39
39
  if (typeof raw.source_locale === 'string') {
40
40
  shaped.sourceLocale = raw.source_locale;
41
41
  }
42
+ // The read's language decision for this document and selected version
43
+ // (storage key `resolved_locale`). `null` is a real value (withheld exact
44
+ // read, multi-locale read, locale-agnostic version); only a result the
45
+ // adapter did not reconstruct lacks the key.
46
+ if ('resolved_locale' in raw) {
47
+ shaped.resolvedLocale = raw.resolved_locale ?? null;
48
+ }
42
49
  // Version audit metadata — acting user + action (storage keys
43
50
  // `created_by` / `event_type`).
44
51
  // `created_by` is NULL on rows written before audit wiring or by
package/dist/search.d.ts CHANGED
@@ -14,10 +14,12 @@
14
14
  * `finalizeSearchHits`, which — per collection represented in the hits —
15
15
  * applies `beforeRead` row scoping (re-resolving candidate ids through the
16
16
  * normal read path) and, when `hydrate` is requested, batch-reads the hits
17
- * into shaped `ClientDocument`s in the same query.
17
+ * into shaped `ClientDocument`s in the same query. Under `'public'` locale
18
+ * visibility every hit is also re-checked for exact-locale eligibility, so a
19
+ * stale index entry for a withheld translation is dropped whole.
18
20
  */
19
21
  import type { RequestContext } from '@byline/auth';
20
- import type { ReadContext, ReadMode, SearchHit, SearchProvider } from '@byline/core';
22
+ import type { LocaleVisibility, ReadContext, ReadMode, SearchHit, SearchProvider } from '@byline/core';
21
23
  import type { BylineClient } from './client.js';
22
24
  import type { ClientSearchResults, HydratedSearchHit, ZoneSearchOptions } from './types.js';
23
25
  /** Validate the optional logical body-field scope before calling a provider. */
@@ -30,9 +32,29 @@ export interface FinalizeSearchHitsParams {
30
32
  status?: ReadMode;
31
33
  hydrate?: boolean;
32
34
  bypassBeforeRead?: true;
35
+ /**
36
+ * The search's locale visibility (derived from `status`). Under `'public'`
37
+ * each hit's document must be publicly eligible in the hit's locale.
38
+ */
39
+ localeVisibility: LocaleVisibility;
33
40
  /** Shared per-request read context (beforeRead cache). Created when omitted. */
34
41
  readContext?: ReadContext;
35
42
  }
43
+ /** Finished hits, plus whether a post-ranking eligibility policy applied. */
44
+ export interface FinalizedSearchHits {
45
+ hits: HydratedSearchHit[];
46
+ /**
47
+ * `true` whenever the public exact-locale eligibility policy applies — that
48
+ * is, for every `'public'` search, including an empty page. The provider's
49
+ * `total` and facets count its whole index, which can hold slices the
50
+ * policy would reject on other pages, so a page-only check cannot certify
51
+ * them. The caller follows the restricted-result convention (retained-hit
52
+ * `total`, no provider facets), exactly as for `beforeRead` row scoping,
53
+ * which also restricts on the presence of the policy, not on an observed
54
+ * removal.
55
+ */
56
+ eligibilityRestricted: boolean;
57
+ }
36
58
  /**
37
59
  * Authorise (and optionally hydrate) provider hits, per collection:
38
60
  *
@@ -42,15 +64,25 @@ export interface FinalizeSearchHitsParams {
42
64
  * hydration cost one query per collection. Hits whose document doesn't
43
65
  * come back — dropped by scoping, or a stale index entry whose document
44
66
  * no longer resolves — are removed; the rest carry `hit.document`.
45
- * - `hydrate` off — collections with a `beforeRead` predicate get the
46
- * trimmed id re-resolution (identity-field projection); collections
47
- * without one pass through untouched (no second query).
67
+ * - `hydrate` off, public visibility — every collection's hit ids are
68
+ * re-resolved through the normal read path with `onMissingLocale:
69
+ * 'omit'` (identity-field projection), which applies both exact-locale
70
+ * eligibility and any `beforeRead` scoping. An index entry can outlive
71
+ * the checkbox that allowed it (until reindexing catches up), so this
72
+ * check runs even without a hook.
73
+ * - `hydrate` off, editorial visibility — collections with a `beforeRead`
74
+ * predicate get the trimmed id re-resolution; collections without one
75
+ * pass through untouched (no second query).
76
+ *
77
+ * Hydration also reads with `'omit'`: a hit is for one locale slice, and a
78
+ * source-language document substituted by fallback would not make that hit
79
+ * safe.
48
80
  *
49
81
  * Hits whose `collectionPath` isn't registered in the runtime config are
50
82
  * dropped with a debug log (an index can outlive a collection). Original
51
83
  * ranking order is preserved.
52
84
  */
53
- export declare function finalizeSearchHits(params: FinalizeSearchHitsParams): Promise<HydratedSearchHit[]>;
85
+ export declare function finalizeSearchHits(params: FinalizeSearchHitsParams): Promise<FinalizedSearchHits>;
54
86
  /**
55
87
  * Cross-collection search over a named zone. Membership is resolved from
56
88
  * the runtime collection definitions (`search.zones`, defaulting to the
package/dist/search.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { assertActorCanPerform, createReadContext, ERR_VALIDATION, getCollectionAdminConfig, MAX_SEARCH_QUERY_LENGTH, resolveSearchZones, } from '@byline/core';
8
+ import { assertActorCanPerform, createReadContext, ERR_VALIDATION, getCollectionAdminConfig, MAX_SEARCH_QUERY_LENGTH, resolveLocaleVisibility, resolveSearchZones, } from '@byline/core';
9
9
  import { resolveReadRequestContext, resolveReadSecurityFilters } from './read-context.js';
10
10
  /** Validate the optional logical body-field scope before calling a provider. */
11
11
  export function assertSearchFieldScope(provider, fieldScope) {
@@ -30,18 +30,29 @@ export function assertSearchFieldScope(provider, fieldScope) {
30
30
  * hydration cost one query per collection. Hits whose document doesn't
31
31
  * come back — dropped by scoping, or a stale index entry whose document
32
32
  * no longer resolves — are removed; the rest carry `hit.document`.
33
- * - `hydrate` off — collections with a `beforeRead` predicate get the
34
- * trimmed id re-resolution (identity-field projection); collections
35
- * without one pass through untouched (no second query).
33
+ * - `hydrate` off, public visibility — every collection's hit ids are
34
+ * re-resolved through the normal read path with `onMissingLocale:
35
+ * 'omit'` (identity-field projection), which applies both exact-locale
36
+ * eligibility and any `beforeRead` scoping. An index entry can outlive
37
+ * the checkbox that allowed it (until reindexing catches up), so this
38
+ * check runs even without a hook.
39
+ * - `hydrate` off, editorial visibility — collections with a `beforeRead`
40
+ * predicate get the trimmed id re-resolution; collections without one
41
+ * pass through untouched (no second query).
42
+ *
43
+ * Hydration also reads with `'omit'`: a hit is for one locale slice, and a
44
+ * source-language document substituted by fallback would not make that hit
45
+ * safe.
36
46
  *
37
47
  * Hits whose `collectionPath` isn't registered in the runtime config are
38
48
  * dropped with a debug log (an index can outlive a collection). Original
39
49
  * ranking order is preserved.
40
50
  */
41
51
  export async function finalizeSearchHits(params) {
42
- const { client, requestContext, hits, locale, status, hydrate, bypassBeforeRead } = params;
52
+ const { client, requestContext, hits, locale, status, hydrate, bypassBeforeRead, localeVisibility, } = params;
53
+ const eligibilityRestricted = localeVisibility === 'public';
43
54
  if (hits.length === 0)
44
- return hits;
55
+ return { hits, eligibilityRestricted };
45
56
  const readCtx = params.readContext ?? createReadContext();
46
57
  // Group hit ids by collection, preserving overall ranking order for the
47
58
  // final reassembly.
@@ -78,6 +89,8 @@ export async function finalizeSearchHits(params) {
78
89
  select,
79
90
  locale,
80
91
  status,
92
+ onMissingLocale: 'omit',
93
+ localeVisibility,
81
94
  page: 1,
82
95
  pageSize: ids.length,
83
96
  _readContext: readCtx,
@@ -86,6 +99,24 @@ export async function finalizeSearchHits(params) {
86
99
  documentsByCollection.set(collectionPath, new Map(result.docs.map((d) => [d.id, d])));
87
100
  continue;
88
101
  }
102
+ if (localeVisibility === 'public') {
103
+ // Exact-locale eligibility (and beforeRead scoping, unless bypassed)
104
+ // for every public hit, hook or no hook.
105
+ const result = await handle.find({
106
+ where: { id: { $in: ids } },
107
+ select: definition.useAsTitle != null ? [definition.useAsTitle] : undefined,
108
+ locale,
109
+ status,
110
+ onMissingLocale: 'omit',
111
+ localeVisibility,
112
+ page: 1,
113
+ pageSize: ids.length,
114
+ _readContext: readCtx,
115
+ ...(bypassBeforeRead ? { _bypassBeforeRead: true } : {}),
116
+ });
117
+ allowedByCollection.set(collectionPath, new Set(result.docs.map((d) => d.id)));
118
+ continue;
119
+ }
89
120
  if (bypassBeforeRead) {
90
121
  allowedByCollection.set(collectionPath, null);
91
122
  continue;
@@ -127,7 +158,7 @@ export async function finalizeSearchHits(params) {
127
158
  finished.push(hit);
128
159
  }
129
160
  }
130
- return finished;
161
+ return { hits: finished, eligibilityRestricted };
131
162
  }
132
163
  // ---------------------------------------------------------------------------
133
164
  // Zone (cross-collection) entry point
@@ -203,7 +234,7 @@ export async function zoneSearch(client, options) {
203
234
  });
204
235
  // Constrain to readable member collections, then authorise / hydrate.
205
236
  const scoped = results.hits.filter((h) => readable.has(h.collectionPath));
206
- const hits = await finalizeSearchHits({
237
+ const { hits, eligibilityRestricted } = await finalizeSearchHits({
207
238
  client,
208
239
  requestContext,
209
240
  hits: scoped,
@@ -211,9 +242,10 @@ export async function zoneSearch(client, options) {
211
242
  status: options.status,
212
243
  hydrate: options.hydrate,
213
244
  bypassBeforeRead: options._bypassBeforeRead,
245
+ localeVisibility: resolveLocaleVisibility(readMode),
214
246
  readContext: readCtx,
215
247
  });
216
- return aggregateRestricted
248
+ return aggregateRestricted || eligibilityRestricted
217
249
  ? { hits, total: hits.length }
218
250
  : { hits, total: results.total, facets: results.facets };
219
251
  }
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { assertActorCanPerform, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copySingletonToLocale, createReadContext, ERR_NOT_FOUND, getDocumentScheduledPublish, resolveSingletonDocumentId, restoreSingletonVersion, scheduleDocumentPublish, unpublishDocument, updateSingleton, } from '@byline/core';
8
+ import { assertActorCanPerform, assertLocaleVisibility, cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copySingletonToLocale, createReadContext, ERR_NOT_FOUND, getDocumentScheduledPublish, resolveLocaleVisibility, resolveSingletonDocumentId, restoreSingletonVersion, scheduleDocumentPublish, unpublishDocument, updateSingleton, } from '@byline/core';
9
9
  import { CollectionHandle } from './collection-handle.js';
10
10
  import { resolveReadRequestContext } from './read-context.js';
11
11
  import { expectedDocumentId, readSingletonForEdit, } from './read-internals.js';
@@ -32,7 +32,11 @@ export class SingletonHandle {
32
32
  }
33
33
  async get(options = {}) {
34
34
  const readContext = options._readContext ?? createReadContext();
35
- await this.authorizeRead(options.status ?? 'published', readContext);
35
+ const requestContext = await this.authorizeRead(options.status ?? 'published', readContext);
36
+ // Validate locale visibility before the slot lookup, so an invalid request
37
+ // is rejected whether or not the slot is materialized, and before any
38
+ // adapter work. The delegated `findById` repeats the same check.
39
+ assertLocaleVisibility(requestContext, this.definition.path, resolveLocaleVisibility(options.status, options.localeVisibility), options.locale ?? this.client.defaultLocale);
36
40
  const documentId = await this.resolveMappedDocumentId();
37
41
  if (documentId == null)
38
42
  return null;
package/dist/types.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { RequestContext } from '@byline/auth';
9
- import type { BylineLogger, CollectionDefinition, DocumentPublishSchedule, IDbAdapter, IStorageProvider, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, RichTextToTextFn, SearchFacetBucket, SearchHit, SearchMatching, SearchProvider, ServerConfig, SingletonSavePrecondition, SlugifierFn, SortSpec } from '@byline/core';
9
+ import type { BylineLogger, CollectionDefinition, DocumentPublishSchedule, IDbAdapter, IStorageProvider, LocaleVisibility, MissingLocalePolicy, PopulateSpec, PredicateValue, QueryPredicate, ReadContext, ReadMode, RichTextPopulateFn, RichTextToTextFn, SearchFacetBucket, SearchHit, SearchMatching, SearchProvider, ServerConfig, SingletonSavePrecondition, SlugifierFn, SortSpec } from '@byline/core';
10
10
  export type { FilterOperators, PredicateValue, QueryPredicate, SearchFacetBucket, SearchHit, SearchProvider, SearchResults, SortDirection, SortSpec, } from '@byline/core';
11
11
  export interface BylineClientConfig {
12
12
  /**
@@ -165,9 +165,18 @@ interface PopulateControls extends ReadContextControls {
165
165
  * Distinct from `where.status`, which is an *exact-match filter* on the
166
166
  * selected version's status column ("show me all drafts"). `status` is
167
167
  * the *source view* selector.
168
+ *
169
+ * `localeVisibility` chooses which of the selected version's languages may be
170
+ * delivered. `'public'` honours the editor's advertised-locale checkboxes (an
171
+ * unchecked translation behaves like a missing one); `'editorial'` ignores them
172
+ * so an authorized reviewer can see a withheld translation. It defaults from
173
+ * `status`: omitted or `'published'` → `'public'`, `'any'` → `'editorial'`.
174
+ * `'editorial'` requires an authenticated actor, and a `'public'` read cannot
175
+ * request `locale: 'all'`. See `LocaleVisibility` in `@byline/core`.
168
176
  */
169
177
  interface StatusControls {
170
178
  status?: ReadMode;
179
+ localeVisibility?: LocaleVisibility;
171
180
  }
172
181
  /**
173
182
  * Options for `CollectionHandle.search(...)` — a ranked full-text query
@@ -596,6 +605,21 @@ export interface ClientDocument<F = Record<string, any>> {
596
605
  * `docs/08-internationalization/index.md`.
597
606
  */
598
607
  sourceLocale?: string;
608
+ /**
609
+ * The content locale this read selected for this document's fields, decided
610
+ * from the whole selected version before any field projection.
611
+ *
612
+ * - `'fallback'`: the first eligible locale in the chain, which may be the
613
+ * source rather than the requested locale.
614
+ * - `'omit'`, or an eligible exact read: the requested locale.
615
+ * - `null`: a public exact read that withheld an unavailable translation,
616
+ * a multi-locale (`locale: 'all'`) read, or a locale-agnostic version.
617
+ *
618
+ * It describes which locale was selected, not whether every field is
619
+ * linguistically in that language, and not whether the translation is
620
+ * publicly released. Each populated target and tree node carries its own.
621
+ */
622
+ resolvedLocale?: string | null;
599
623
  /** When this version was created. */
600
624
  createdAt: Date;
601
625
  /** When this version was last updated. */
@@ -730,6 +754,6 @@ export interface EditableTreeNode<F = Record<string, any>> {
730
754
  depth: number;
731
755
  children: EditableTreeNode<F>[];
732
756
  }
733
- export type FindByIdForEditOptions<F = Record<string, any>> = Omit<FindByIdOptions<F>, 'status'>;
734
- export type FindForEditOptions<F = Record<string, any>> = Omit<FindOptions<F>, 'status'>;
735
- export type GetSubtreeForEditOptions<F = Record<string, any>> = Omit<GetSubtreeOptions<F>, 'status'>;
757
+ export type FindByIdForEditOptions<F = Record<string, any>> = Omit<FindByIdOptions<F>, 'status' | 'localeVisibility'>;
758
+ export type FindForEditOptions<F = Record<string, any>> = Omit<FindOptions<F>, 'status' | 'localeVisibility'>;
759
+ export type GetSubtreeForEditOptions<F = Record<string, any>> = Omit<GetSubtreeOptions<F>, 'status' | 'localeVisibility'>;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/client",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "6.6.0",
5
+ "version": "6.7.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -44,8 +44,8 @@
44
44
  ],
45
45
  "dependencies": {
46
46
  "uuid": "^14.0.2",
47
- "@byline/auth": "6.6.0",
48
- "@byline/core": "6.6.0"
47
+ "@byline/auth": "6.7.0",
48
+ "@byline/core": "6.7.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@biomejs/biome": "2.5.14",
@@ -57,8 +57,8 @@
57
57
  "tsc-alias": "^1.9.5",
58
58
  "typescript": "^7.0.2",
59
59
  "vitest": "^5.0.1",
60
- "@byline/db-postgres": "6.6.0",
61
- "@byline/search-postgres": "6.6.0"
60
+ "@byline/search-postgres": "6.7.0",
61
+ "@byline/db-postgres": "6.7.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public",