fix(client): localize the read-aloud interface

Replace the hardcoded English in the TTS and narration components with
message keys, label every icon-only control, and add settings.common.nav.tts,
which the settings sidebar rendered as a raw key.

Stop requiring PODCAST_ENCRYPTION_KEY while APP_FEATURES.podcasts is off, and
skip podcast routes and permissions in the authorization matrix for the same
reason.

Pass the direct-download settle() helper the SETTLE_MS budget the file already
defines: vi.waitFor's one-second default cannot cover six DNS-resolved redirect
hops on a cold CI resolver.
This commit is contained in:
neon
2026-09-21 04:35:57 +00:00
parent 7c7e23806e
commit 67ef7f29a5
22 changed files with 550 additions and 171 deletions
+17 -8
View File
@@ -1,3 +1,5 @@
import { APP_FEATURES } from '@bookorbit/types';
import { validateEnv } from './env.validation';
const BASE_ENV = {
@@ -200,15 +202,22 @@ describe('validateEnv', () => {
expect(() => validateEnv({ ...BASE_ENV, NODE_ENV: 'test' })).not.toThrow();
});
it('requires a dedicated podcast encryption key in production', () => {
const productionEnv = {
...BASE_ENV,
NODE_ENV: 'production',
SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef',
};
const PRODUCTION_ENV = {
...BASE_ENV,
NODE_ENV: 'production',
SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef',
};
expect(() => validateEnv(productionEnv)).toThrow('PODCAST_ENCRYPTION_KEY is required in production');
expect(() => validateEnv({ ...productionEnv, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow();
it.runIf(APP_FEATURES.podcasts)('requires a dedicated podcast encryption key in production while podcasts are enabled', () => {
expect(() => validateEnv(PRODUCTION_ENV)).toThrow('PODCAST_ENCRYPTION_KEY is required in production');
});
it.skipIf(APP_FEATURES.podcasts)('starts in production without a podcast encryption key while podcasts are disabled', () => {
expect(() => validateEnv(PRODUCTION_ENV)).not.toThrow();
});
it('accepts a dedicated podcast encryption key in production either way', () => {
expect(() => validateEnv({ ...PRODUCTION_ENV, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow();
});
it.each([
+6 -1
View File
@@ -3,6 +3,8 @@ import { isAbsolute } from 'node:path';
import { isIP } from 'node:net';
import { z } from 'zod';
import { APP_FEATURES } from '@bookorbit/types';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', 'yes', 'no', 'on', 'off'];
@@ -146,7 +148,10 @@ export function validateEnv(config: Record<string, unknown>) {
if (result.data.NODE_ENV === 'production' && !result.data.SETUP_BOOTSTRAP_TOKEN?.trim()) {
throw new Error('Environment validation failed:\n SETUP_BOOTSTRAP_TOKEN: SETUP_BOOTSTRAP_TOKEN is required in production');
}
if (result.data.NODE_ENV === 'production' && !result.data.PODCAST_ENCRYPTION_KEY) {
// Only gate startup on the key once podcast controllers are actually registered. Requiring it
// while the feature is off turns every existing deployment's upgrade into a boot failure for a
// feature its users cannot reach, and podcastConfig already falls back when it is absent.
if (APP_FEATURES.podcasts && result.data.NODE_ENV === 'production' && !result.data.PODCAST_ENCRYPTION_KEY) {
throw new Error('Environment validation failed:\n PODCAST_ENCRYPTION_KEY: PODCAST_ENCRYPTION_KEY is required in production');
}
return result.data;
@@ -45,13 +45,22 @@ describe('DirectDownloadService', () => {
return new Response(body, { headers: { 'content-type': 'application/epub+zip', ...headers } });
}
/** Waits for the fire-and-forget download, which `add` deliberately does not await. */
/**
* Waits for the fire-and-forget download, which `add` deliberately does not await.
*
* The budget has to be passed: `vi.waitFor` defaults to one second, and a redirect chain pays a
* real `dns.lookup` per hop through `ensureSafeUrl`, so six hops on a slow resolver outlast it
* and the case fails in CI while passing on a warm cache.
*/
async function settle() {
return vi.waitFor(async () => {
const [status] = await service.status([HASH]);
expect(status.state === 'completed' || status.state === 'failed').toBe(true);
return status;
});
return vi.waitFor(
async () => {
const [status] = await service.status([HASH]);
expect(status.state === 'completed' || status.state === 'failed').toBe(true);
return status;
},
{ timeout: SETTLE_MS },
);
}
it('writes the file into its staging directory and reports where it landed', async () => {
+51 -29
View File
@@ -5,7 +5,7 @@ import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { ConfigService } from '@nestjs/config';
import { Permission } from '@bookorbit/types';
import { APP_FEATURES, Permission } from '@bookorbit/types';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { and, eq, inArray } from 'drizzle-orm';
@@ -99,6 +99,16 @@ interface Personas {
}
const currentDir = dirname(fileURLToPath(import.meta.url));
const PODCAST_MODULE_DIR = 'server/src/modules/podcast/';
/**
* Podcast permissions are only ever carried by PodcastModule's guards, so while
* APP_FEATURES.podcasts is off no registered route can prove them and the inventory holds none.
*/
function isFeatureGatedPermission(permission: Permission): boolean {
return !APP_FEATURES.podcasts && permission.startsWith('podcast_');
}
const routeInventory = loadRouteInventory();
const uncoveredRoutes = loadUncoveredRoutes();
@@ -111,12 +121,17 @@ function isSupportedMethod(method: string): method is SupportedHttpMethod {
function loadRouteInventory(): RouteInventory {
const inventoryDir = join(currentDir, 'e2e/authorization-matrix/route-inventory');
const manifest = JSON.parse(readFileSync(join(inventoryDir, 'manifest.json'), 'utf8')) as RouteInventoryManifest;
const routes = manifest.routeChunks.flatMap(
const inventoried = manifest.routeChunks.flatMap(
(chunkFile) => JSON.parse(readFileSync(join(inventoryDir, chunkFile), 'utf8')) as RouteInventoryRoute[],
);
// PodcastModule registers its controllers only when APP_FEATURES.podcasts is on, so with the
// feature off Nest never mounts these paths and every probe below would read the resulting 404
// as a missing guard. The inventory keeps the entries so they return with the feature.
const routes = APP_FEATURES.podcasts ? inventoried : inventoried.filter((route) => !route.file.startsWith(PODCAST_MODULE_DIR));
return {
totalRoutes: manifest.totalRoutes,
totalRoutes: manifest.totalRoutes - (inventoried.length - routes.length),
byPermission: manifest.byPermission,
byLibraryAccess: manifest.byLibraryAccess,
routes,
@@ -660,7 +675,9 @@ describe('Authorization matrix (e2e)', () => {
// so they legitimately never appear on a guard. Every entry needs a service-level test
// proving the capability is actually enforced; keep this list as short as it can be.
const serviceEnforcedPermissions: Permission[] = [Permission.BookRequestAutoApprove];
const enumPermissions = [...Object.values(Permission)].filter((permission) => !serviceEnforcedPermissions.includes(permission)).sort();
const enumPermissions = [...Object.values(Permission)]
.filter((permission) => !serviceEnforcedPermissions.includes(permission) && !isFeatureGatedPermission(permission))
.sort();
expect(inventoryPermissions).toEqual(enumPermissions);
const probes: Record<
@@ -845,7 +862,7 @@ describe('Authorization matrix (e2e)', () => {
const failures: MatrixFailure[] = [];
for (const permission of Object.values(Permission)) {
if (serviceEnforcedPermissions.includes(permission)) continue;
if (serviceEnforcedPermissions.includes(permission) || isFeatureGatedPermission(permission)) continue;
const probe = probes[permission];
const inventoryRoute = routeInventory.routes.find((route) => route.httpMethod === probe.method && route.path === probe.path);
expect(inventoryRoute).toBeTruthy();
@@ -885,6 +902,25 @@ describe('Authorization matrix (e2e)', () => {
const guardedRoutes = routeInventory.routes.filter(
(route) => !route.isPublic && route.libraryAccess.length > 0 && isSupportedMethod(route.httpMethod),
);
const podcastGuardedRoutes = [
'GET /podcast-libraries/:libraryId/podcasts',
'GET /podcast-libraries/:libraryId/episodes',
'POST /podcast-libraries/:libraryId/queue-episodes',
'POST /podcast-libraries/:libraryId/feed-preview',
'POST /podcast-libraries/:libraryId/podcasts',
'POST /podcast-libraries/:libraryId/opml/import',
'POST /podcast-libraries/:libraryId/import-scan',
'GET /podcast-libraries/:libraryId/import-scan/latest',
'POST /podcast-libraries/:libraryId/shows/bulk-purge-preview',
'POST /podcast-libraries/:libraryId/shows/bulk-delete',
'GET /podcast-libraries/:libraryId/opml/export',
'GET /podcast-libraries/:libraryId/settings',
'PATCH /podcast-libraries/:libraryId/settings',
'GET /podcast-libraries/:libraryId/health',
'GET /podcast-libraries/:libraryId/activity',
'GET /podcast-libraries/:libraryId/jobs',
'GET /podcast-libraries/:libraryId/jobs/:jobId',
];
expect(guardedRoutes.map(routeLabel).sort()).toEqual(
[
'GET /libraries/:id',
@@ -898,23 +934,7 @@ describe('Authorization matrix (e2e)', () => {
'GET /libraries/:id/bulk-rename/preview',
'GET /libraries/:id/bulk-rename/status',
'POST /libraries/:id/bulk-rename/execute',
'GET /podcast-libraries/:libraryId/podcasts',
'GET /podcast-libraries/:libraryId/episodes',
'POST /podcast-libraries/:libraryId/queue-episodes',
'POST /podcast-libraries/:libraryId/feed-preview',
'POST /podcast-libraries/:libraryId/podcasts',
'POST /podcast-libraries/:libraryId/opml/import',
'POST /podcast-libraries/:libraryId/import-scan',
'GET /podcast-libraries/:libraryId/import-scan/latest',
'POST /podcast-libraries/:libraryId/shows/bulk-purge-preview',
'POST /podcast-libraries/:libraryId/shows/bulk-delete',
'GET /podcast-libraries/:libraryId/opml/export',
'GET /podcast-libraries/:libraryId/settings',
'PATCH /podcast-libraries/:libraryId/settings',
'GET /podcast-libraries/:libraryId/health',
'GET /podcast-libraries/:libraryId/activity',
'GET /podcast-libraries/:libraryId/jobs',
'GET /podcast-libraries/:libraryId/jobs/:jobId',
...(APP_FEATURES.podcasts ? podcastGuardedRoutes : []),
].sort(),
);
@@ -952,12 +972,14 @@ describe('Authorization matrix (e2e)', () => {
});
expect(bypassResponse.statusCode).toBe(200);
const podcastBypassResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
headers: authHeader(ctx.adminToken),
});
expect(podcastBypassResponse.statusCode).toBe(200);
if (APP_FEATURES.podcasts) {
const podcastBypassResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
headers: authHeader(ctx.adminToken),
});
expect(podcastBypassResponse.statusCode).toBe(200);
}
}, 120_000);
it('enforces default-password lock with allow-list exceptions', async () => {
@@ -989,7 +1011,7 @@ describe('Authorization matrix (e2e)', () => {
});
});
describe('custom public guards - podcast stream tickets', () => {
describe.skipIf(!APP_FEATURES.podcasts)('custom public guards - podcast stream tickets', () => {
it('rejects an unauthenticated stream request that carries no ticket', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/podcast-episodes/1/stream' });
expect(response.statusCode).toBe(401);
@@ -1,12 +1,12 @@
{
"totalRoutes": 606,
"totalRoutes": 612,
"byPermission": {
"(none)": 259,
"(none)": 260,
"Permission.ManageMetadataConfig": 34,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 35,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 57,
"Permission.ManageAppSettings": 62,
"Permission.ManageLibraries": 22,
"Permission.ManageUsers": 13,
"Permission.ManageEmail": 8,
@@ -792,6 +792,66 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "listPluginUpdates",
"httpMethod": "GET",
"path": "/admin/request-indexers/plugins/updates",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "checkPluginUpdates",
"httpMethod": "POST",
"path": "/admin/request-indexers/plugins/updates/check",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "inspectPluginUpdate",
"httpMethod": "POST",
"path": "/admin/request-indexers/plugins/:type/update/inspect",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "installPluginUpdate",
"httpMethod": "POST",
"path": "/admin/request-indexers/plugins/:type/update",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "setPluginAutomaticUpdate",
"httpMethod": "PUT",
"path": "/admin/request-indexers/plugins/:type/auto-update",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer-manager.controller.ts",
"className": "IndexerManagerController",