fix(managed): read the wanderer as off on managed, keep the replica alerts mutable, and drop dead guards

This commit is contained in:
Maurice
2026-11-05 13:02:27 +01:00
parent 887f5d8c84
commit 7743e4da16
6 changed files with 65 additions and 23 deletions
@@ -192,7 +192,8 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
</MAdminCard>
{/* API keys, place search and routing: the operator's on a managed install,
same as the desktop tab. Share place details has its own section there. */}
same as the desktop tab, so the whole card is left out. Unlike the desktop
tab, the phone has no share place details section for a managed install. */}
{!managed && (
<MAdminCard>
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
@@ -204,9 +205,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
laptop. */}
<MTrekApiBlock />
{/* The keys belong to the operator on a managed install, same as on the
desktop tab; the provider choice below stays the admin's. */}
{!managed && (<>
<MProviderBlock title={t('admin.mapsKey')} badge={t('admin.googleCaveat.badge')} tone="caution">
{/* Said before the field, not after it: somebody about to paste a key
should read this while deciding, not once they already have. */}
@@ -325,7 +323,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
{...keyInputProps('unsplash')}
/>
</MProviderBlock>
</>)}
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
{/* The app's own picker rather than a bare <select>: a native one is
@@ -378,11 +375,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
</div>
{!managed && (
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
{t('common.save')}
</MAdminButton>
)}
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
)}
+9 -11
View File
@@ -390,12 +390,11 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
as it is changed. */}
{/* On a managed install place search and routing are the operator's: the
services, their allowance and the providers behind them come with the
instance. Share place details stays the admin's call and has a tab of
its own there (AdminPage). */}
instance, so the whole card is left out. Share place details stays the
admin's call and is rendered on its own just above (PlaceContribPanel). */}
{!managed && (
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
<TrekApiCard t={t} managed={managed} />
{!managed && (<>
<TrekApiCard t={t} />
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
so an install can point transit search at Google instead, on the
same key. Falls back to Transitous when no key resolves. A block of
@@ -448,10 +447,9 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<Callout>{t('admin.transitProvider.personalKeyWarning')}</Callout>
)}
</ProviderBlock>
</>)}
{/* Which keyed provider answers place search. Outside the managed guard
on purpose: the operator owns the credentials, but what their users
search against is still the admin's call (see server managed.ts). */}
{/* Which keyed provider answers place search. Never shown on a managed
install: places_provider is operator-locked there (MANAGED_LOCKED_SETTING_KEYS
in server managed.ts), so a pick would be dropped on save. */}
<ProviderBlock title={t('admin.placesProvider.title')}>
<SettingsHint>{t('admin.placesProvider.subtitle')}</SettingsHint>
<div className={savingPlacesProvider ? 'opacity-60' : undefined}>
@@ -504,9 +502,9 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
)}
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
the operator's on a managed install, so the card is not shown there;
which provider answers search stays the admin's call in the card above
(server managed.ts). The per-place Google switches sit under the key
the operator's on a managed install, so the card is not shown there
(server managed.ts), and neither is the provider pick in the card
above. The per-place Google switches sit under the key
because they are set once and then never touched. */}
{!managed && (
<SettingsCard icon={KeyRound} title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')}>
+6 -1
View File
@@ -120,8 +120,13 @@ export class AddonsService {
return this.photoProviders.listEnabledIds();
}
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
/**
* wanderer may be used: Tours is on and the admin allowed it under Tours. Never on a managed
* install, where it would send searches to a server the user picks; the admin switch is locked
* there, so a value stored before the install became managed must not keep it on.
*/
async isWandererAvailable(): Promise<boolean> {
if (readEnv().managed.enabled) return false;
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
}
@@ -1,5 +1,6 @@
import { withRequestContext } from '../database/request-context';
import { StorageEventsService } from '../storage/storage-events.service';
import { offeredEventTypes } from './notification-preferences.service';
import { NotificationsService } from './notifications.service';
import { ReplicaFailureDebouncer } from './replica-failure-debouncer';
import { MikroORM } from '@mikro-orm/core';
@@ -44,8 +45,19 @@ export class StorageHealthNotifierService implements OnApplicationBootstrap {
onApplicationBootstrap(): void {
this.events.onReplicaFailure((failure) => {
// On a managed install the replicas are the operator's, and the
// preference matrix has no row to mute this event, so the admins there
// would get alerts they can neither act on nor switch off. The warning
// keeps the failure in the operator's logs, behind the same debounce so
// an outage is one line an hour per backend rather than one per write.
const suppressed = this.debouncer.admit(failure.backend);
if (suppressed === null) return;
if (!offeredEventTypes().includes('replica_failure')) {
this.logger.warn(
`replica ${failure.op} failed on ${failure.backend}: ${failure.error} (${suppressed} suppressed)`,
);
return;
}
const sendNotification = () =>
this.notifications
.send({
@@ -434,6 +434,22 @@ describe('AddonsService addon/feature flags', () => {
expect((await svc.list()).toursWanderer).toBe(false);
});
it('wanderer is never available on a managed install, whatever the stored switch says', async () => {
const saved = process.env.TREK_MANAGED;
try {
await insertAddon({ id: 'tours', name: 'Tours', enabled: 1 });
await svc.updateToursWanderer(true);
expect(await svc.isWandererAvailable()).toBe(true);
process.env.TREK_MANAGED = '1';
expect(await svc.isWandererAvailable()).toBe(false);
expect((await svc.list()).toursWanderer).toBe(false);
} finally {
if (saved === undefined) delete process.env.TREK_MANAGED;
else process.env.TREK_MANAGED = saved;
}
});
it('OpenHikingMap under Tours is on until an admin switches it off, and offered only with Tours (MAP-01)', async () => {
expect(await svc.getToursHikingMap()).toEqual({ enabled: true }); // absent row → ON
expect((await svc.list()).toursHikingMap).toBe(false); // Tours is off
@@ -4,7 +4,7 @@ import { StorageEventsService } from '../../../../src/nest/storage/storage-event
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
// L4 (task-7-review.md): `orm` is now a required constructor param — in
// production it is always injected, so `@Optional()` was a fail-open seam
@@ -21,6 +21,12 @@ afterAll(async () => {
testDb.close();
});
const savedManaged = process.env.TREK_MANAGED;
afterEach(() => {
if (savedManaged === undefined) delete process.env.TREK_MANAGED;
else process.env.TREK_MANAGED = savedManaged;
});
describe('StorageHealthNotifierService', () => {
it('NOTIF-001 subscribes on bootstrap and sends an admin-scoped replica_failure with params', () => {
const events = new StorageEventsService();
@@ -47,4 +53,14 @@ describe('StorageHealthNotifierService', () => {
notifier.onApplicationBootstrap();
expect(() => events.emitReplicaFailure({ backend: 'b', key: 'k', op: 'delete', error: 'e', at: 3 })).not.toThrow();
});
it('NOTIF-003 a managed install sends nothing, since its admins have no row to mute the alert', () => {
process.env.TREK_MANAGED = '1';
const events = new StorageEventsService();
const send = vi.fn().mockResolvedValue(undefined);
const notifier = new StorageHealthNotifierService(events, { send } as unknown as NotificationsService, t.orm);
notifier.onApplicationBootstrap();
events.emitReplicaFailure({ backend: 's3-bkp', key: 'backups/db.zip', op: 'put', error: 'timeout', at: 4 });
expect(send).not.toHaveBeenCalled();
});
});