From 966619942c31b7a9e365ae24dc3b32407af45fc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 20:37:23 +0000 Subject: [PATCH 1/8] docs: record YPE-4657 usable-version filter decisions Grill-shaped glossary and ADR so implementation uses Swift names without copying Swift's picker-vs-text split. Co-authored-by: Cameron Pak --- CONTEXT.md | 60 ++++++++++++++++++++ docs/adr/YPE-4657-usable-bible-version.md | 67 +++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 docs/adr/YPE-4657-usable-bible-version.md diff --git a/CONTEXT.md b/CONTEXT.md index df3975c7..204b4232 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,6 +27,66 @@ calls this `version_id`; the highlights wire API calls the same value `bible_id`. The SDK name is canonical in public types; mapping happens at the API boundary only. +A Bible version is **usable** for an app only when it satisfies the +integrator **version filter**. An unusable id is not a Bible version this +app may use: lists omit it, pickers omit it, restored recents omit it, and +content fetches refuse it. The host passing `versionId` does not override +the filter. + +## Version filter + +Integrator policy (YPE-4657) for which Bible versions this app may use, +set on `YouVersionProvider` and stored on +`YouVersionPlatformConfiguration` (same path as `appName` / +`signInPromptMessage`). Three optional lists, Swift names, React `Id` +spelling: + +- `permittedVersionIds` — allowlist of Bible version ids +- `excludedVersionIds` — denylist of Bible version ids +- `permittedLanguageTags` — allowlist of BCP 47 tags (`en`, `zh-Hans`). + This is `BibleVersion.language_tag` / `Language.id`, not a numeric id. + _Avoid_: `permittedLanguageIDs`, `permittedLanguageIds` (ticket draft; + Swift and this SDK both say tags) + +Unset (`undefined`) on a permit list means no restriction on that +dimension. An empty permit list (`[]`) permits nothing. Unset or empty +`excludedVersionIds` excludes nothing. + +A version is usable when: it is not in `excludedVersionIds`, and (if +`permittedVersionIds` is set) its id is in that list, and (if +`permittedLanguageTags` is set) its `language_tag` is in that list. The +two allowlists combine with AND. Exclusion wins if an id is in both +permit and exclude lists. + +`versionId: number` stays a number. Types cannot enforce the filter +(runtime lists; `Exclude` is still `number`). + +## Version refuse + +What the SDK does with an unusable Bible version (YPE-4657). One rule +for every surface (reader, card, text, VOTD) and for core/hooks. + +Core throws before returning scripture (and before the network when the +id alone decides). The error carries `status: 403` so existing UI maps +it to `forbiddenError` ("This app isn't allowed to access this Bible +content."). Hooks surface that `error`. UI does not throw, does not +render an empty tree, does not swap in another version. Dev: one +`console.warn` for a host-passed unusable `versionId`. + +Language-only allowlists need `language_tag`. Content methods may fetch +version metadata once; if metadata is missing, refuse (fail closed). +`getVersion` itself is refused when the id is already unusable without +the tag. + +Filtered `getVersions` / `getLanguages` pages must not hide a usable +row that exists on a later server page of the same query. Picker recents +are hidden at read time; localStorage is not rewritten, so lifting the +filter restores them. + +_Avoid_: picker-only policy (hide in the chooser, still render +`versionId`); silent fallback to `DEFAULT_LICENSE_FREE_BIBLE_VERSION`; +opening the picker as the refuse (text/VOTD have none). + ## Color A highlight's fill, a 6-character lowercase hex string without `#` diff --git a/docs/adr/YPE-4657-usable-bible-version.md b/docs/adr/YPE-4657-usable-bible-version.md new file mode 100644 index 00000000..a762618e --- /dev/null +++ b/docs/adr/YPE-4657-usable-bible-version.md @@ -0,0 +1,67 @@ +# YPE-4657 — Usable Bible version (version filter) + +Status: **Accepted** (grilling session 2026-08-18) +Component: `@youversion/platform-core` (predicate + client refuse) → hooks inherit → UI error path +Epic: YPE-1315 (React-SDK v2) +Sister: Swift `permittedVersionIds` / `excludedVersionIds` / `permittedLanguageTags` +Related: [Swift version picker docs](https://developers.youversion.com/sdks/swift/components) + +This ADR records the ticket shape. Public types and tests own the API +once implemented. Glossary: `CONTEXT.md` (**version filter**, **version +refuse**, usable **Bible version**). + +## Context + +Swift lets an integrator limit which Bible versions the SDK may use. +YPE-4657 adds the same capability to React. David’s bar: as close to +Swift as possible, without making React worse to comply. + +Swift’s public docs talk about limiting the **version picker**. Swift’s +reader also drops unusable ids from restore and fallback, then opens the +picker if nothing remains. `BibleTextView` does **not** apply the client +filter; it fetches the host `versionId` and only shows “unavailable” on +server 401. + +React has reader, card, text, and VOTD. Text and VOTD have no picker. +Copying Swift’s split would mean two refuse rules. Types cannot close +the hole: filters are runtime lists, every prop is `versionId: number`, +and `Exclude` is still `number`. + +## Decisions + +- **Usable-version policy, one refuse.** An unusable id is not a Bible + version this app may use. Lists, pickers, recents, and content fetches + all refuse. The host passing `versionId` does not override the filter. +- **Names.** `permittedVersionIds`, `excludedVersionIds`, + `permittedLanguageTags`. Swift’s words, React’s `Id` (not `IDs`). + Ticket draft `permittedLanguageIDs` is rejected: `Language.id` is + already a BCP 47 tag; `BibleVersion.language_tag` is the field. +- **Predicate.** Unset permit list = no restriction. Empty permit list = + permit nothing. Unset/empty exclude = exclude nothing. Allowlists AND. + Exclusion wins. Same as Swift. +- **Config path.** Optional `YouVersionProvider` props, written onto + `YouVersionPlatformConfiguration` (same as `appName` / + `signInPromptMessage`). No new `configure()`. Core-only hosts set the + statics. UI provider must mirror onto its bundled core copy. Filters + must be visible to the first `BibleClient` call after mount. +- **Refuse presentation.** Core throws `Error` with `status: 403` before + returning scripture (no network when the id alone decides). Hooks pass + `error` through. UI reuses `VerseUnavailableMessage` / + `forbiddenError`. No throw from UI, no blank tree, no silent fallback + to `DEFAULT_LICENSE_FREE_BIBLE_VERSION`. One dev `console.warn` for a + host-passed unusable id. +- **Language allowlist.** Content methods may `getVersion` once for + `language_tag`, then refuse or continue. Missing metadata = refuse. + `getVersion` is itself refused when the id is unusable without the tag. +- **Lists.** Filtered pages must not drop a usable row that lives on a + later server page of the same query. Recents: hide at read time; do + not rewrite localStorage. +- **Out of scope for v1.** Branded/`as const` `versionId` generics. + Swift’s “open the picker when fallbacks fail.” + +## Why not Swift’s split + +React’s BibleText / VOTD cannot open a picker. A picker-only rule would +also leak scripture through `usePassage` / `BibleClient`. One core +refuse is the smaller surface and matches “this app may not use that +version.” From 1cd2a513d9fead087f1bc3991737f0803c1e1df7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 20:46:03 +0000 Subject: [PATCH 2/8] feat: add usable Bible version filters (YPE-4657) Apps can limit which Bible versions the SDK uses via permittedVersionIds, excludedVersionIds, and permittedLanguageTags. Core refuses unusable ids with 403; lists walk pages; UI reuses the forbidden error and hides filtered recents without rewriting storage. --- .changeset/usable-bible-version-filter.md | 7 + packages/core/README.md | 11 +- .../src/YouVersionPlatformConfiguration.ts | 39 +++++ .../YouVersionPlatformConfiguration.test.ts | 3 + .../__tests__/bible-version-filters.test.ts | 133 ++++++++++++++++++ packages/core/src/bible.ts | 61 +++++++- packages/core/src/highlights.ts | 24 +++- packages/core/src/index.ts | 11 ++ packages/core/src/languages.ts | 31 +++- packages/core/src/version-filters.test.ts | 117 +++++++++++++++ packages/core/src/version-filters.ts | 104 ++++++++++++++ packages/hooks/README.md | 10 +- .../src/context/YouVersionProvider.test.tsx | 24 ++++ .../hooks/src/context/YouVersionProvider.tsx | 23 +++ packages/ui/README.md | 17 +++ .../ui/src/components/YouVersionProvider.tsx | 7 +- .../components/bible-version-picker.test.tsx | 42 +++++- .../src/components/bible-version-picker.tsx | 34 +++-- packages/ui/src/components/verse.tsx | 2 + .../lib/use-version-filter-warning.test.ts | 26 ++++ .../ui/src/lib/use-version-filter-warning.ts | 18 +++ 21 files changed, 728 insertions(+), 16 deletions(-) create mode 100644 .changeset/usable-bible-version-filter.md create mode 100644 packages/core/src/__tests__/bible-version-filters.test.ts create mode 100644 packages/core/src/version-filters.test.ts create mode 100644 packages/core/src/version-filters.ts create mode 100644 packages/ui/src/lib/use-version-filter-warning.test.ts create mode 100644 packages/ui/src/lib/use-version-filter-warning.ts diff --git a/.changeset/usable-bible-version-filter.md b/.changeset/usable-bible-version-filter.md new file mode 100644 index 00000000..587b3daf --- /dev/null +++ b/.changeset/usable-bible-version-filter.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-core': minor +'@youversion/platform-react-hooks': minor +'@youversion/platform-react-ui': minor +--- + +Add `permittedVersionIds`, `excludedVersionIds`, and `permittedLanguageTags` so apps can limit which Bible versions the SDK uses (YPE-4657). diff --git a/packages/core/README.md b/packages/core/README.md index 0a0f845e..2fea9b66 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -27,13 +27,22 @@ Get your App Key at [platform.youversion.com](https://platform.youversion.com/) ## Usage ```ts -import { ApiClient, BibleClient } from '@youversion/platform-core'; +import { + ApiClient, + BibleClient, + YouVersionPlatformConfiguration, +} from '@youversion/platform-core'; const apiClient = new ApiClient({ appKey: 'YOUR_APP_KEY', }); const bibleClient = new BibleClient(apiClient); +// Optional: limit which Bible versions this app may use (YPE-4657) +YouVersionPlatformConfiguration.permittedLanguageTags = ['en']; +YouVersionPlatformConfiguration.permittedVersionIds = [111, 3034]; +YouVersionPlatformConfiguration.excludedVersionIds = [4212]; + // Find available Bible versions in English const versions = await bibleClient.getVersions('en*'); console.log(versions.data[0].title); diff --git a/packages/core/src/YouVersionPlatformConfiguration.ts b/packages/core/src/YouVersionPlatformConfiguration.ts index 126c99b4..711a9e7b 100644 --- a/packages/core/src/YouVersionPlatformConfiguration.ts +++ b/packages/core/src/YouVersionPlatformConfiguration.ts @@ -18,6 +18,9 @@ export class YouVersionPlatformConfiguration { private static _expiryDateKey: string | null = null; private static _signInPromptMessage: string | undefined = undefined; private static _appName: string | undefined = undefined; + private static _permittedVersionIds: number[] | undefined = undefined; + private static _excludedVersionIds: number[] | undefined = undefined; + private static _permittedLanguageTags: string[] | undefined = undefined; private static getOrSetInstallationId(): string { const storage = getLocalStorage(); @@ -328,4 +331,40 @@ export class YouVersionPlatformConfiguration { static set appName(value: string | undefined) { this._appName = value; } + + /** + * Allowlist of Bible version ids this app may use. Unset = no restriction. + * An empty array permits nothing. See YPE-4657. + */ + static get permittedVersionIds(): number[] | undefined { + return this._permittedVersionIds; + } + + static set permittedVersionIds(value: number[] | undefined) { + this._permittedVersionIds = value; + } + + /** + * Denylist of Bible version ids this app may not use. Unset or empty = + * exclude nothing. Exclusion wins over `permittedVersionIds`. + */ + static get excludedVersionIds(): number[] | undefined { + return this._excludedVersionIds; + } + + static set excludedVersionIds(value: number[] | undefined) { + this._excludedVersionIds = value; + } + + /** + * Allowlist of BCP 47 language tags (`en`, `zh-Hans`). Unset = no + * restriction. An empty array permits nothing. + */ + static get permittedLanguageTags(): string[] | undefined { + return this._permittedLanguageTags; + } + + static set permittedLanguageTags(value: string[] | undefined) { + this._permittedLanguageTags = value; + } } diff --git a/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts b/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts index 3cd11f60..8c96f9e4 100644 --- a/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts +++ b/packages/core/src/__tests__/YouVersionPlatformConfiguration.test.ts @@ -51,6 +51,9 @@ describe('YouVersionPlatformConfiguration', () => { YouVersionPlatformConfiguration.installationId = null; YouVersionPlatformConfiguration.clearAuthTokens(); YouVersionPlatformConfiguration.apiHost = envApiHost; + YouVersionPlatformConfiguration.permittedVersionIds = undefined; + YouVersionPlatformConfiguration.excludedVersionIds = undefined; + YouVersionPlatformConfiguration.permittedLanguageTags = undefined; }); afterEach(() => { diff --git a/packages/core/src/__tests__/bible-version-filters.test.ts b/packages/core/src/__tests__/bible-version-filters.test.ts new file mode 100644 index 00000000..1a06c731 --- /dev/null +++ b/packages/core/src/__tests__/bible-version-filters.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { ApiClient } from '../client'; +import { BibleClient } from '../bible'; +import { LanguagesClient } from '../languages'; +import { HighlightsClient } from '../highlights'; +import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration'; +import { server } from './setup'; + +const apiHost = process.env.YVP_API_HOST || 'api.youversion.com'; + +function bibleClient(): BibleClient { + return new BibleClient( + new ApiClient({ + apiHost, + appKey: 'test-app', + installationId: 'test-installation', + }), + ); +} + +function clearFilters(): void { + YouVersionPlatformConfiguration.permittedVersionIds = undefined; + YouVersionPlatformConfiguration.excludedVersionIds = undefined; + YouVersionPlatformConfiguration.permittedLanguageTags = undefined; +} + +describe('BibleClient version filter', () => { + it('refuses an excluded version before fetching and walks pages for usable rows', async () => { + YouVersionPlatformConfiguration.excludedVersionIds = [111]; + YouVersionPlatformConfiguration.permittedVersionIds = [111, 206]; + + let passageCalls = 0; + server.use( + http.get(`https://${apiHost}/v1/bibles/111/passages/:usfm`, () => { + passageCalls += 1; + return HttpResponse.json({ content: 'leaked', reference: 'John 3:16' }); + }), + ); + + await expect(bibleClient().getPassage(111, 'JHN.3.16', 'text')).rejects.toMatchObject({ + status: 403, + }); + expect(passageCalls).toBe(0); + + clearFilters(); + YouVersionPlatformConfiguration.permittedVersionIds = [3, 4]; + + const pages: Record< + string, + { data: { id: number; language_tag: string }[]; next_page_token: string | null } + > = { + '': { + data: [ + { id: 1, language_tag: 'en' }, + { id: 2, language_tag: 'en' }, + ], + next_page_token: 'p2', + }, + p2: { + data: [ + { id: 3, language_tag: 'en' }, + { id: 5, language_tag: 'en' }, + ], + next_page_token: 'p3', + }, + p3: { data: [{ id: 4, language_tag: 'en' }], next_page_token: null }, + }; + + server.use( + http.get(`https://${apiHost}/v1/bibles`, ({ request }) => { + const token = new URL(request.url).searchParams.get('page_token') ?? ''; + return HttpResponse.json(pages[token] ?? { data: [], next_page_token: null }); + }), + ); + + const versions = await bibleClient().getVersions('en*', undefined, { page_size: 2 }); + expect(versions.data.map((version) => version.id)).toEqual([3, 4]); + + clearFilters(); + }); + + it('fetches version metadata for a language allowlist, then refuses the wrong tag', async () => { + YouVersionPlatformConfiguration.permittedLanguageTags = ['en']; + + server.use( + http.get(`https://${apiHost}/v1/bibles/206`, () => + HttpResponse.json({ + id: 206, + language_tag: 'es', + abbreviation: 'NVI', + localized_abbreviation: 'NVI', + localized_title: 'NVI', + title: 'NVI', + books: ['GEN'], + youversion_deep_link: 'https://bible.com/versions/206', + }), + ), + ); + + await expect(bibleClient().getVersion(206)).rejects.toMatchObject({ status: 403 }); + await expect(bibleClient().getPassage(206, 'JHN.3.16', 'text')).rejects.toMatchObject({ + status: 403, + }); + + const languages = new LanguagesClient( + new ApiClient({ apiHost, appKey: 'test-app', installationId: 'test-installation' }), + ); + server.use( + http.get(`https://${apiHost}/v1/languages`, () => + HttpResponse.json({ + data: [ + { id: 'en', language: 'en' }, + { id: 'es', language: 'es' }, + ], + next_page_token: null, + }), + ), + ); + const filtered = await languages.getLanguages(); + expect(filtered.data.map((language) => language.id)).toEqual(['en']); + + YouVersionPlatformConfiguration.excludedVersionIds = [111]; + const highlights = new HighlightsClient( + new ApiClient({ apiHost, appKey: 'test-app', installationId: 'test-installation' }), + ); + await expect( + highlights.getHighlights({ version_id: 111, passage_id: 'JHN.3' }, 'token'), + ).rejects.toMatchObject({ status: 403 }); + + clearFilters(); + }); +}); diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index 6cf1fa16..085dca43 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -2,6 +2,15 @@ import { z } from 'zod'; import type { ApiClient } from './client'; import { transformBibleHtml, type TransformBibleHtmlOptions } from './bible-html-transformer'; import { BibleVersionSchema } from './schemas'; +import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration'; +import { + collectFilteredPage, + fieldsNeededForVersionFilter, + isUsableBibleVersion, + isVersionFilterActive, + isVersionIdDecidablyUnusable, + throwUnusableBibleVersion, +} from './version-filters'; import type { BibleBook, BibleChapter, @@ -145,7 +154,31 @@ export class BibleClient { if (options?.all_available) { params.all_available = 'true'; } - return this.client.get>(`/v1/bibles`, params); + + const filterFields = fieldsNeededForVersionFilter(options?.fields); + let pageSize = options?.page_size; + if (filterFields) { + params['fields[]'] = filterFields; + if (pageSize === '*' && filterFields.length > 3) { + delete params.page_size; + pageSize = undefined; + } + } + + const fetchPage = (pageToken?: string) => { + const pageParams = pageToken ? { ...params, page_token: pageToken } : params; + return this.client.get>(`/v1/bibles`, pageParams); + }; + + if (!isVersionFilterActive()) { + return fetchPage(options?.page_token); + } + + return collectFilteredPage( + (pageToken) => fetchPage(pageToken ?? options?.page_token), + (version) => isUsableBibleVersion({ id: version.id, languageTag: version.language_tag }), + pageSize, + ); } /** @@ -155,7 +188,23 @@ export class BibleClient { */ async getVersion(id: number): Promise { BibleClient.versionIdSchema.parse(id); - return this.client.get(`/v1/bibles/${id}`); + if (isVersionIdDecidablyUnusable(id)) { + throwUnusableBibleVersion(); + } + const version = await this.client.get(`/v1/bibles/${id}`); + if (!isUsableBibleVersion({ id: version.id, languageTag: version.language_tag })) { + throwUnusableBibleVersion(); + } + return version; + } + + private async assertUsableVersion(versionId: number): Promise { + if (isVersionIdDecidablyUnusable(versionId)) { + throwUnusableBibleVersion(); + } + if (YouVersionPlatformConfiguration.permittedLanguageTags !== undefined) { + await this.getVersion(versionId); + } } /** @@ -168,6 +217,7 @@ export class BibleClient { */ async getBooks(versionId: number, canon?: CANON): Promise> { BibleClient.versionIdSchema.parse(versionId); + await this.assertUsableVersion(versionId); return this.client.get>(`/v1/bibles/${versionId}/books`, { ...(canon && { canon }), }); @@ -184,6 +234,7 @@ export class BibleClient { async getBook(versionId: number, book: string): Promise { BibleClient.versionIdSchema.parse(versionId); BibleClient.bookSchema.parse(book); + await this.assertUsableVersion(versionId); return this.client.get(`/v1/bibles/${versionId}/books/${book}`); } @@ -196,6 +247,7 @@ export class BibleClient { async getChapters(versionId: number, book: string): Promise> { BibleClient.versionIdSchema.parse(versionId); BibleClient.bookSchema.parse(book); + await this.assertUsableVersion(versionId); return this.client.get>( `/v1/bibles/${versionId}/books/${book}/chapters`, ); @@ -212,6 +264,7 @@ export class BibleClient { BibleClient.versionIdSchema.parse(versionId); BibleClient.bookSchema.parse(book); BibleClient.chapterSchema.parse(chapter); + await this.assertUsableVersion(versionId); return this.client.get( `/v1/bibles/${versionId}/books/${book}/chapters/${chapter}`, @@ -233,6 +286,7 @@ export class BibleClient { BibleClient.versionIdSchema.parse(versionId); BibleClient.bookSchema.parse(book); BibleClient.chapterSchema.parse(chapter); + await this.assertUsableVersion(versionId); return this.client.get>( `/v1/bibles/${versionId}/books/${book}/chapters/${chapter}/verses`, @@ -257,6 +311,7 @@ export class BibleClient { BibleClient.bookSchema.parse(book); BibleClient.chapterSchema.parse(chapter); BibleClient.verseSchema.parse(verse); + await this.assertUsableVersion(versionId); return this.client.get( `/v1/bibles/${versionId}/books/${book}/chapters/${chapter}/verses/${verse}`, @@ -325,6 +380,7 @@ export class BibleClient { if (include_notes !== undefined) { params.include_notes = include_notes; } + await this.assertUsableVersion(versionId); const passage = await this.client.get( `/v1/bibles/${versionId}/passages/${usfm}`, params, @@ -346,6 +402,7 @@ export class BibleClient { */ async getIndex(versionId: number): Promise { BibleClient.versionIdSchema.parse(versionId); + await this.assertUsableVersion(versionId); return this.client.get(`/v1/bibles/${versionId}/index`); } diff --git a/packages/core/src/highlights.ts b/packages/core/src/highlights.ts index a9459354..51a809a5 100644 --- a/packages/core/src/highlights.ts +++ b/packages/core/src/highlights.ts @@ -1,12 +1,18 @@ import { z } from 'zod'; import type { ApiClient } from './client'; -import type { Collection, Highlight, CreateHighlight } from './types'; +import type { BibleVersion, Collection, CreateHighlight, Highlight } from './types'; import { resolveAuthToken } from './auth-token'; import { HighlightCollectionWireSchema, HighlightWireSchema, toHighlight, } from './schemas/highlight'; +import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration'; +import { + isUsableBibleVersion, + isVersionIdDecidablyUnusable, + throwUnusableBibleVersion, +} from './version-filters'; /** * Options for getting highlights. @@ -86,6 +92,19 @@ export class HighlightsClient { } } + private async assertUsableVersion(versionId: number): Promise { + if (isVersionIdDecidablyUnusable(versionId)) { + throwUnusableBibleVersion(); + } + if (YouVersionPlatformConfiguration.permittedLanguageTags === undefined) { + return; + } + const version = await this.client.get(`/v1/bibles/${versionId}`); + if (!isUsableBibleVersion({ id: version.id, languageTag: version.language_tag })) { + throwUnusableBibleVersion(); + } + } + private validateColor(value: string): void { try { this.colorSchema.parse(value); @@ -123,6 +142,7 @@ export class HighlightsClient { async getHighlights(options: GetHighlightsOptions, lat?: string): Promise> { this.validateVersionId(options.version_id); this.validatePassageId(options.passage_id); + await this.assertUsableVersion(options.version_id); const response = await this.client.get( `/v1/highlights`, @@ -160,6 +180,7 @@ export class HighlightsClient { this.validateVersionId(data.version_id); this.validatePassageId(data.passage_id); this.validateColor(data.color); + await this.assertUsableVersion(data.version_id); const response = await this.client.post( `/v1/highlights`, @@ -206,6 +227,7 @@ export class HighlightsClient { ): Promise { this.validatePassageId(passageId); this.validateVersionId(options.version_id); + await this.assertUsableVersion(options.version_id); await this.client.delete( `/v1/highlights/${encodeURIComponent(passageId)}`, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a3aac545..3b661c46 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,17 @@ export * from './YouVersionUserInfo'; export * from './SignInWithYouVersionResult'; export * from './YouVersionAPI'; export * from './YouVersionPlatformConfiguration'; +export { + collectFilteredPage, + fieldsNeededForLanguageFilter, + fieldsNeededForVersionFilter, + isUsableBibleVersion, + isUsableLanguageTag, + isVersionFilterActive, + isVersionIdDecidablyUnusable, + throwUnusableBibleVersion, + type VersionFilterCandidate, +} from './version-filters'; export * from './types'; export * from './utils/constants'; export { getAdjacentChapter } from './getAdjacentChapter'; diff --git a/packages/core/src/languages.ts b/packages/core/src/languages.ts index 1bfa5950..ced689df 100644 --- a/packages/core/src/languages.ts +++ b/packages/core/src/languages.ts @@ -2,6 +2,12 @@ import { z } from 'zod'; import type { ApiClient } from './client'; import type { Collection, Language } from './types'; import { LanguageSchema } from './schemas'; +import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration'; +import { + collectFilteredPage, + fieldsNeededForLanguageFilter, + isUsableLanguageTag, +} from './version-filters'; /** * Options for getting languages collection. @@ -98,7 +104,30 @@ export class LanguagesClient { params.page_token = options.page_token; } - return this.client.get>(`/v1/languages`, params); + const filterFields = fieldsNeededForLanguageFilter(options.fields); + let pageSize = options.page_size; + if (filterFields) { + params['fields[]'] = filterFields; + if (pageSize === '*' && filterFields.length > 3) { + delete params.page_size; + pageSize = undefined; + } + } + + const fetchPage = (pageToken?: string) => { + const pageParams = pageToken ? { ...params, page_token: pageToken } : params; + return this.client.get>(`/v1/languages`, pageParams); + }; + + if (YouVersionPlatformConfiguration.permittedLanguageTags === undefined) { + return fetchPage(options.page_token); + } + + return collectFilteredPage( + (pageToken) => fetchPage(pageToken ?? options.page_token), + (language) => isUsableLanguageTag(language.id), + pageSize, + ); } /** diff --git a/packages/core/src/version-filters.test.ts b/packages/core/src/version-filters.test.ts new file mode 100644 index 00000000..c0a2af18 --- /dev/null +++ b/packages/core/src/version-filters.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration'; +import { + collectFilteredPage, + fieldsNeededForLanguageFilter, + fieldsNeededForVersionFilter, + isUsableBibleVersion, + isUsableLanguageTag, + isVersionFilterActive, + isVersionIdDecidablyUnusable, + throwUnusableBibleVersion, +} from './version-filters'; + +function setFilters(filters: { + permittedVersionIds?: number[]; + excludedVersionIds?: number[]; + permittedLanguageTags?: string[]; +}): void { + YouVersionPlatformConfiguration.permittedVersionIds = filters.permittedVersionIds; + YouVersionPlatformConfiguration.excludedVersionIds = filters.excludedVersionIds; + YouVersionPlatformConfiguration.permittedLanguageTags = filters.permittedLanguageTags; +} + +function clearFilters(): void { + setFilters({}); +} + +describe('version filters', () => { + it('treats unset lists as no restriction and empty permit lists as permit nothing', () => { + clearFilters(); + expect(isVersionFilterActive()).toBe(false); + expect(isUsableBibleVersion({ id: 111, languageTag: 'en' })).toBe(true); + expect(isVersionIdDecidablyUnusable(111)).toBe(false); + + setFilters({ permittedVersionIds: [] }); + expect(isVersionFilterActive()).toBe(true); + expect(isUsableBibleVersion({ id: 111, languageTag: 'en' })).toBe(false); + expect(isVersionIdDecidablyUnusable(111)).toBe(true); + + setFilters({ excludedVersionIds: [] }); + expect(isVersionFilterActive()).toBe(false); + expect(isUsableBibleVersion({ id: 111, languageTag: 'en' })).toBe(true); + clearFilters(); + }); + + it('ANDs allowlists and lets exclusion win', () => { + setFilters({ + permittedVersionIds: [111, 206], + excludedVersionIds: [111], + permittedLanguageTags: ['en'], + }); + + expect(isUsableBibleVersion({ id: 111, languageTag: 'en' })).toBe(false); + expect(isUsableBibleVersion({ id: 206, languageTag: 'en' })).toBe(true); + expect(isUsableBibleVersion({ id: 206, languageTag: 'es' })).toBe(false); + expect(isUsableBibleVersion({ id: 3034, languageTag: 'en' })).toBe(false); + expect(isVersionIdDecidablyUnusable(111)).toBe(true); + expect(isVersionIdDecidablyUnusable(206)).toBe(false); + expect(isUsableLanguageTag('en')).toBe(true); + expect(isUsableLanguageTag('es')).toBe(false); + + clearFilters(); + }); + + it('fails closed when a language allowlist is set and the tag is missing', () => { + setFilters({ permittedLanguageTags: ['en'] }); + expect(isUsableBibleVersion({ id: 111 })).toBe(false); + expect(isVersionIdDecidablyUnusable(111)).toBe(false); + clearFilters(); + }); + + it('throws a 403-shaped refuse and adds filter fields to projections', () => { + try { + throwUnusableBibleVersion(); + expect.unreachable(); + } catch (error) { + expect(error).toMatchObject({ + status: 403, + message: 'This app is not allowed to access this Bible version.', + }); + } + + clearFilters(); + expect(fieldsNeededForVersionFilter(['title'])).toEqual(['title']); + + setFilters({ permittedVersionIds: [111], permittedLanguageTags: ['en'] }); + expect(fieldsNeededForVersionFilter(['title'])).toEqual(['title', 'id', 'language_tag']); + expect(fieldsNeededForLanguageFilter(['display_names'])).toEqual(['display_names', 'id']); + clearFilters(); + }); + + it('walks pages until a filtered page is full or the server is exhausted', async () => { + setFilters({ permittedVersionIds: [3, 4] }); + + const pages = [ + { data: [{ id: 1 }, { id: 2 }], next_page_token: 'p2' }, + { data: [{ id: 3 }, { id: 5 }], next_page_token: 'p3' }, + { data: [{ id: 4 }], next_page_token: null }, + ]; + let calls = 0; + + const collected = await collectFilteredPage( + () => { + const page = pages[calls]; + calls += 1; + return Promise.resolve(page ?? { data: [], next_page_token: null }); + }, + (item) => isUsableBibleVersion({ id: item.id }), + 2, + ); + + expect(calls).toBe(3); + expect(collected.data.map((item) => item.id)).toEqual([3, 4]); + expect(collected.next_page_token).toBeNull(); + clearFilters(); + }); +}); diff --git a/packages/core/src/version-filters.ts b/packages/core/src/version-filters.ts new file mode 100644 index 00000000..cfd64bf5 --- /dev/null +++ b/packages/core/src/version-filters.ts @@ -0,0 +1,104 @@ +import { YouVersionPlatformConfiguration } from './YouVersionPlatformConfiguration'; +import type { BibleVersion, Collection, Language } from './types'; + +export type VersionFilterCandidate = { + id: number; + languageTag?: string; +}; + +export function isVersionFilterActive(): boolean { + return ( + YouVersionPlatformConfiguration.permittedVersionIds !== undefined || + YouVersionPlatformConfiguration.permittedLanguageTags !== undefined || + (YouVersionPlatformConfiguration.excludedVersionIds?.length ?? 0) > 0 + ); +} + +/** True when the id alone is enough to refuse, without `language_tag`. */ +export function isVersionIdDecidablyUnusable(versionId: number): boolean { + const excluded = YouVersionPlatformConfiguration.excludedVersionIds; + if (excluded?.includes(versionId)) return true; + + const permittedIds = YouVersionPlatformConfiguration.permittedVersionIds; + return permittedIds !== undefined && !permittedIds.includes(versionId); +} + +export function isUsableBibleVersion(candidate: VersionFilterCandidate): boolean { + if (isVersionIdDecidablyUnusable(candidate.id)) return false; + + const permittedTags = YouVersionPlatformConfiguration.permittedLanguageTags; + if (permittedTags === undefined) return true; + if (candidate.languageTag === undefined) return false; + return permittedTags.includes(candidate.languageTag); +} + +export function isUsableLanguageTag(languageTag: string): boolean { + const permittedTags = YouVersionPlatformConfiguration.permittedLanguageTags; + return permittedTags === undefined || permittedTags.includes(languageTag); +} + +export function throwUnusableBibleVersion(): never { + throw Object.assign(new Error('This app is not allowed to access this Bible version.'), { + status: 403, + }); +} + +export function fieldsNeededForVersionFilter( + fields?: readonly (keyof BibleVersion)[], +): (keyof BibleVersion)[] | undefined { + if (!fields) return undefined; + if (!isVersionFilterActive()) return [...fields]; + + const next = new Set(fields); + next.add('id'); + if (YouVersionPlatformConfiguration.permittedLanguageTags !== undefined) { + next.add('language_tag'); + } + return [...next]; +} + +export function fieldsNeededForLanguageFilter( + fields?: readonly (keyof Language)[], +): (keyof Language)[] | undefined { + if (!fields) return undefined; + if (YouVersionPlatformConfiguration.permittedLanguageTags === undefined) return [...fields]; + + const next = new Set(fields); + next.add('id'); + return [...next]; +} + +export async function collectFilteredPage( + fetchPage: (pageToken?: string) => Promise>, + isUsable: (item: T) => boolean, + pageSize?: number | '*', +): Promise> { + if (pageSize === '*') { + const first = await fetchPage(); + const data = [...first.data.filter(isUsable)]; + let token = first.next_page_token; + while (token) { + const next = await fetchPage(token); + data.push(...next.data.filter(isUsable)); + token = next.next_page_token; + } + return { data, next_page_token: null, total_size: data.length }; + } + + const first = await fetchPage(); + const target = typeof pageSize === 'number' ? pageSize : first.data.length; + const usable = [...first.data.filter(isUsable)]; + let token = first.next_page_token; + + while (usable.length < target && token) { + const next = await fetchPage(token); + usable.push(...next.data.filter(isUsable)); + token = next.next_page_token; + } + + return { + data: usable.slice(0, target), + next_page_token: token, + total_size: first.total_size, + }; +} diff --git a/packages/hooks/README.md b/packages/hooks/README.md index f3580e46..f7e0b452 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -29,6 +29,9 @@ Get your App Key at [platform.youversion.com](https://platform.youversion.com/) ```tsx import { YouVersionProvider, usePassage } from '@youversion/platform-react-hooks'; +// `permittedVersionIds`, `excludedVersionIds`, and `permittedLanguageTags` +// on the provider limit which Bible versions hooks will load. + function BibleVerse() { const { passage, loading } = usePassage({ versionId: 3034, usfm: 'JHN.3.16' }); if (loading) return
Loading...
; @@ -37,7 +40,12 @@ function BibleVerse() { function App() { return ( - + ); diff --git a/packages/hooks/src/context/YouVersionProvider.test.tsx b/packages/hooks/src/context/YouVersionProvider.test.tsx index b5378f7b..308ad142 100644 --- a/packages/hooks/src/context/YouVersionProvider.test.tsx +++ b/packages/hooks/src/context/YouVersionProvider.test.tsx @@ -5,6 +5,14 @@ import { YouVersionPlatformConfiguration } from '@youversion/platform-core'; import { YouVersionProvider } from './YouVersionProvider'; import { YouVersionContext } from './YouVersionContext'; +function FilterReader() { + return ( +
+ {YouVersionPlatformConfiguration.permittedVersionIds?.join(',') ?? 'none'} +
+ ); +} + function ContextReader() { const ctx = useContext(YouVersionContext); return
{ctx?.installationId ?? 'none'}
; @@ -13,6 +21,9 @@ function ContextReader() { describe('YouVersionProvider', () => { beforeEach(() => { YouVersionPlatformConfiguration.installationId = null; + YouVersionPlatformConfiguration.permittedVersionIds = undefined; + YouVersionPlatformConfiguration.excludedVersionIds = undefined; + YouVersionPlatformConfiguration.permittedLanguageTags = undefined; }); it('provides a non-null installationId via context', () => { @@ -41,4 +52,17 @@ describe('YouVersionProvider', () => { ), ).toThrow(/non-empty "appKey" is required/); }); + + it('writes version filters during render so the first child read sees them', () => { + YouVersionPlatformConfiguration.permittedVersionIds = undefined; + + render( + + + , + ); + + expect(screen.getByTestId('filters').textContent).toBe('111,3034'); + expect(YouVersionPlatformConfiguration.permittedVersionIds).toEqual([111, 3034]); + }); }); diff --git a/packages/hooks/src/context/YouVersionProvider.tsx b/packages/hooks/src/context/YouVersionProvider.tsx index af9af471..c45a3106 100644 --- a/packages/hooks/src/context/YouVersionProvider.tsx +++ b/packages/hooks/src/context/YouVersionProvider.tsx @@ -34,6 +34,22 @@ interface YouVersionProviderPropsBase { * to replace `X-YVP-Sdk` with their own identifier. */ additionalHeaders?: Record; + /** + * Bible version ids this app may use. Unset = no restriction. `[]` permits + * nothing. Synced onto `YouVersionPlatformConfiguration` during render so + * the first child fetch sees the filter (YPE-4657). + */ + permittedVersionIds?: number[]; + /** + * Bible version ids this app may not use. Unset or `[]` excludes nothing. + * Exclusion wins over `permittedVersionIds`. + */ + excludedVersionIds?: number[]; + /** + * BCP 47 language tags this app may use (`en`, `zh-Hans`). Unset = no + * restriction. `[]` permits nothing. + */ + permittedLanguageTags?: string[]; } interface YouVersionProviderPropsWithAuth extends YouVersionProviderPropsBase { @@ -115,9 +131,16 @@ function YouVersionProviderInner( additionalHeaders, appName, signInPromptMessage, + permittedVersionIds, + excludedVersionIds, + permittedLanguageTags, children, } = props; + YouVersionPlatformConfiguration.permittedVersionIds = permittedVersionIds; + YouVersionPlatformConfiguration.excludedVersionIds = excludedVersionIds; + YouVersionPlatformConfiguration.permittedLanguageTags = permittedLanguageTags; + const resolvedTheme = useResolvedTheme(theme); // Stable identity so memoized consumers (hooks that build ApiClient) don't diff --git a/packages/ui/README.md b/packages/ui/README.md index 39af9d7e..8764d116 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -41,6 +41,23 @@ function App() { } ``` +### Limit which Bible versions the SDK uses + +By default the version picker offers Bible versions in every available language. Limit that with `permittedLanguageTags`, `permittedVersionIds`, and `excludedVersionIds` on `YouVersionProvider`. A version must satisfy every list that is set. Exclusion wins if an id is in both permit and exclude lists. Unset means no restriction; an empty permit list permits nothing. + +```tsx + + + +``` + +Language tags are BCP 47 (`en`, `es`, `zh-Hans`). Version ids are YouVersion Bible version ids. An unusable id is refused everywhere — lists, picker, and `versionId` on reader/card/text/VOTD — and surfaces the existing forbidden error. Core-only hosts can set the same lists on `YouVersionPlatformConfiguration`. + ## Styling All component CSS is automatically injected when you wrap your app with `YouVersionProvider` — no extra imports or build steps needed. Under the hood, it uses React 19's [`