@byline/client 6.6.0 → 6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -48,6 +48,11 @@ 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. The provider `total`
53
+ * and facets pass through unless an actor-specific restriction applies
54
+ * (unreadable members or `beforeRead` scoping), as `CollectionHandle.search()`
55
+ * does.
51
56
  * For homogeneous, single-collection search use
52
57
  * `client.collection(path).search()`.
53
58
  */
package/dist/client.js CHANGED
@@ -122,6 +122,11 @@ 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. The provider `total`
127
+ * and facets pass through unless an actor-specific restriction applies
128
+ * (unreadable members or `beforeRead` scoping), as `CollectionHandle.search()`
129
+ * does.
125
130
  * For homogeneous, single-collection search use
126
131
  * `client.collection(path).search()`.
127
132
  */
@@ -45,25 +45,32 @@ 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
+ * - under `beforeRead` row scoping, `total` is the surviving hit count
63
+ * for this page and facets are omitted rather than leaking
64
+ * provider-wide aggregates of rows this actor can't see;
65
+ * - otherwise — including every unscoped public search — the provider
66
+ * `total` and facets pass through unchanged (public locale eligibility
67
+ * is actor-independent and applied at indexing, so a hit the re-check
68
+ * drops is a stale index entry, logged at warn level);
62
69
  * - a page of hits can come back shorter than `limit` when candidates
63
70
  * are dropped; paginate on `offset`, not on received length.
64
71
  *
65
72
  * `_bypassBeforeRead: true` is the same system-operation escape hatch the
66
- * read methods take.
73
+ * read methods take; it skips `beforeRead` scoping, never the locale check.
67
74
  *
68
75
  * Throws `ERR_VALIDATION` when no provider is registered.
69
76
  */
@@ -309,6 +316,17 @@ export declare class CollectionHandle<TFields extends Record<string, any> = Reco
309
316
  * Returns the resolved context so callers can thread `readMode` and
310
317
  * other per-request state into the adapter without re-resolving.
311
318
  */
319
+ /**
320
+ * Resolve the request context for a read and authorize it: the collection
321
+ * read ability (`assertActorCanPerform`) and the read's locale visibility
322
+ * (`assertLocaleVisibility`). Visibility defaults from `readMode`, so the
323
+ * editing, history and version reads (`readMode: 'any'`) are editorial.
324
+ *
325
+ * The visibility check validates the *effective* locale, the value every
326
+ * read method sends to storage (`locale ?? client.defaultLocale`), so a
327
+ * client configured with `defaultLocale: 'all'` cannot turn an omitted
328
+ * locale into a public all-locale read.
329
+ */
312
330
  private resolveAndAssertRead;
313
331
  /**
314
332
  * 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,32 @@ 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
+ * - under `beforeRead` row scoping, `total` is the surviving hit count
184
+ * for this page and facets are omitted rather than leaking
185
+ * provider-wide aggregates of rows this actor can't see;
186
+ * - otherwise — including every unscoped public search — the provider
187
+ * `total` and facets pass through unchanged (public locale eligibility
188
+ * is actor-independent and applied at indexing, so a hit the re-check
189
+ * drops is a stale index entry, logged at warn level);
180
190
  * - a page of hits can come back shorter than `limit` when candidates
181
191
  * are dropped; paginate on `offset`, not on received length.
182
192
  *
183
193
  * `_bypassBeforeRead: true` is the same system-operation escape hatch the
184
- * read methods take.
194
+ * read methods take; it skips `beforeRead` scoping, never the locale check.
185
195
  *
186
196
  * Throws `ERR_VALIDATION` when no provider is registered.
187
197
  */
@@ -212,9 +222,13 @@ export class CollectionHandle {
212
222
  limit: options.limit,
213
223
  offset: options.offset,
214
224
  });
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.
225
+ // Row-level authorization, exact-locale eligibility (+ optional
226
+ // hydration) — the shared finishing pipeline the zone entry point uses
227
+ // too. Public searches re-check every hit; an editorial search without a
228
+ // beforeRead predicate or hydration passes hits through untouched. Only
229
+ // actor-specific restriction (beforeRead) replaces the provider
230
+ // aggregate — public locale eligibility is actor-independent and already
231
+ // applied at indexing, so provider total and facets pass through.
218
232
  const hits = await finalizeSearchHits({
219
233
  client: this.client,
220
234
  requestContext,
@@ -223,6 +237,7 @@ export class CollectionHandle {
223
237
  status: options.status,
224
238
  hydrate: options.hydrate,
225
239
  bypassBeforeRead: options._bypassBeforeRead,
240
+ localeVisibility: resolveLocaleVisibility(readMode),
226
241
  readContext: readCtx,
227
242
  });
228
243
  return aggregateRestricted
@@ -251,9 +266,13 @@ export class CollectionHandle {
251
266
  return;
252
267
  const populate = this.buildSearchFacetPopulateMap();
253
268
  for (const locale of this.client.contentLocales) {
269
+ // Public delivery, pinned explicitly: the system client indexing here
270
+ // is authenticated, but only publicly eligible locale slices (source,
271
+ // or checked and complete) may enter the index.
254
272
  const view = await this.findById(documentId, {
255
273
  locale,
256
274
  status: 'published',
275
+ localeVisibility: 'public',
257
276
  onMissingLocale: 'omit',
258
277
  populate,
259
278
  _bypassBeforeRead: true,
@@ -360,7 +379,8 @@ export class CollectionHandle {
360
379
  async findById(documentId, options = {}) {
361
380
  const readMode = resolveReadMode(options.status);
362
381
  const readCtx = options._readContext ?? createReadContext();
363
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
382
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
383
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
364
384
  const { locale = this.client.defaultLocale } = options;
365
385
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
366
386
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -373,6 +393,7 @@ export class CollectionHandle {
373
393
  filters,
374
394
  lenient: options.lenient,
375
395
  onMissingLocale: options.onMissingLocale ?? 'fallback',
396
+ localeVisibility,
376
397
  });
377
398
  if (raw == null)
378
399
  return null;
@@ -385,7 +406,7 @@ export class CollectionHandle {
385
406
  ...options,
386
407
  _readContext: readCtx,
387
408
  });
388
- await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
409
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth), localeVisibility);
389
410
  return this.shapeWithPopulated(raw);
390
411
  }
391
412
  async findByIdForEdit(documentId, options = {}) {
@@ -453,7 +474,8 @@ export class CollectionHandle {
453
474
  async findByPath(path, options = {}) {
454
475
  const readMode = resolveReadMode(options.status);
455
476
  const readCtx = options._readContext ?? createReadContext();
456
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
477
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
478
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
457
479
  const { locale = this.client.defaultLocale } = options;
458
480
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
459
481
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -465,6 +487,7 @@ export class CollectionHandle {
465
487
  readMode,
466
488
  filters,
467
489
  onMissingLocale: options.onMissingLocale ?? 'fallback',
490
+ localeVisibility,
468
491
  });
469
492
  if (raw == null)
470
493
  return null;
@@ -475,7 +498,7 @@ export class CollectionHandle {
475
498
  ...options,
476
499
  _readContext: readCtx,
477
500
  });
478
- await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth));
501
+ await this.finishReadDocuments([raw], readCtx, requestContext, locale, readMode, options._bypassBeforeRead, options.select, readMaterialization(options.populate, options.depth), localeVisibility);
479
502
  return this.shapeWithPopulated(raw);
480
503
  }
481
504
  // -------------------------------------------------------------------------
@@ -817,7 +840,8 @@ export class CollectionHandle {
817
840
  this.assertTreeCollection();
818
841
  const readMode = resolveReadMode(options.status);
819
842
  const readCtx = options._readContext ?? createReadContext();
820
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
843
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
844
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
821
845
  const locale = options.locale ?? this.client.defaultLocale;
822
846
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
823
847
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -828,10 +852,11 @@ export class CollectionHandle {
828
852
  readMode,
829
853
  locale,
830
854
  filters,
855
+ localeVisibility,
831
856
  });
832
857
  if (structure.length === 0)
833
858
  return [];
834
- const shapedById = await this.hydrateTreeNodes(collectionId, structure.map((n) => n.document_id), locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead);
859
+ const shapedById = await this.hydrateTreeNodes(collectionId, structure.map((n) => n.document_id), locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead, localeVisibility);
835
860
  // Assemble the nested forest. Rows arrive pre-order, so a parent is always
836
861
  // materialised before its children; a row whose parent is not in the result
837
862
  // set (null parent, or the requested root's own parent) is a top-level node.
@@ -960,7 +985,8 @@ export class CollectionHandle {
960
985
  this.assertTreeCollection();
961
986
  const readMode = resolveReadMode(options.status);
962
987
  const readCtx = options._readContext ?? createReadContext();
963
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
988
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
989
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
964
990
  const locale = options.locale ?? this.client.defaultLocale;
965
991
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
966
992
  const collectionId = await this.client.resolveCollectionId(this.definition.path);
@@ -969,11 +995,12 @@ export class CollectionHandle {
969
995
  readMode,
970
996
  locale,
971
997
  filters,
998
+ localeVisibility,
972
999
  });
973
1000
  if (ancestors.length === 0)
974
1001
  return [];
975
1002
  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);
1003
+ const shapedById = await this.hydrateTreeNodes(collectionId, ids, locale, readMode, options.select, filters, readCtx, requestContext, options._bypassBeforeRead, localeVisibility);
977
1004
  // A hydration-time miss truncates the edge rather than compacting past a
978
1005
  // newly-hidden ancestor.
979
1006
  const visible = [];
@@ -1001,13 +1028,15 @@ export class CollectionHandle {
1001
1028
  this.assertTreeCollection();
1002
1029
  const readMode = resolveReadMode(options.status);
1003
1030
  const readCtx = options._readContext ?? createReadContext();
1004
- const requestContext = await this.resolveAndAssertRead(readMode, readCtx);
1031
+ const localeVisibility = resolveLocaleVisibility(options.status, options.localeVisibility);
1032
+ const requestContext = await this.resolveAndAssertRead(readMode, readCtx, localeVisibility, options.locale);
1005
1033
  const filters = await this.resolveBeforeReadFilters(requestContext, readCtx, options._bypassBeforeRead);
1006
1034
  const result = await this.client.db.queries.documents.getTreeParent({
1007
1035
  document_id: documentId,
1008
1036
  readMode,
1009
1037
  locale: options.locale ?? this.client.defaultLocale,
1010
1038
  filters,
1039
+ localeVisibility,
1011
1040
  });
1012
1041
  return {
1013
1042
  placed: result.placed,
@@ -1085,9 +1114,21 @@ export class CollectionHandle {
1085
1114
  * Returns the resolved context so callers can thread `readMode` and
1086
1115
  * other per-request state into the adapter without re-resolving.
1087
1116
  */
1088
- async resolveAndAssertRead(readMode, readContext) {
1117
+ /**
1118
+ * Resolve the request context for a read and authorize it: the collection
1119
+ * read ability (`assertActorCanPerform`) and the read's locale visibility
1120
+ * (`assertLocaleVisibility`). Visibility defaults from `readMode`, so the
1121
+ * editing, history and version reads (`readMode: 'any'`) are editorial.
1122
+ *
1123
+ * The visibility check validates the *effective* locale, the value every
1124
+ * read method sends to storage (`locale ?? client.defaultLocale`), so a
1125
+ * client configured with `defaultLocale: 'all'` cannot turn an omitted
1126
+ * locale into a public all-locale read.
1127
+ */
1128
+ async resolveAndAssertRead(readMode, readContext, localeVisibility = resolveLocaleVisibility(readMode), locale) {
1089
1129
  const requestContext = await resolveReadRequestContext(this.client, readContext, readMode);
1090
1130
  assertActorCanPerform(requestContext, this.definition, 'read');
1131
+ assertLocaleVisibility(requestContext, this.definition.path, localeVisibility, locale ?? this.client.defaultLocale);
1091
1132
  return requestContext;
1092
1133
  }
1093
1134
  /**
@@ -1113,7 +1154,7 @@ export class CollectionHandle {
1113
1154
  * `afterRead` per node (deduped within one shared `ReadContext`) to stay
1114
1155
  * consistent with the other client read paths.
1115
1156
  */
1116
- async hydrateTreeNodes(collectionId, documentIds, locale, readMode, select, filters, readContext, requestContext, bypassBeforeRead) {
1157
+ async hydrateTreeNodes(collectionId, documentIds, locale, readMode, select, filters, readContext, requestContext, bypassBeforeRead, localeVisibility = resolveLocaleVisibility(readMode)) {
1117
1158
  const shapedById = new Map();
1118
1159
  if (documentIds.length === 0)
1119
1160
  return shapedById;
@@ -1124,8 +1165,9 @@ export class CollectionHandle {
1124
1165
  readMode,
1125
1166
  fields: select,
1126
1167
  filters,
1168
+ localeVisibility,
1127
1169
  });
1128
- await this.finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, select, 'tree');
1170
+ await this.finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, select, 'tree', localeVisibility);
1129
1171
  for (const raw of rawDocs) {
1130
1172
  const doc = raw;
1131
1173
  shapedById.set(doc.document_id, this.shapeWithPopulated(doc));
@@ -1153,6 +1195,9 @@ export class CollectionHandle {
1153
1195
  depth: options.depth,
1154
1196
  locale,
1155
1197
  readMode,
1198
+ // Equals the entry point's resolved visibility: an explicit option wins,
1199
+ // otherwise the default follows the same status that selected `readMode`.
1200
+ localeVisibility: options.localeVisibility ?? resolveLocaleVisibility(readMode),
1156
1201
  readContext: options._readContext,
1157
1202
  requestContext,
1158
1203
  securityDomain: getReadSecurityDomain(this.client),
@@ -1169,7 +1214,7 @@ export class CollectionHandle {
1169
1214
  * `populateRelationsOnRead` inside the service. No-ops when no adapter
1170
1215
  * is registered.
1171
1216
  */
1172
- async richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead) {
1217
+ async richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, localeVisibility) {
1173
1218
  const populate = this.client.richTextPopulate;
1174
1219
  if (!populate || rawDocs.length === 0)
1175
1220
  return;
@@ -1187,6 +1232,7 @@ export class CollectionHandle {
1187
1232
  requestContext,
1188
1233
  readContext,
1189
1234
  readMode,
1235
+ localeVisibility,
1190
1236
  locale,
1191
1237
  bypassBeforeRead,
1192
1238
  richTextPopulate: populate,
@@ -1195,10 +1241,10 @@ export class CollectionHandle {
1195
1241
  });
1196
1242
  }
1197
1243
  /** Complete the common post-reconstruction pipeline in security order. */
1198
- async finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, projection, materialization) {
1244
+ async finishReadDocuments(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, projection, materialization, localeVisibility = resolveLocaleVisibility(readMode)) {
1199
1245
  // Rich-text targets must be authorised and refreshed before user hooks see
1200
1246
  // the document, regardless of which read entry point materialised it.
1201
- await this.richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead);
1247
+ await this.richTextPopulateSources(rawDocs, readContext, requestContext, locale, readMode, bypassBeforeRead, localeVisibility);
1202
1248
  for (const doc of rawDocs) {
1203
1249
  await applyAfterRead({
1204
1250
  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,21 @@
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.
20
+ *
21
+ * Aggregates: the provider's `total` and facets pass through unless an
22
+ * *actor-specific* restriction applies (a `beforeRead` predicate, or zone
23
+ * members the actor cannot read) — then the restricted-result convention
24
+ * (retained-hit `total`, no facets) prevents leaking counts of rows this
25
+ * reader can't see. Public locale eligibility is actor-independent and is
26
+ * the same rule indexing applies, so the provider aggregate already is the
27
+ * public aggregate; a stale entry the re-check drops is an index-maintenance
28
+ * lag, logged at warn level, not a reason to degrade the response.
18
29
  */
19
30
  import type { RequestContext } from '@byline/auth';
20
- import type { ReadContext, ReadMode, SearchHit, SearchProvider } from '@byline/core';
31
+ import type { LocaleVisibility, ReadContext, ReadMode, SearchHit, SearchProvider } from '@byline/core';
21
32
  import type { BylineClient } from './client.js';
22
33
  import type { ClientSearchResults, HydratedSearchHit, ZoneSearchOptions } from './types.js';
23
34
  /** Validate the optional logical body-field scope before calling a provider. */
@@ -30,6 +41,11 @@ export interface FinalizeSearchHitsParams {
30
41
  status?: ReadMode;
31
42
  hydrate?: boolean;
32
43
  bypassBeforeRead?: true;
44
+ /**
45
+ * The search's locale visibility (derived from `status`). Under `'public'`
46
+ * each hit's document must be publicly eligible in the hit's locale.
47
+ */
48
+ localeVisibility: LocaleVisibility;
33
49
  /** Shared per-request read context (beforeRead cache). Created when omitted. */
34
50
  readContext?: ReadContext;
35
51
  }
@@ -42,9 +58,19 @@ export interface FinalizeSearchHitsParams {
42
58
  * hydration cost one query per collection. Hits whose document doesn't
43
59
  * come back — dropped by scoping, or a stale index entry whose document
44
60
  * 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).
61
+ * - `hydrate` off, public visibility — every collection's hit ids are
62
+ * re-resolved through the normal read path with `onMissingLocale:
63
+ * 'omit'` (identity-field projection), which applies both exact-locale
64
+ * eligibility and any `beforeRead` scoping. An index entry can outlive
65
+ * the checkbox that allowed it (until reindexing catches up), so this
66
+ * check runs even without a hook.
67
+ * - `hydrate` off, editorial visibility — collections with a `beforeRead`
68
+ * predicate get the trimmed id re-resolution; collections without one
69
+ * pass through untouched (no second query).
70
+ *
71
+ * Hydration also reads with `'omit'`: a hit is for one locale slice, and a
72
+ * source-language document substituted by fallback would not make that hit
73
+ * safe.
48
74
  *
49
75
  * Hits whose `collectionPath` isn't registered in the runtime config are
50
76
  * dropped with a debug log (an index can outlive a collection). Original
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,16 +30,26 @@ 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;
43
53
  if (hits.length === 0)
44
54
  return hits;
45
55
  const readCtx = params.readContext ?? createReadContext();
@@ -78,12 +88,35 @@ export async function finalizeSearchHits(params) {
78
88
  select,
79
89
  locale,
80
90
  status,
91
+ onMissingLocale: 'omit',
92
+ localeVisibility,
81
93
  page: 1,
82
94
  pageSize: ids.length,
83
95
  _readContext: readCtx,
84
96
  ...(bypassBeforeRead ? { _bypassBeforeRead: true } : {}),
85
97
  });
86
98
  documentsByCollection.set(collectionPath, new Map(result.docs.map((d) => [d.id, d])));
99
+ await warnOnStaleHits(params, readCtx, collectionPath, group, new Set(result.docs.map((d) => d.id)));
100
+ continue;
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
+ const allowed = new Set(result.docs.map((d) => d.id));
118
+ allowedByCollection.set(collectionPath, allowed);
119
+ await warnOnStaleHits(params, readCtx, collectionPath, group, allowed);
87
120
  continue;
88
121
  }
89
122
  if (bypassBeforeRead) {
@@ -129,6 +162,30 @@ export async function finalizeSearchHits(params) {
129
162
  }
130
163
  return finished;
131
164
  }
165
+ /**
166
+ * Warn about hits the re-check dropped for a reason other than `beforeRead`
167
+ * scoping — a stale index entry (deleted document, withdrawn translation,
168
+ * unpublished version) that index maintenance hasn't caught up with yet.
169
+ * Drops under an active `beforeRead` predicate are expected and not logged.
170
+ */
171
+ async function warnOnStaleHits(params, readCtx, collectionPath, group, resolved) {
172
+ const dropped = group.filter((h) => !resolved.has(h.documentId));
173
+ if (dropped.length === 0)
174
+ return;
175
+ const { client, requestContext, bypassBeforeRead } = params;
176
+ if (!bypassBeforeRead) {
177
+ const definition = client.collections.find((c) => c.path === collectionPath);
178
+ const filters = definition == null
179
+ ? null
180
+ : await resolveReadSecurityFilters(client, definition, requestContext, readCtx);
181
+ if (filters != null)
182
+ return;
183
+ }
184
+ client.logger.warn({
185
+ collectionPath,
186
+ dropped: dropped.map((h) => ({ documentId: h.documentId, locale: h.locale })),
187
+ }, 'search: dropped index entries that no longer resolve for this read; reindex the affected documents');
188
+ }
132
189
  // ---------------------------------------------------------------------------
133
190
  // Zone (cross-collection) entry point
134
191
  // ---------------------------------------------------------------------------
@@ -211,6 +268,7 @@ export async function zoneSearch(client, options) {
211
268
  status: options.status,
212
269
  hydrate: options.hydrate,
213
270
  bypassBeforeRead: options._bypassBeforeRead,
271
+ localeVisibility: resolveLocaleVisibility(readMode),
214
272
  readContext: readCtx,
215
273
  });
216
274
  return aggregateRestricted
@@ -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.1",
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.1",
48
+ "@byline/core": "6.7.1"
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/db-postgres": "6.7.1",
61
+ "@byline/search-postgres": "6.7.1"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public",