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
+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",