fix(settings): add release tiers on plain-HTTP origins

Create client IDs with createUuid instead of crypto.randomUUID, which
browsers only expose in secure contexts, so Add tier, saved views, table
presets and podcast sessions work on HTTP installs. Lint now rejects raw
randomUUID calls.

Fixes #1722
This commit is contained in:
neon
2026-10-11 03:54:24 +00:00
parent cc50b55eeb
commit c3abf6d9e1
14 changed files with 241 additions and 13 deletions
+17
View File
@@ -67,6 +67,23 @@ export default defineConfigWithVueTs(
},
},
// randomUUID exists only in secure contexts, so a self-hosted install opened over plain HTTP throws
// on the first call. A separate rule from the one above, because a second no-restricted-syntax
// block would replace that block's options rather than add to them.
{
files: ['src/**/*.{vue,ts,mts,tsx}'],
ignores: ['src/lib/uuid.ts', 'src/**/__tests__/*', 'src/**/*.spec.ts', 'src/**/*.test.ts'],
rules: {
'no-restricted-properties': [
'error',
{
property: 'randomUUID',
message: "Use createUuid from '@/lib/uuid' so IDs can be created on HTTP/self-hosted origins.",
},
],
},
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
@@ -20,7 +20,7 @@ export async function generatePkce(): Promise<{ codeVerifier: string; codeChalle
return { codeVerifier, codeChallenge }
}
function generateNonce(): string {
export function generateNonce(): string {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useSavedViews } from '../useSavedViews'
import { stubInsecureOriginCrypto, UUID_V4 } from '@/lib/__tests__/insecure-origin'
const mockStorage: Record<string, string> = {}
@@ -96,4 +97,24 @@ describe('useSavedViews', () => {
expect(savedViews.savedViews.value).toHaveLength(1)
expect(savedViews.savedViews.value[0]?.name).toBe('Imported')
})
/** A self-hosted install opened over plain HTTP has no `crypto.randomUUID`. */
it('creates distinct view ids on a plain-HTTP origin', () => {
const insecure = stubInsecureOriginCrypto()
try {
const savedViews = useSavedViews('library', ref<number | null>(4))
const layout = { columnOrder: ['title'], hiddenColumns: [], columnWidths: {} }
expect(savedViews.saveView({ name: 'Saved', layout, sort: [] })).toBe(true)
savedViews.duplicateView(savedViews.savedViews.value[0]!.id)
savedViews.importViews([{ id: 'saved_view_import', name: 'Imported', layout, sort: [] }])
const ids = savedViews.savedViews.value.map((view) => view.id)
expect(savedViews.savedViews.value.map((view) => view.name)).toEqual(['Saved', 'Saved Copy', 'Imported'])
expect(ids.every((id) => UUID_V4.test(id.replace(/^saved_view_/, '')))).toBe(true)
expect(new Set(ids).size).toBe(3)
} finally {
insecure.restore()
}
})
})
@@ -18,6 +18,7 @@ vi.stubGlobal('localStorage', {
import { useTablePresets } from '../useTablePresets'
import type { ColumnId } from '../useTableColumns'
import { stubInsecureOriginCrypto, UUID_V4 } from '@/lib/__tests__/insecure-origin'
const allColumnIds = ['cover', 'title', 'authors', 'language', 'actions'] as ColumnId[]
@@ -194,4 +195,24 @@ describe('useTablePresets', () => {
const presets = useTablePresets('library', allColumnIds)
expect(presets.customPresets.value).toHaveLength(0)
})
/** A self-hosted install opened over plain HTTP has no `crypto.randomUUID`. */
it('creates distinct preset ids on a plain-HTTP origin', () => {
const insecure = stubInsecureOriginCrypto()
try {
const presets = useTablePresets('library', allColumnIds)
const layout = { columnOrder: [...allColumnIds], hiddenColumns: [], columnWidths: {} }
presets.savePreset('Saved', layout)
presets.duplicatePreset('default')
presets.importPresets([{ id: 'custom_import', name: 'Imported', layout }])
const ids = presets.customPresets.value.map((preset) => preset.id)
expect(presets.customPresets.value.map((preset) => preset.name)).toEqual(['Saved', 'Default Copy', 'Imported'])
expect(ids.every((id) => UUID_V4.test(id.replace(/^custom_/, '')))).toBe(true)
expect(new Set(ids).size).toBe(3)
} finally {
insecure.restore()
}
})
})
@@ -1,5 +1,6 @@
import { computed, ref, watch, type Ref } from 'vue'
import { storage } from '@/services/storage'
import { createUuid } from '@/lib/uuid'
import type { GroupRule, SortSpec, TableLayoutState, TableViewType } from '@bookorbit/types'
/**
@@ -85,7 +86,7 @@ function createSnapshot(input: SaveSavedViewInput): SavedView | null {
const trimmedName = input.name.trim()
if (!trimmedName) return null
return {
id: `saved_view_${crypto.randomUUID()}`,
id: `saved_view_${createUuid()}`,
name: trimmedName,
layout: cloneLayout(input.layout),
sort: input.sort ? [...input.sort] : [],
@@ -133,7 +134,7 @@ export function useSavedViews(viewType: TableViewType, scopeId: Readonly<Ref<num
...savedViews.value,
{
...existing,
id: `saved_view_${crypto.randomUUID()}`,
id: `saved_view_${createUuid()}`,
name: `${existing.name} Copy`,
layout: cloneLayout(existing.layout),
sort: [...existing.sort],
@@ -149,7 +150,7 @@ export function useSavedViews(viewType: TableViewType, scopeId: Readonly<Ref<num
function importViews(views: SavedView[]): number {
const valid = views.filter(isValidSavedView).map((view) => ({
...view,
id: `saved_view_${crypto.randomUUID()}`,
id: `saved_view_${createUuid()}`,
layout: cloneLayout(view.layout),
sort: [...view.sort],
filter: cloneFilter(view.filter),
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { storage } from '@/services/storage'
import { createUuid } from '@/lib/uuid'
import { cloneTableLayout, validateTableLayout, type SortSpec, type TableLayoutState, type TableViewType } from '@bookorbit/types'
/**
@@ -107,7 +108,7 @@ export function useTablePresets(viewType: TableViewType, allColumnIds: string[])
function savePreset(name: string, layout: TableLayoutState, sort?: SortSpec[]): void {
const trimmedName = name.trim()
if (!trimmedName) return
const id = `custom_${crypto.randomUUID()}`
const id = `custom_${createUuid()}`
const preset: TablePreset = { id, name: trimmedName, layout: cloneTableLayout(layout) }
if (sort && sort.length > 0) preset.sort = [...sort]
customPresets.value = [...customPresets.value, preset]
@@ -128,7 +129,7 @@ export function useTablePresets(viewType: TableViewType, allColumnIds: string[])
if (!existing) return
const duplicate: TablePreset = {
...existing,
id: `custom_${crypto.randomUUID()}`,
id: `custom_${createUuid()}`,
name: `${existing.name} Copy`,
isBuiltIn: false,
layout: cloneTableLayout(existing.layout),
@@ -153,7 +154,7 @@ export function useTablePresets(viewType: TableViewType, allColumnIds: string[])
.filter((preset) => isValidPreset(preset, allColumnIds) && !preset.isBuiltIn)
.map((preset) => ({
...preset,
id: `custom_${crypto.randomUUID()}`,
id: `custom_${createUuid()}`,
layout: cloneTableLayout(preset.layout),
sort: preset.sort ? [...preset.sort] : undefined,
}))
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PodcastPlaybackPreferences } from '@bookorbit/types'
import { stubInsecureOriginCrypto, UUID_V4 } from '@/lib/__tests__/insecure-origin'
import { createPodcastPlayerPersistence } from './podcast-player-persistence'
describe('createPodcastPlayerPersistence', () => {
@@ -107,6 +108,25 @@ describe('createPodcastPlayerPersistence', () => {
)
})
/** A self-hosted install opened over plain HTTP has no `crypto.randomUUID`. */
it('starts and closes a listening session with a generated id on a plain-HTTP origin', async () => {
const insecure = stubInsecureOriginCrypto()
try {
const request = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>().mockResolvedValue(new Response(null, { status: 204 }))
const persistence = createPodcastPlayerPersistence(request)
persistence.startListeningSession()
vi.setSystemTime(new Date('2026-08-01T12:01:00.000Z'))
await persistence.closeListeningSession(42, 60)
const [, init] = request.mock.calls[0]!
expect(JSON.parse(String(init?.body)).sessionId).toMatch(UUID_V4)
expect(insecure.getRandomValues).toHaveBeenCalledOnce()
} finally {
insecure.restore()
}
})
it('does not persist sub-second or discarded listening sessions', async () => {
const request = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>()
const persistence = createPodcastPlayerPersistence(request)
@@ -1,5 +1,6 @@
import type { PodcastPlaybackPreferences } from '@bookorbit/types'
import { api } from '@/lib/api'
import { createUuid } from '@/lib/uuid'
type ApiRequest = (url: string, init?: RequestInit) => Promise<Response>
@@ -83,7 +84,7 @@ export function createPodcastPlayerPersistence(request: ApiRequest = api) {
return stateWrite
}
function startListeningSession(startedAt = new Date(), activeSessionId: string = crypto.randomUUID()) {
function startListeningSession(startedAt = new Date(), activeSessionId: string = createUuid()) {
playStartedAt = startedAt
sessionId = activeSessionId
}
@@ -7,7 +7,7 @@ import { Clock, KeyRound, Link, LinkIcon, MapPin, Save, Trash2, Trophy, Upload }
import { toast } from 'vue-sonner'
import UserAvatar from '@/components/UserAvatar.vue'
import { api } from '@/lib/api'
import { generatePkce } from '@/features/auth/composables/useOidc'
import { generateNonce, generatePkce } from '@/features/auth/composables/useOidc'
import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { MAX_PROFILE_AVATAR_BYTES, useProfileAvatar } from '@/features/auth/composables/useProfileAvatar'
@@ -279,7 +279,7 @@ async function initiateOidcLink(provider: OidcProviderPublic) {
throw new Error(((err as Record<string, unknown>).message as string) ?? t('settings.account.connectedAccounts.linkStateFailed'))
}
const { state, authorizationEndpoint, clientId, scopes } = await stateRes.json()
const nonce = crypto.randomUUID()
const nonce = generateNonce()
const { codeVerifier, codeChallenge } = await generatePkce()
const params = new URLSearchParams({
response_type: 'code',
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/lib/api'
import { generatePkce } from '@/features/auth/composables/useOidc'
import { generateNonce, generatePkce } from '@/features/auth/composables/useOidc'
import { toast } from 'vue-sonner'
import { OidcErrorCode, Permission, PERMISSION_LABELS } from '@bookorbit/types'
import { ArrowLeft, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
@@ -314,7 +314,7 @@ async function previewOidcClaims() {
const stateRes = await api(`/api/v1/auth/oidc/${editingSlug.value}/preview-state`, { method: 'POST' })
if (!stateRes.ok) throw new Error(t('settings.oidc.errors.previewState'))
const { state, authorizationEndpoint } = await stateRes.json()
const nonce = crypto.randomUUID()
const nonce = generateNonce()
const { codeVerifier, codeChallenge } = await generatePkce()
const params = new URLSearchParams({
@@ -9,6 +9,7 @@ vi.mock('@/lib/api', () => ({ api: apiMock }))
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>() } }))
import { toast } from 'vue-sonner'
import { stubInsecureOriginCrypto, UUID_V4 } from '@/lib/__tests__/insecure-origin'
import RequestAutomationPanel from '../RequestAutomationPanel.vue'
const PATH = '/api/v1/admin/book-request-automation'
@@ -349,6 +350,29 @@ describe('RequestAutomationPanel', () => {
}
})
/** Issue 1722: a self-hosted install opened over plain HTTP has no `crypto.randomUUID`. */
it('saves a tier added on a plain-HTTP origin', async () => {
vi.useFakeTimers()
const insecure = stubInsecureOriginCrypto()
try {
const wrapper = await profilePanel()
const audiobook = wrapper.get('#release-profile-audiobook')
await audiobook
.findAll('button')
.find((button) => button.text() === 'Add tier')!
.trigger('click')
expect(audiobook.findAll('ol > li')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1000)
expect(sentBodies()).toEqual([{ profiles: { audiobook: [{ id: expect.stringMatching(UUID_V4), name: 'Tier 1', conditions: {} }] } }])
expect(toast.error).not.toHaveBeenCalled()
} finally {
insecure.restore()
vi.useRealTimers()
}
})
/** The chip presses land on the editor, and one request lands on the server. */
it('collapses a burst of tier edits into one save', async () => {
vi.useFakeTimers()
@@ -0,0 +1,100 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { MAX_RELEASE_TIERS } from '@bookorbit/types'
import type { BookRequestMediaKind, ReleaseTier } from '@bookorbit/types'
import { stubInsecureOriginCrypto, UUID_V4 } from '@/lib/__tests__/insecure-origin'
import ReleaseProfileEditor from './ReleaseProfileEditor.vue'
type Emitted = [BookRequestMediaKind, ReleaseTier[]]
function tier(id: string, name = id): ReleaseTier {
return { id, name, conditions: {} }
}
function mountEditor(tiers: ReleaseTier[] = []) {
return mount(ReleaseProfileEditor, { props: { mediaKind: 'ebook', tiers, indexers: [], adapters: [] } })
}
function addTierButton(wrapper: VueWrapper) {
return wrapper.findAll('button').find((button) => button.text() === 'Add tier')!
}
function lastEmitted(wrapper: VueWrapper): Emitted {
return wrapper.emitted<Emitted>('update')!.at(-1)!
}
function renderedNames(wrapper: VueWrapper): string[] {
return wrapper.findAll('input[aria-label="Tier name"]').map((input) => (input.element as HTMLInputElement).value)
}
describe('ReleaseProfileEditor', () => {
describe('adding a tier', () => {
it('appends an empty tier named for its position and reports the whole profile', async () => {
const wrapper = mountEditor([tier('retail', 'Retail')])
await addTierButton(wrapper).trigger('click')
expect(lastEmitted(wrapper)).toEqual([
'ebook',
[tier('retail', 'Retail'), { id: expect.stringMatching(UUID_V4), name: 'Tier 2', conditions: {} }],
])
expect(renderedNames(wrapper)).toEqual(['Retail', 'Tier 2'])
})
it('stops offering another tier once the profile is full', async () => {
const full = Array.from({ length: MAX_RELEASE_TIERS }, (_, index) => tier(`tier-${index}`))
const wrapper = mountEditor(full)
expect(addTierButton(wrapper).attributes('disabled')).toBeDefined()
await addTierButton(wrapper).trigger('click')
expect(wrapper.emitted('update')).toBeUndefined()
})
/** Issue 1722: a self-hosted install opened over plain HTTP has no `crypto.randomUUID`. */
describe('on a plain-HTTP origin', () => {
let insecure: ReturnType<typeof stubInsecureOriginCrypto>
afterEach(() => insecure.restore())
it('still adds the tier, with a UUID made from getRandomValues', async () => {
insecure = stubInsecureOriginCrypto()
const wrapper = mountEditor()
await addTierButton(wrapper).trigger('click')
expect(lastEmitted(wrapper)).toEqual(['ebook', [{ id: expect.stringMatching(UUID_V4), name: 'Tier 1', conditions: {} }]])
expect(renderedNames(wrapper)).toEqual(['Tier 1'])
expect(insecure.getRandomValues).toHaveBeenCalledOnce()
})
/** Rows are keyed by tier id, so two tiers sharing one would render and edit as one. */
it('gives every added tier its own id', async () => {
insecure = stubInsecureOriginCrypto()
const wrapper = mountEditor()
for (let click = 0; click < 3; click++) await addTierButton(wrapper).trigger('click')
const [, tiers] = lastEmitted(wrapper)
expect(tiers.map((added) => added.name)).toEqual(['Tier 1', 'Tier 2', 'Tier 3'])
expect(tiers.every((added) => UUID_V4.test(added.id))).toBe(true)
expect(new Set(tiers.map((added) => added.id)).size).toBe(3)
expect(renderedNames(wrapper)).toEqual(['Tier 1', 'Tier 2', 'Tier 3'])
})
it('lets the added tier be described like any other', async () => {
insecure = stubInsecureOriginCrypto()
const wrapper = mountEditor([tier('retail', 'Retail')])
await addTierButton(wrapper).trigger('click')
const [, [, added]] = lastEmitted(wrapper)
const addedRow = wrapper.findAll('ol > li').at(-1)!
await addedRow
.findAll('button[aria-pressed]')
.find((chip) => chip.text() === 'epub')!
.trigger('click')
expect(lastEmitted(wrapper)).toEqual(['ebook', [tier('retail', 'Retail'), { ...added, conditions: { formats: ['epub'] } }]])
})
})
})
})
@@ -13,6 +13,7 @@ import type {
} from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import TokenSelect from '@/components/ui/TokenSelect.vue'
import { createUuid } from '@/lib/uuid'
import { formatLanguageName } from '@/i18n/formatters'
/**
@@ -123,7 +124,7 @@ function prune(conditions: ReleaseTierConditions): ReleaseTierConditions {
function addTier() {
if (!canAdd.value) return
const tier: ReleaseTier = {
id: crypto.randomUUID(),
id: createUuid(),
name: t('settings.system.requests.profiles.newTier', { position: tiers.value.length + 1 }),
conditions: {},
}
@@ -0,0 +1,21 @@
import { vi } from 'vitest'
export const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
/**
* `crypto` as a page served over plain HTTP from anything but localhost sees it, which is how most
* self-hosted installs are first opened: `getRandomValues` is there, `randomUUID` and `subtle` are
* not. Backed by real randomness so a caller can assert that the IDs it makes stay unique.
*
* `restore` puts the native object back without `vi.unstubAllGlobals()`, which would also undo any
* module-level stub the calling spec depends on.
*/
export function stubInsecureOriginCrypto() {
const native = globalThis.crypto
const getRandomValues = vi.fn<Crypto['getRandomValues']>((array) => native.getRandomValues(array))
vi.stubGlobal('crypto', { getRandomValues })
return {
getRandomValues,
restore: () => vi.stubGlobal('crypto', native),
}
}