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/CONTEXT.md b/CONTEXT.md index 35039ab2..f13c3b03 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.” 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 cee186c7..1d8545da 100644 --- a/packages/core/src/YouVersionPlatformConfiguration.ts +++ b/packages/core/src/YouVersionPlatformConfiguration.ts @@ -19,6 +19,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(); @@ -317,4 +320,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..167599a1 --- /dev/null +++ b/packages/core/src/__tests__/bible-version-filters.test.ts @@ -0,0 +1,290 @@ +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 firstServerPage = { + data: [ + { id: 1, language_tag: 'en' }, + { id: 2, language_tag: 'en' }, + ], + next_page_token: 'p2', + }; + const secondServerPage = { + data: [ + { id: 3, language_tag: 'en' }, + { id: 5, language_tag: 'en' }, + ], + next_page_token: 'p3', + }; + const thirdServerPage = { 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') ?? ''; + if (token === 'p2') return HttpResponse.json(secondServerPage); + if (token === 'p3') return HttpResponse.json(thirdServerPage); + return HttpResponse.json( + token === '' ? firstServerPage : { 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]); + + const overflowFirstPage = { + data: [ + { id: 1, language_tag: 'en' }, + { id: 2, language_tag: 'en' }, + { id: 3, language_tag: 'en' }, + { id: 4, language_tag: 'en' }, + ], + next_page_token: 'p2', + }; + const overflowLaterPage = { data: [{ id: 5, language_tag: 'en' }], next_page_token: null }; + const requestedTokens: string[] = []; + + server.use( + http.get(`https://${apiHost}/v1/bibles`, ({ request }) => { + const token = new URL(request.url).searchParams.get('page_token') ?? ''; + requestedTokens.push(token); + expect(token.startsWith('yv-vf1:')).toBe(false); + if (token === 'p2') return HttpResponse.json(overflowLaterPage); + return HttpResponse.json( + token === '' ? overflowFirstPage : { data: [], next_page_token: null }, + ); + }), + ); + + YouVersionPlatformConfiguration.permittedVersionIds = [1, 2, 3, 4, 5]; + const firstPage = await bibleClient().getVersions('en*', undefined, { page_size: 2 }); + expect(firstPage.data.map((version) => version.id)).toEqual([1, 2]); + expect(firstPage.next_page_token).toMatch(/^yv-vf1:/); + + const secondPage = await bibleClient().getVersions('en*', undefined, { + page_size: 2, + page_token: firstPage.next_page_token ?? undefined, + }); + expect(secondPage.data.map((version) => version.id)).toEqual([3, 4]); + expect(secondPage.next_page_token).toBe('p2'); + expect(requestedTokens).toEqual(['', '']); + + clearFilters(); + }); + + it('keeps every usable version across pages when page_size is * and a filter injects fields', async () => { + YouVersionPlatformConfiguration.permittedVersionIds = [3, 4, 5]; + + const firstServerPage = { + data: [ + { id: 3, title: 'Permitted first' }, + { id: 1, title: 'Excluded' }, + ], + next_page_token: 'p2', + }; + const secondServerPage = { + data: [{ id: 4, title: 'Permitted second' }], + next_page_token: 'p3', + }; + const thirdServerPage = { data: [{ id: 5, title: 'Permitted third' }], next_page_token: null }; + + server.use( + http.get(`https://${apiHost}/v1/bibles`, ({ request }) => { + const token = new URL(request.url).searchParams.get('page_token') ?? ''; + if (token === 'p2') return HttpResponse.json(secondServerPage); + if (token === 'p3') return HttpResponse.json(thirdServerPage); + return HttpResponse.json( + token === '' ? firstServerPage : { data: [], next_page_token: null }, + ); + }), + ); + + const versions = await bibleClient().getVersions('en*', undefined, { + page_size: '*', + fields: ['title', 'abbreviation', 'localized_title'], + }); + expect(versions.data.map((version) => version.id)).toEqual([3, 4, 5]); + + clearFilters(); + }); + + it('keeps every usable language across pages when page_size is * and a filter injects fields', async () => { + YouVersionPlatformConfiguration.permittedLanguageTags = ['en', 'fr', 'ko']; + + const firstServerPage = { + data: [ + { id: 'en', language: 'en' }, + { id: 'es', language: 'es' }, + ], + next_page_token: 'p2', + }; + const secondServerPage = { data: [{ id: 'fr', language: 'fr' }], next_page_token: 'p3' }; + const thirdServerPage = { data: [{ id: 'ko', language: 'ko' }], next_page_token: null }; + + server.use( + http.get(`https://${apiHost}/v1/languages`, ({ request }) => { + const token = new URL(request.url).searchParams.get('page_token') ?? ''; + if (token === 'p2') return HttpResponse.json(secondServerPage); + if (token === 'p3') return HttpResponse.json(thirdServerPage); + return HttpResponse.json( + token === '' ? firstServerPage : { data: [], next_page_token: null }, + ); + }), + ); + + const languages = new LanguagesClient( + new ApiClient({ apiHost, appKey: 'test-app', installationId: 'test-installation' }), + ); + const filtered = await languages.getLanguages({ + page_size: '*', + fields: ['language', 'text_direction', 'speaking_population'], + }); + expect(filtered.data.map((language) => language.id)).toEqual(['en', 'fr', 'ko']); + + clearFilters(); + }); + + it('leaves unfiltered page_size=* on the wire and still rejects more than 3 fields', async () => { + clearFilters(); + + const requestedPageSizes: Array = []; + server.use( + http.get(`https://${apiHost}/v1/bibles`, ({ request }) => { + requestedPageSizes.push(new URL(request.url).searchParams.get('page_size')); + return HttpResponse.json({ data: [{ id: 1 }], next_page_token: 'p2' }); + }), + http.get(`https://${apiHost}/v1/languages`, ({ request }) => { + requestedPageSizes.push(new URL(request.url).searchParams.get('page_size')); + return HttpResponse.json({ data: [{ id: 'en', language: 'en' }], next_page_token: 'p2' }); + }), + ); + + const versions = await bibleClient().getVersions('en*', undefined, { + page_size: '*', + fields: ['title', 'abbreviation', 'localized_title'], + }); + expect(versions.data.map((version) => version.id)).toEqual([1]); + expect(versions.next_page_token).toBe('p2'); + + await expect( + bibleClient().getVersions('en*', undefined, { + page_size: '*', + fields: ['title', 'abbreviation', 'localized_title', 'books'], + }), + ).rejects.toThrow(/page_size/); + + const languages = new LanguagesClient( + new ApiClient({ apiHost, appKey: 'test-app', installationId: 'test-installation' }), + ); + const languagePage = await languages.getLanguages({ + page_size: '*', + fields: ['language', 'text_direction', 'speaking_population'], + }); + expect(languagePage.data.map((language) => language.id)).toEqual(['en']); + expect(languagePage.next_page_token).toBe('p2'); + + await expect( + languages.getLanguages({ + page_size: '*', + fields: ['language', 'text_direction', 'speaking_population', 'id'], + }), + ).rejects.toThrow(/page_size/); + + expect(requestedPageSizes).toEqual(['*', '*']); + }); + + 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 1a4c8ee8..681a1ddb 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, @@ -151,14 +160,40 @@ export class BibleClient { params['fields[]'] = options.fields; } - if (options?.page_token) { - params.page_token = options.page_token; - } - if (options?.all_available) { params.all_available = 'true'; } - return this.client.get>(`/v1/bibles`, params); + + const filterFields = fieldsNeededForVersionFilter(options?.fields); + const pageSize = options?.page_size; + if (filterFields) { + params['fields[]'] = filterFields; + if (isVersionFilterActive() && pageSize === '*' && filterFields.length > 3) { + // API rejects page_size=* with more than 3 fields. Keep pageSize='*' so + // collectFilteredPage still walks every server page. Unfiltered *+>3 + // stays a loud schema reject — do not drop * on that path. + delete params.page_size; + } + } + + const fetchPage = (pageToken?: string) => { + const pageParams = { ...params }; + if (pageToken) { + pageParams.page_token = pageToken; + } + return this.client.get>(`/v1/bibles`, pageParams); + }; + + if (!isVersionFilterActive()) { + return fetchPage(options?.page_token); + } + + return collectFilteredPage( + fetchPage, + (version) => isUsableBibleVersion({ id: version.id, languageTag: version.language_tag }), + pageSize, + options?.page_token, + ); } /** @@ -168,7 +203,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); + } } /** @@ -181,6 +232,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 }), }); @@ -197,6 +249,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}`); } @@ -209,6 +262,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`, ); @@ -225,6 +279,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}`, @@ -246,6 +301,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`, @@ -270,6 +326,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}`, @@ -338,6 +395,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, @@ -359,6 +417,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 6925a8ed..291d534a 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. @@ -90,6 +96,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); @@ -127,6 +146,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`, @@ -164,6 +184,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`, @@ -210,6 +231,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..6da763fb 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. @@ -94,11 +100,40 @@ export class LanguagesClient { params.page_size = options.page_size; } - if (options.page_token !== undefined) { - params.page_token = options.page_token; + const filterFields = fieldsNeededForLanguageFilter(options.fields); + const pageSize = options.page_size; + if (filterFields) { + params['fields[]'] = filterFields; + if ( + YouVersionPlatformConfiguration.permittedLanguageTags !== undefined && + pageSize === '*' && + filterFields.length > 3 + ) { + // API rejects page_size=* with more than 3 fields. Keep pageSize='*' so + // collectFilteredPage still walks every server page. Unfiltered *+>3 + // stays a loud reject — do not drop * on that path. + delete params.page_size; + } + } + + const fetchPage = (pageToken?: string) => { + const pageParams = { ...params }; + if (pageToken) { + pageParams.page_token = pageToken; + } + return this.client.get>(`/v1/languages`, pageParams); + }; + + if (YouVersionPlatformConfiguration.permittedLanguageTags === undefined) { + return fetchPage(options.page_token); } - return this.client.get>(`/v1/languages`, params); + return collectFilteredPage( + fetchPage, + (language) => isUsableLanguageTag(language.id), + pageSize, + options.page_token, + ); } /** diff --git a/packages/core/src/version-filters.test.ts b/packages/core/src/version-filters.test.ts new file mode 100644 index 00000000..2dd086bd --- /dev/null +++ b/packages/core/src/version-filters.test.ts @@ -0,0 +1,177 @@ +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(); + }); + + it('resumes leftover usable rows instead of dropping them after a full page', async () => { + const firstServerPage = { + data: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], + next_page_token: 'p2', + }; + const laterServerPage = { data: [{ id: 5 }], next_page_token: null }; + + const fetchPage = (pageToken?: string) => { + expect(pageToken?.startsWith('yv-vf1:')).not.toBe(true); + if (!pageToken) return Promise.resolve(firstServerPage); + if (pageToken === 'p2') return Promise.resolve(laterServerPage); + return Promise.resolve({ data: [], next_page_token: null }); + }; + + const first = await collectFilteredPage(fetchPage, () => true, 2); + expect(first.data.map((item) => item.id)).toEqual([1, 2]); + expect(first.next_page_token).toMatch(/^yv-vf1:/); + + const second = await collectFilteredPage( + fetchPage, + () => true, + 2, + first.next_page_token ?? undefined, + ); + expect(second.data.map((item) => item.id)).toEqual([3, 4]); + expect(second.next_page_token).toBe('p2'); + + const third = await collectFilteredPage( + fetchPage, + () => true, + 2, + second.next_page_token ?? undefined, + ); + expect(third.data.map((item) => item.id)).toEqual([5]); + expect(third.next_page_token).toBeNull(); + }); + + it('keeps leftover usable rows on the last server page', async () => { + const fetchPage = (pageToken?: string) => { + expect(pageToken).toBeUndefined(); + return Promise.resolve({ + data: [{ id: 1 }, { id: 2 }, { id: 3 }], + next_page_token: null, + }); + }; + + const first = await collectFilteredPage(fetchPage, () => true, 2); + expect(first.data.map((item) => item.id)).toEqual([1, 2]); + expect(first.next_page_token).toMatch(/^yv-vf1:/); + + const second = await collectFilteredPage( + fetchPage, + () => true, + 2, + first.next_page_token ?? undefined, + ); + expect(second.data.map((item) => item.id)).toEqual([3]); + expect(second.next_page_token).toBeNull(); + }); +}); diff --git a/packages/core/src/version-filters.ts b/packages/core/src/version-filters.ts new file mode 100644 index 00000000..fa1e1fc8 --- /dev/null +++ b/packages/core/src/version-filters.ts @@ -0,0 +1,162 @@ +import { z } from 'zod'; +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]; +} + +/** Resume token for leftover usable rows on a server page. Never send to the API. */ +const FILTER_PAGE_CURSOR_PREFIX = 'yv-vf1:'; + +const FilterPageCursorSchema = z.object({ + t: z.string().nullable(), + s: z.number().int().nonnegative(), +}); + +type FilterPageCursor = z.infer; + +type FilterPageStart = { + pageToken?: string; + skip: number; +}; + +function encodeFilterPageCursor(pageToken: string | null, skip: number): string { + return `${FILTER_PAGE_CURSOR_PREFIX}${JSON.stringify({ t: pageToken, s: skip })}`; +} + +function decodeFilterPageCursor(token: string): FilterPageCursor | undefined { + if (!token.startsWith(FILTER_PAGE_CURSOR_PREFIX)) return undefined; + try { + const parsed = FilterPageCursorSchema.safeParse( + JSON.parse(token.slice(FILTER_PAGE_CURSOR_PREFIX.length)), + ); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} + +function resolveFilterPageStart(startToken?: string): FilterPageStart { + if (!startToken) return { skip: 0 }; + const cursor = decodeFilterPageCursor(startToken); + if (!cursor) return { pageToken: startToken, skip: 0 }; + return { pageToken: cursor.t ?? undefined, skip: cursor.s }; +} + +export async function collectFilteredPage( + fetchPage: (pageToken?: string) => Promise>, + isUsable: (item: T) => boolean, + pageSize?: number | '*', + startToken?: string, +): Promise> { + const start = resolveFilterPageStart(startToken); + + if (pageSize === '*') { + const first = await fetchPage(start.pageToken); + const data = first.data.filter(isUsable).slice(start.skip); + 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 }; + } + + let fetchToken = start.pageToken; + let skip = start.skip; + const first = await fetchPage(fetchToken); + const target = pageSize ?? first.data.length; + const collected: T[] = []; + let page = first; + + while (true) { + const usable = page.data.filter(isUsable).slice(skip); + const take = usable.slice(0, target - collected.length); + collected.push(...take); + + if (usable.length > take.length) { + return { + data: collected, + next_page_token: encodeFilterPageCursor(fetchToken ?? null, skip + take.length), + total_size: first.total_size, + }; + } + + skip = 0; + if (collected.length >= target || !page.next_page_token) { + return { + data: collected, + next_page_token: page.next_page_token ?? null, + total_size: first.total_size, + }; + } + + fetchToken = page.next_page_token; + page = await fetchPage(fetchToken); + } +} 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 75d795e9..42b8d5ae 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 [`