From bc62eb0d6f0dd3d6c94561bc6fdb92655fe36fda Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 29 Oct 2026 10:44:50 +0100 Subject: [PATCH] feat(admin): show the allowance of place search, address search and routing in the TREK API card GET /api/admin/service-identity lists places, photon and valhalla, each with today's allowance read fresh from /v1/quota of its own host (cached for 20 seconds per host) and whether this instance asks it right now: Photon only beside the public Nominatim, the TREK Valhalla only where the routing choice comes to it. Photon and Valhalla are asked only with a key and only while TREK_SERVICES_HOSTS lists them. The last allowance an answer stated is kept per host for when a host does not answer. The card lists the three services under each other in the look it had, a service this instance does not ask dimmed and named as such. --- .../Admin/ServiceIdentityPanel.test.tsx | 60 ++++++++-- .../components/Admin/ServiceIdentityPanel.tsx | 56 +++++++-- .../Admin/useServiceIdentity.test.ts | 2 +- client/src/repo/serviceIdentityRepo.test.ts | 8 +- client/tests/helpers/msw/handlers/admin.ts | 2 +- .../nest/service-identity/issuer.client.ts | 15 ++- .../nest/service-identity/quota-tracker.ts | 32 +++-- .../service-identity.constants.ts | 6 + .../service-identity.service.ts | 110 ++++++++++++++---- .../service-request-identity.ts | 2 +- server/tests/e2e/service-identity.e2e.test.ts | 64 ++++++++-- .../service-identity/issuer.client.test.ts | 11 +- .../service-identity.service.test.ts | 101 ++++++++++++++-- .../service-request-identity.test.ts | 11 +- shared/src/i18n/ar/admin.ts | 4 + shared/src/i18n/az/admin.ts | 4 + shared/src/i18n/br/admin.ts | 4 + shared/src/i18n/ca/admin.ts | 4 + shared/src/i18n/cs/admin.ts | 4 + shared/src/i18n/de/admin.ts | 4 + shared/src/i18n/en/admin.ts | 4 + shared/src/i18n/es/admin.ts | 4 + shared/src/i18n/et/admin.ts | 4 + shared/src/i18n/fr/admin.ts | 4 + shared/src/i18n/gr/admin.ts | 4 + shared/src/i18n/hu/admin.ts | 4 + shared/src/i18n/id/admin.ts | 4 + shared/src/i18n/it/admin.ts | 4 + shared/src/i18n/ja/admin.ts | 4 + shared/src/i18n/ko/admin.ts | 4 + shared/src/i18n/nl/admin.ts | 4 + shared/src/i18n/pl/admin.ts | 4 + shared/src/i18n/ru/admin.ts | 4 + shared/src/i18n/sk/admin.ts | 4 + shared/src/i18n/sv/admin.ts | 4 + shared/src/i18n/th/admin.ts | 4 + shared/src/i18n/tr/admin.ts | 4 + shared/src/i18n/uk/admin.ts | 4 + shared/src/i18n/vi/admin.ts | 4 + shared/src/i18n/zh-TW/admin.ts | 4 + shared/src/i18n/zh/admin.ts | 4 + .../service-identity.schema.spec.ts | 17 ++- .../service-identity.schema.ts | 15 ++- 43 files changed, 531 insertions(+), 89 deletions(-) diff --git a/client/src/components/Admin/ServiceIdentityPanel.test.tsx b/client/src/components/Admin/ServiceIdentityPanel.test.tsx index 41c4a53f7..5d2dcea67 100644 --- a/client/src/components/Admin/ServiceIdentityPanel.test.tsx +++ b/client/src/components/Admin/ServiceIdentityPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-SVCID-001 to FE-ADMIN-SVCID-008 +// FE-ADMIN-SVCID-001 to FE-ADMIN-SVCID-010 import userEvent from '@testing-library/user-event'; import type { ServiceIdentityStatus } from '@trek/shared'; import { http, HttpResponse } from 'msw'; @@ -13,11 +13,16 @@ vi.mock('../../i18n', async (importOriginal) => ({ useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }), })); +const QUOTA = { limit: 10_000, used: 2_500, remaining: 7_500, resetAt: '2026-10-30T00:00:00.000Z' }; const CONNECTED: ServiceIdentityStatus = { state: 'connected', tier: 'std', keySource: 'registered', - quota: { limit: 10_000, used: 2_500, remaining: 7_500, resetAt: '2026-10-30T00:00:00.000Z' }, + services: [ + { service: 'places', inUse: true, quota: QUOTA }, + { service: 'photon', inUse: false, quota: null }, + { service: 'valhalla', inUse: false, quota: null }, + ], lastSeenAt: '2026-10-29T12:00:00.000Z', }; @@ -31,7 +36,7 @@ describe('ServiceIdentityPanel', () => { render(); expect(await screen.findByText('admin.trekApi.quota.state.connected')).toBeInTheDocument(); expect(screen.getByText('admin.trekApi.quota.tier.std')).toBeInTheDocument(); - const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.title' }); + const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' }); expect(bar).toHaveAttribute('aria-valuenow', '2500'); expect(bar).toHaveAttribute('aria-valuemax', '10000'); expect(screen.getByText('2,500 / 10,000')).toBeInTheDocument(); @@ -41,7 +46,7 @@ describe('ServiceIdentityPanel', () => { }); it('FE-ADMIN-SVCID-002: an installation without a key says so and offers a new identity', async () => { - serve({ state: 'legacy', tier: 'legacy', keySource: null, quota: null, lastSeenAt: null }); + serve({ state: 'legacy', tier: 'legacy', keySource: null, services: [], lastSeenAt: null }); render(); expect(await screen.findByText('admin.trekApi.quota.hint.legacy')).toBeInTheDocument(); expect(screen.queryByRole('progressbar')).toBeNull(); @@ -49,7 +54,7 @@ describe('ServiceIdentityPanel', () => { }); it('FE-ADMIN-SVCID-003: switched off, it shows why and offers nothing', async () => { - serve({ state: 'off', tier: null, keySource: null, quota: null, lastSeenAt: null }); + serve({ state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null }); render(); expect(await screen.findByText('admin.trekApi.quota.hint.off')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull(); @@ -62,10 +67,45 @@ describe('ServiceIdentityPanel', () => { expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull(); }); - it('FE-ADMIN-SVCID-005: a connected key without an allowance to show says it cannot be read', async () => { - serve({ ...CONNECTED, quota: null }); + it('FE-ADMIN-SVCID-005: a service in use without an allowance to show says it cannot be read', async () => { + serve({ ...CONNECTED, services: [{ service: 'places', inUse: true, quota: null }] }); render(); expect(await screen.findByText('admin.trekApi.quota.unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); + + it('FE-ADMIN-SVCID-009: lists place search, address search and routing, each with its own allowance', async () => { + serve({ + ...CONNECTED, + services: [ + { service: 'places', inUse: true, quota: QUOTA }, + { service: 'photon', inUse: true, quota: { ...QUOTA, used: 40, remaining: 9_960 } }, + { service: 'valhalla', inUse: true, quota: { ...QUOTA, used: 9_500, remaining: 500 } }, + ], + }); + render(); + const list = await screen.findByRole('list', { name: 'admin.trekApi.quota.title' }); + expect(list.querySelectorAll('li')).toHaveLength(3); + const value = (name: string) => screen.getByRole('progressbar', { name }).getAttribute('aria-valuenow'); + expect(value('admin.trekApi.quota.service.places')).toBe('2500'); + expect(value('admin.trekApi.quota.service.photon')).toBe('40'); + expect(value('admin.trekApi.quota.service.valhalla')).toBe('9500'); + expect(screen.queryByText('admin.trekApi.quota.notInUse')).toBeNull(); + }); + + it('FE-ADMIN-SVCID-010: a service this instance does not ask is named as not in use, its allowance still shown', async () => { + serve({ + ...CONNECTED, + services: [ + { service: 'places', inUse: true, quota: QUOTA }, + { service: 'photon', inUse: false, quota: null }, + { service: 'valhalla', inUse: false, quota: QUOTA }, + ], + }); + render(); + expect(await screen.findAllByText('admin.trekApi.quota.notInUse')).toHaveLength(2); + expect(screen.getAllByRole('progressbar')).toHaveLength(2); + expect(screen.queryByText('admin.trekApi.quota.unavailable')).toBeNull(); }); it('FE-ADMIN-SVCID-006: a failed read says so instead of showing nothing', async () => { @@ -76,7 +116,7 @@ describe('ServiceIdentityPanel', () => { it('FE-ADMIN-SVCID-007: a revoked key asks before it starts over, then shows the new state', async () => { let regenerated = 0; - serve({ state: 'revoked', tier: 'std', keySource: 'registered', quota: null, lastSeenAt: null }); + serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null }); server.use( http.post('/api/admin/service-identity/regenerate', () => { regenerated += 1; @@ -84,7 +124,7 @@ describe('ServiceIdentityPanel', () => { state: 'registering', tier: 'legacy', keySource: null, - quota: null, + services: [], lastSeenAt: null, }); }) @@ -106,7 +146,7 @@ describe('ServiceIdentityPanel', () => { }); it('FE-ADMIN-SVCID-008: a managed install leaves the identity to its operator', async () => { - serve({ state: 'revoked', tier: 'std', keySource: 'registered', quota: null, lastSeenAt: null }); + serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null }); render(); expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull(); diff --git a/client/src/components/Admin/ServiceIdentityPanel.tsx b/client/src/components/Admin/ServiceIdentityPanel.tsx index cdc4fd929..f4455d173 100644 --- a/client/src/components/Admin/ServiceIdentityPanel.tsx +++ b/client/src/components/Admin/ServiceIdentityPanel.tsx @@ -1,4 +1,10 @@ -import type { ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared'; +import type { + ServiceAllowance, + ServiceIdentityState, + ServiceIdentityStatus, + ServiceName, + ServiceQuotaView, +} from '@trek/shared'; import { RefreshCw } from 'lucide-react'; import { useRef, useState } from 'react'; import { useTranslation } from '../../i18n'; @@ -18,6 +24,12 @@ const STATE_DOT: Record = { revoked: 'bg-danger', }; +const SERVICE_LABEL: Record = { + places: 'admin.trekApi.quota.service.places', + photon: 'admin.trekApi.quota.service.photon', + valhalla: 'admin.trekApi.quota.service.valhalla', +}; + /** Below a tenth of the day left the bar turns amber, at nothing left red. */ function barTone(quota: ServiceQuotaView): string { if (quota.remaining === 0) return 'bg-danger'; @@ -33,7 +45,7 @@ function Stat({ label, value }: { label: string; value: string }) { ); } -function QuotaMeter({ quota }: { quota: ServiceQuotaView }) { +function QuotaMeter({ quota, label }: { quota: ServiceQuotaView; label: string }) { const { t, locale } = useTranslation(); const is12h = useSettingsStore((s) => s.settings.time_format === '12h'); const number = new Intl.NumberFormat(locale); @@ -48,7 +60,7 @@ function QuotaMeter({ quota }: { quota: ServiceQuotaView }) {
+

{name}

+ {allowance.quota && } + {note &&

{note}

} + + ); +} + function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, failed: boolean): string | null { if (!status) return failed ? t('admin.trekApi.quota.unavailable') : null; - if (status.state !== 'connected') return t(`admin.trekApi.quota.hint.${status.state}`); - return status.quota ? null : t('admin.trekApi.quota.unavailable'); + return status.state === 'connected' ? null : t(`admin.trekApi.quota.hint.${status.state}`); } /** * How this installation stands with the TREK services: whether it has its key, - * today's allowance against what is used, and when the count starts again. + * and for place search, address search and routing today's allowance against + * what is used, and when the count starts again. * Read when the card is in view and refreshed every 30 seconds while it stays. * A managed install leaves the identity to its operator, so it offers no new one. */ @@ -121,7 +157,13 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo )}
- {status?.quota && status.state !== 'off' && } + {status && status.services.length > 0 && ( +
    + {status.services.map((allowance) => ( + + ))} +
+ )} {hint &&

{hint}

} {status?.keySource === 'env' && ( diff --git a/client/src/components/Admin/useServiceIdentity.test.ts b/client/src/components/Admin/useServiceIdentity.test.ts index 4fc165723..b69524d9b 100644 --- a/client/src/components/Admin/useServiceIdentity.test.ts +++ b/client/src/components/Admin/useServiceIdentity.test.ts @@ -17,7 +17,7 @@ const LEGACY: ServiceIdentityStatus = { state: 'legacy', tier: 'legacy', keySource: null, - quota: null, + services: [], lastSeenAt: null, }; const card = { current: null }; diff --git a/client/src/repo/serviceIdentityRepo.test.ts b/client/src/repo/serviceIdentityRepo.test.ts index 8d661aebc..9801e66e6 100644 --- a/client/src/repo/serviceIdentityRepo.test.ts +++ b/client/src/repo/serviceIdentityRepo.test.ts @@ -11,7 +11,13 @@ const STATUS: ServiceIdentityStatus = { state: 'connected', tier: 'std', keySource: 'registered', - quota: { limit: 10_000, used: 1, remaining: 9_999, resetAt: '2026-10-30T00:00:00.000Z' }, + services: [ + { + service: 'places', + inUse: true, + quota: { limit: 10_000, used: 1, remaining: 9_999, resetAt: '2026-10-30T00:00:00.000Z' }, + }, + ], lastSeenAt: null, }; diff --git a/client/tests/helpers/msw/handlers/admin.ts b/client/tests/helpers/msw/handlers/admin.ts index 14b9aadf2..112a63045 100644 --- a/client/tests/helpers/msw/handlers/admin.ts +++ b/client/tests/helpers/msw/handlers/admin.ts @@ -164,6 +164,6 @@ export const adminHandlers = [ // The TREK API card's allowance panel, read whenever the settings tab renders it. http.get('/api/admin/service-identity', () => { - return HttpResponse.json({ state: 'legacy', tier: 'legacy', keySource: null, quota: null, lastSeenAt: null }); + return HttpResponse.json({ state: 'legacy', tier: 'legacy', keySource: null, services: [], lastSeenAt: null }); }), ]; diff --git a/server/src/nest/service-identity/issuer.client.ts b/server/src/nest/service-identity/issuer.client.ts index bb581072d..8999de357 100644 --- a/server/src/nest/service-identity/issuer.client.ts +++ b/server/src/nest/service-identity/issuer.client.ts @@ -42,7 +42,7 @@ export class IssuerError extends Error { } } -/** The four calls TREK makes to the key issuer at places.liketrek.com. */ +/** The calls TREK makes to the key issuer at places.liketrek.com, and the allowance any liketrek host reports. */ @Injectable() export class IssuerClient { challenge(): Promise> { @@ -57,22 +57,25 @@ export class IssuerClient { return this.call('POST', '/v1/register/renew', serviceKeyGrantSchema, { bearer: key }); } - /** Today's allowance of the key, or of the shared legacy pool without one. Costs no allowance itself. */ - quota(key: string | null): Promise> { - return this.call('GET', '/v1/quota', serviceQuotaSchema, { bearer: key }); + /** + * Today's allowance of the key at the host of `origin`, or of the shared + * legacy pool without one (only Places has that pool). Costs no allowance itself. + */ + quota(origin: string, key: string | null): Promise> { + return this.call('GET', '/v1/quota', serviceQuotaSchema, { bearer: key, origin }); } private async call( method: 'GET' | 'POST', path: string, schema: z.ZodType, - opts: { body?: RegistrationRequest; bearer?: string | null } = {}, + opts: { body?: RegistrationRequest; bearer?: string | null; origin?: string } = {}, ): Promise> { const headers: Record = { Accept: 'application/json', 'User-Agent': serviceUserAgent() }; if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`; if (opts.body) headers['Content-Type'] = 'application/json'; const res = await safeFetch( - `${SERVICE_ISSUER_ORIGIN}${path}`, + `${opts.origin ?? SERVICE_ISSUER_ORIGIN}${path}`, { method, headers, diff --git a/server/src/nest/service-identity/quota-tracker.ts b/server/src/nest/service-identity/quota-tracker.ts index 925fa8324..8fa758db8 100644 --- a/server/src/nest/service-identity/quota-tracker.ts +++ b/server/src/nest/service-identity/quota-tracker.ts @@ -2,27 +2,37 @@ import type { QuotaReading } from './service-identity.helpers'; import { Injectable } from '@nestjs/common'; interface QuotaSighting extends QuotaReading { - /** When a liketrek service last answered this process. */ + /** When this host last answered this process. */ seenAt: string; } /** - * The allowance as the last liketrek answer stated it. Kept in memory only: - * it is a fallback for the admin card when the issuer cannot be asked. + * The allowance as the last answer of each liketrek host stated it. Kept in + * memory only: it is a fallback for the admin card when a host cannot be asked. */ @Injectable() export class QuotaTracker { - private last: QuotaSighting | null = null; + private readonly byHost = new Map(); - record(reading: QuotaReading | null, at = new Date()): void { - this.last = { - tier: reading?.tier ?? this.last?.tier ?? null, - quota: reading?.quota ?? this.last?.quota ?? null, + record(host: string, reading: QuotaReading | null, at = new Date()): void { + const last = this.byHost.get(host); + this.byHost.set(host, { + tier: reading?.tier ?? last?.tier ?? null, + quota: reading?.quota ?? last?.quota ?? null, seenAt: at.toISOString(), - }; + }); } - latest(): QuotaSighting | null { - return this.last; + latest(host: string): QuotaSighting | null { + return this.byHost.get(host) ?? null; + } + + /** The most recent answer of any host. */ + newest(): QuotaSighting | null { + let newest: QuotaSighting | null = null; + for (const sighting of this.byHost.values()) { + if (!newest || sighting.seenAt > newest.seenAt) newest = sighting; + } + return newest; } } diff --git a/server/src/nest/service-identity/service-identity.constants.ts b/server/src/nest/service-identity/service-identity.constants.ts index 97ebd17d3..6a815c4a9 100644 --- a/server/src/nest/service-identity/service-identity.constants.ts +++ b/server/src/nest/service-identity/service-identity.constants.ts @@ -1,2 +1,8 @@ /** The issuer of installation keys: the Places host itself, so no new egress domain is needed. */ export const SERVICE_ISSUER_ORIGIN = 'https://places.liketrek.com'; + +/** The TREK services' Photon, the address search that stands in for the public Nominatim. */ +export const PHOTON_ORIGIN = 'https://photon.liketrek.com'; + +/** The Nominatim TREK asks when NOMINATIM_URL names none of the admin's own. */ +export const PUBLIC_NOMINATIM_URL = 'https://nominatim.openstreetmap.org'; diff --git a/server/src/nest/service-identity/service-identity.service.ts b/server/src/nest/service-identity/service-identity.service.ts index 9ca782320..3063627a4 100644 --- a/server/src/nest/service-identity/service-identity.service.ts +++ b/server/src/nest/service-identity/service-identity.service.ts @@ -1,30 +1,42 @@ +import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { servicesConfig } from '../app-config/tokens'; import { logDebug } from '../audit/audit-log.logger'; +import { SettingsService } from '../settings/settings.service'; import { IssuerClient } from './issuer.client'; import { QuotaTracker } from './quota-tracker'; import { ServiceClock } from './service-clock'; -import { SERVICE_ISSUER_ORIGIN } from './service-identity.constants'; -import { isAllowlisted } from './service-identity.helpers'; +import { PHOTON_ORIGIN, PUBLIC_NOMINATIM_URL, SERVICE_ISSUER_ORIGIN } from './service-identity.constants'; +import { isAllowlisted, sameServiceUrl } from './service-identity.helpers'; import { ServiceKeyService, type KeySnapshot } from './service-key.service'; import { Inject, Injectable } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import { + SERVICE_NAMES, + resolveValhallaProvider, serviceTierSchema, + type ServiceAllowance, type ServiceIdentityStatus, + type ServiceName, type ServiceQuota, type ServiceQuotaView, type ServiceTier, } from '@trek/shared'; -/** The admin card refreshes every 30 s; one issuer call serves every open card for this long. */ +/** The admin card refreshes every 30 s; one call per host serves every open card for this long. */ const QUOTA_CACHE_MS = 20_000; -const OFF: ServiceIdentityStatus = { state: 'off', tier: null, keySource: null, quota: null, lastSeenAt: null }; +const OFF: ServiceIdentityStatus = { state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null }; + +interface CachedQuota { + bearer: string | null; + at: number; + quota: ServiceQuota | null; +} /** What the admin sees of this installation's identity, and the one thing an admin can do with it. */ @Injectable() export class ServiceIdentityService { - private cached: { bearer: string | null; at: number; quota: ServiceQuota | null } | null = null; + private readonly cached = new Map(); constructor( @Inject(servicesConfig.KEY) private readonly config: ConfigType, @@ -32,59 +44,109 @@ export class ServiceIdentityService { private readonly issuer: IssuerClient, private readonly tracker: QuotaTracker, private readonly clock: ServiceClock, + private readonly settings: SettingsService, + private readonly env: RuntimeEnvService, ) {} async status(): Promise { const key = await this.keys.snapshot(); if (key.state === 'off') return OFF; - const fresh = await this.freshQuota(key.bearer); - const seen = this.tracker.latest(); + const inUse = await this.servicesInUse(key); + const fresh = await Promise.all(SERVICE_NAMES.map((service) => this.freshQuota(service, key.bearer))); + const services = SERVICE_NAMES.map((service, index): ServiceAllowance => ({ + service, + inUse: inUse[service], + quota: viewOf(fresh[index]) ?? this.tracker.latest(hostOf(this.originOf(service)))?.quota ?? null, + })); return { state: key.state, - tier: fresh?.tier ?? seen?.tier ?? keyTier(key), + tier: + fresh[SERVICE_NAMES.indexOf('places')]?.tier ?? + this.tracker.latest(hostOf(SERVICE_ISSUER_ORIGIN))?.tier ?? + keyTier(key), keySource: key.source, - quota: fresh ? quotaView(fresh) : (seen?.quota ?? null), - lastSeenAt: seen?.seenAt ?? null, + services, + lastSeenAt: this.tracker.newest()?.seenAt ?? null, }; } async regenerate(): Promise { await this.keys.regenerate(); - this.cached = null; + this.cached.clear(); return this.status(); } - /** GET /v1/quota, which costs no allowance; null when the issuer cannot or may not be asked right now. */ - private async freshQuota(bearer: string | null): Promise { - if (bearer && !this.issuerMaySeeKey()) return null; - if (this.cached && this.cached.bearer === bearer && Date.now() - this.cached.at < QUOTA_CACHE_MS) { - return this.cached.quota; - } + private originOf(service: ServiceName): string { + if (service === 'photon') return PHOTON_ORIGIN; + return service === 'valhalla' ? this.config.valhallaUrl : SERVICE_ISSUER_ORIGIN; + } + + /** + * Whether this instance asks each service right now. Places is in use + * whenever the identity is; the other two need the key itself, since they + * have no shared pool. Photon stands in for the public Nominatim only, and + * the TREK Valhalla answers only where the routing choice comes to it. + */ + private async servicesInUse(key: KeySnapshot): Promise> { + const keyed = (service: ServiceName) => key.bearer !== null && this.mayCarryKey(this.originOf(service)); + const defaults = await this.settings.getAdminUserDefaults(); + const routing = resolveValhallaProvider({ + provider: defaults.routing_valhalla_provider, + valhallaUrl: defaults.valhalla_base_url, + routingUrl: defaults.routing_base_url, + trekAvailable: keyed('valhalla'), + }); + return { + places: true, + photon: keyed('photon') && sameServiceUrl(this.env.env().integrations.nominatimUrl, PUBLIC_NOMINATIM_URL), + valhalla: routing === 'trek', + }; + } + + /** + * GET /v1/quota of the service's host, which costs no allowance; null when + * the host cannot or may not be asked right now. Without a key only Places + * answers, for the shared pool. + */ + private async freshQuota(service: ServiceName, bearer: string | null): Promise { + const origin = this.originOf(service); + if (bearer ? !this.mayCarryKey(origin) : service !== 'places') return null; + const cached = this.cached.get(origin); + if (cached && cached.bearer === bearer && Date.now() - cached.at < QUOTA_CACHE_MS) return cached.quota; let quota: ServiceQuota | null = null; try { - const answer = await this.issuer.quota(bearer); + const answer = await this.issuer.quota(origin, bearer); this.clock.learn(answer.serverMs); quota = answer.body; } catch (err) { - logDebug(`TREK services: quota not available: ${err instanceof Error ? err.message : String(err)}`); + logDebug(`TREK services: ${service} quota not available: ${err instanceof Error ? err.message : String(err)}`); } - this.cached = { bearer, at: Date.now(), quota }; + this.cached.set(origin, { bearer, at: Date.now(), quota }); return quota; } - /** Even the issuer sees the key only while TREK_SERVICES_HOSTS lists it. */ - private issuerMaySeeKey(): boolean { - return isAllowlisted(new URL(SERVICE_ISSUER_ORIGIN), this.config.hosts); + /** A host sees the key only while TREK_SERVICES_HOSTS lists it. */ + private mayCarryKey(origin: string): boolean { + try { + return isAllowlisted(new URL(origin), this.config.hosts); + } catch { + return false; + } } } +function hostOf(origin: string): string { + return new URL(origin).hostname.toLowerCase(); +} + function keyTier(key: KeySnapshot): ServiceTier | null { if (!key.key) return key.state === 'legacy' || key.state === 'registering' ? 'legacy' : null; const tier = serviceTierSchema.safeParse(key.key.payload.q); return tier.success ? tier.data : null; } -function quotaView(quota: ServiceQuota): ServiceQuotaView { +function viewOf(quota: ServiceQuota | null): ServiceQuotaView | null { + if (!quota) return null; return { limit: quota.limit, used: quota.used, diff --git a/server/src/nest/service-identity/service-request-identity.ts b/server/src/nest/service-identity/service-request-identity.ts index ca2ee0281..dc4d223fd 100644 --- a/server/src/nest/service-identity/service-request-identity.ts +++ b/server/src/nest/service-identity/service-request-identity.ts @@ -38,7 +38,7 @@ export class ServiceRequestIdentity extends ServiceIdentityPort { observe(url: URL, response: Response): void { if (!isAllowlisted(url, this.config.hosts)) return; this.clock.learn(serverDateMs(response.headers)); - this.tracker.record(readQuotaHeaders(response.headers)); + this.tracker.record(url.hostname.toLowerCase(), readQuotaHeaders(response.headers)); if (response.ok) this.keys.noteAccepted(); else if (response.status === 401) this.keys.noteInvalid(); else if (response.status === 403) void this.checkRevocation(response.clone()); diff --git a/server/tests/e2e/service-identity.e2e.test.ts b/server/tests/e2e/service-identity.e2e.test.ts index 3a91edcdd..96886314f 100644 --- a/server/tests/e2e/service-identity.e2e.test.ts +++ b/server/tests/e2e/service-identity.e2e.test.ts @@ -38,18 +38,25 @@ const ADMIN = 2; const ENV_KEY = serviceKeyTokenFor({ i: 'from-environment', iat: 1_793_000_000, exp: 4_102_444_800, q: 'own' }); const STORED_KEY = serviceKeyTokenFor({ i: 'registered-one', iat: 1_793_000_000, exp: 4_102_444_800 }); const QUOTA = { tier: 'own', limit: 50_000, used: 1_234, remaining: 48_766, reset_at: 1_793_059_200, per_minute: 600 }; +const VIEW = { limit: 50_000, used: 1_234, remaining: 48_766, resetAt: new Date(1_793_059_200_000).toISOString() }; const writeAudit = vi.fn(); let orm: TestOrm; -type ServicesConfig = { mode: 'auto' | 'off'; key: string | null; hosts: string[] }; +type ServicesConfig = { mode: 'auto' | 'off'; key: string | null; hosts: string[]; valhallaUrl: string }; async function build(config: Partial = {}) { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), ServiceIdentityModule], }) .overrideProvider(servicesConfig.KEY) - .useValue({ mode: 'auto', key: null, hosts: ['places.liketrek.com'], ...config }) + .useValue({ + mode: 'auto', + key: null, + hosts: ['places.liketrek.com'], + valhallaUrl: 'https://valhalla.liketrek.com', + ...config, + }) .overrideProvider(AuditService) .useValue({ writeAudit }) .compile(); @@ -61,7 +68,7 @@ async function build(config: Partial = {}) { return nest; } -/** The issuer as places.liketrek.com answers /v1/quota. */ +/** Every liketrek host as it answers /v1/quota. */ function issuerAnswers(status = 200) { safeFetch.mockImplementation(async () => status === 200 @@ -107,16 +114,21 @@ describe('with a key from TREK_SERVICES_KEY', () => { expect((await request(server).post('/api/admin/service-identity/regenerate').set(as(USER))).status).toBe(403); }); - it('SIE2E-002: shows the allowance the issuer reports for that key', async () => { + it('SIE2E-002: shows the allowance the issuer reports for that key, and no other host sees it', async () => { const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN)); expect(res.status).toBe(200); expect(res.body).toEqual({ state: 'connected', tier: 'own', keySource: 'env', - quota: { limit: 50_000, used: 1_234, remaining: 48_766, resetAt: new Date(1_793_059_200_000).toISOString() }, + services: [ + { service: 'places', inUse: true, quota: VIEW }, + { service: 'photon', inUse: false, quota: null }, + { service: 'valhalla', inUse: false, quota: null }, + ], lastSeenAt: null, }); + expect(safeFetch).toHaveBeenCalledTimes(1); const [url, init] = safeFetch.mock.calls[0] as [string, RequestInit & { headers: Record }]; expect(url).toBe('https://places.liketrek.com/v1/quota'); expect(init.headers.Authorization).toBe(`Bearer ${ENV_KEY}`); @@ -147,7 +159,8 @@ describe('with a key the installation registered', () => { issuerAnswers(503); const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN)); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ state: 'connected', tier: 'std', keySource: 'registered', quota: null }); + expect(res.body).toMatchObject({ state: 'connected', tier: 'std', keySource: 'registered' }); + expect(res.body.services[0]).toEqual({ service: 'places', inUse: true, quota: null }); }); it('SIE2E-011: a new identity drops the stored key, is audited, and leaves the shared pool until the next search', async () => { @@ -166,12 +179,49 @@ describe('with a key the installation registered', () => { }); }); +describe('with every liketrek host listed in TREK_SERVICES_HOSTS', () => { + const ALL_HOSTS = ['places.liketrek.com', 'photon.liketrek.com', 'valhalla.liketrek.com']; + + it('SIE2E-030: asks each host for its own allowance and says which services this instance uses', async () => { + const app = await build({ key: ENV_KEY, hosts: ALL_HOSTS }); + try { + const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN)); + expect(res.status).toBe(200); + expect(res.body.services).toEqual([ + { service: 'places', inUse: true, quota: VIEW }, + { service: 'photon', inUse: true, quota: VIEW }, + { service: 'valhalla', inUse: true, quota: VIEW }, + ]); + const asked = safeFetch.mock.calls.map(([url]) => String(url)).sort(); + expect(asked).toEqual([ + 'https://photon.liketrek.com/v1/quota', + 'https://places.liketrek.com/v1/quota', + 'https://valhalla.liketrek.com/v1/quota', + ]); + } finally { + await app.close(); + } + }); + + it('SIE2E-031: the routing service is not in use once the admin picks FOSSGIS', async () => { + const app = await build({ key: ENV_KEY, hosts: ALL_HOSTS }); + await upsertRow(orm, AppSettings, { key: 'default_user_setting_routing_valhalla_provider', value: '"fossgis"' }); + try { + const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN)); + expect(res.body.services[2]).toEqual({ service: 'valhalla', inUse: false, quota: VIEW }); + } finally { + await deleteRows(orm, AppSettings, { key: 'default_user_setting_routing_valhalla_provider' }); + await app.close(); + } + }); +}); + describe('with TREK_SERVICES=off', () => { it('SIE2E-020: says so and asks the issuer nothing', async () => { const app = await build({ mode: 'off' }); try { const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN)); - expect(res.body).toEqual({ state: 'off', tier: null, keySource: null, quota: null, lastSeenAt: null }); + expect(res.body).toEqual({ state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null }); expect(safeFetch).not.toHaveBeenCalled(); const regenerate = await request(app.getHttpServer()) .post('/api/admin/service-identity/regenerate') diff --git a/server/tests/unit/nest/service-identity/issuer.client.test.ts b/server/tests/unit/nest/service-identity/issuer.client.test.ts index 5a44b54d9..6e4a880a0 100644 --- a/server/tests/unit/nest/service-identity/issuer.client.test.ts +++ b/server/tests/unit/nest/service-identity/issuer.client.test.ts @@ -59,10 +59,19 @@ describe('IssuerClient', () => { const quota = { tier: 'legacy', limit: 3000, used: 3, remaining: 2997, reset_at: 1_793_059_200, per_minute: 60 }; safeFetch.mockResolvedValueOnce(json(quota)); - expect((await issuer.quota(null)).body).toEqual(quota); + expect((await issuer.quota('https://places.liketrek.com', null)).body).toEqual(quota); + expect(lastCall().url).toBe('https://places.liketrek.com/v1/quota'); expect(lastCall().init.headers).not.toHaveProperty('Authorization'); }); + it('SIISS-006: reads the allowance of another liketrek host at its own /v1/quota', async () => { + const quota = { tier: 'std', limit: 20_000, used: 5, remaining: 19_995, reset_at: 1_793_059_200, per_minute: 120 }; + safeFetch.mockResolvedValueOnce(json(quota)); + expect((await issuer.quota('https://valhalla.liketrek.com', 'tk1.a.b.c')).body).toEqual(quota); + expect(lastCall().url).toBe('https://valhalla.liketrek.com/v1/quota'); + expect(lastCall().init.headers.Authorization).toBe('Bearer tk1.a.b.c'); + }); + it('SIISS-004: turns a refusal into an IssuerError with the wait it asked for', async () => { safeFetch.mockResolvedValueOnce(json({ error: 'slow_down' }, 429, { 'Retry-After': '90' })); const err = await issuer.challenge().catch((e: unknown) => e); diff --git a/server/tests/unit/nest/service-identity/service-identity.service.test.ts b/server/tests/unit/nest/service-identity/service-identity.service.test.ts index c355c9f9f..52f22bdc9 100644 --- a/server/tests/unit/nest/service-identity/service-identity.service.test.ts +++ b/server/tests/unit/nest/service-identity/service-identity.service.test.ts @@ -1,8 +1,10 @@ /** * ServiceIdentityService: what the admin card reads. A fresh allowance from - * the issuer when it answers, the last one a liketrek answer stated when it - * does not, nothing at all while the services are off. + * each liketrek host when it answers, the last one that host stated when it + * does not, nothing at all while the services are off; and which of the + * three services this instance asks. */ +import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service'; import { servicesConfig } from '../../../../src/nest/app-config/tokens'; import { IssuerClient } from '../../../../src/nest/service-identity/issuer.client'; import { QuotaTracker } from '../../../../src/nest/service-identity/quota-tracker'; @@ -10,6 +12,7 @@ import { ServiceClock } from '../../../../src/nest/service-identity/service-cloc import { ServiceIdentityService } from '../../../../src/nest/service-identity/service-identity.service'; import { parseServiceKey } from '../../../../src/nest/service-identity/service-key.format'; import { ServiceKeyService, type KeySnapshot } from '../../../../src/nest/service-identity/service-key.service'; +import { SettingsService } from '../../../../src/nest/settings/settings.service'; import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { serviceKeyTokenFor } from '../../../helpers/service-keys'; import { createTestModule, type TestModule } from '../../../helpers/test-module'; @@ -22,7 +25,17 @@ const QUOTA = { tier: 'std', limit: 10_000, used: 250, remaining: 9_750, reset_a const keys = { snapshot: vi.fn(async (): Promise => CONNECTED), regenerate: vi.fn(async () => undefined) }; const issuer = { quota: vi.fn() }; -const config = { mode: 'auto' as const, key: null, hosts: ['places.liketrek.com'] }; +const config = { + mode: 'auto' as const, + key: null, + hosts: ['places.liketrek.com'], + valhallaUrl: 'https://valhalla.liketrek.com', +}; +const ALL_HOSTS = ['places.liketrek.com', 'photon.liketrek.com', 'valhalla.liketrek.com']; +const defaults = vi.fn(async (): Promise> => ({})); +const nominatim = { url: 'https://nominatim.openstreetmap.org' }; +const env = { env: () => ({ integrations: { nominatimUrl: nominatim.url } }) }; +const VIEW = { limit: 10_000, used: 250, remaining: 9_750, resetAt: new Date(1_793_059_200_000).toISOString() }; const testDb = createSnapshotTestDb(); let t: TestModule; @@ -31,6 +44,8 @@ let identity: ServiceIdentityService; beforeEach(async () => { vi.clearAllMocks(); config.hosts = ['places.liketrek.com']; + nominatim.url = 'https://nominatim.openstreetmap.org'; + defaults.mockResolvedValue({}); issuer.quota.mockResolvedValue({ body: QUOTA, serverMs: null }); t = await createTestModule({ db: testDb, @@ -42,6 +57,8 @@ beforeEach(async () => { { provide: ServiceKeyService, useValue: keys }, { provide: IssuerClient, useValue: issuer }, { provide: servicesConfig.KEY, useValue: config }, + { provide: SettingsService, useValue: { getAdminUserDefaults: defaults } }, + { provide: RuntimeEnvService, useValue: env }, ], }); identity = t.get(ServiceIdentityService); @@ -60,10 +77,15 @@ describe('ServiceIdentityService.status', () => { state: 'connected', tier: 'std', keySource: 'registered', - quota: { limit: 10_000, used: 250, remaining: 9_750, resetAt: new Date(1_793_059_200_000).toISOString() }, + services: [ + { service: 'places', inUse: true, quota: VIEW }, + { service: 'photon', inUse: false, quota: null }, + { service: 'valhalla', inUse: false, quota: null }, + ], lastSeenAt: null, }); - expect(issuer.quota).toHaveBeenCalledWith(TOKEN); + expect(issuer.quota).toHaveBeenCalledTimes(1); + expect(issuer.quota).toHaveBeenCalledWith('https://places.liketrek.com', TOKEN); }); it('SISVC-002: asks the issuer once per 20 seconds, however often the card refreshes', async () => { @@ -78,24 +100,31 @@ describe('ServiceIdentityService.status', () => { it('SISVC-003: falls back to the last stated allowance, then to the class in the key', async () => { issuer.quota.mockRejectedValue(new Error('fetch failed')); - expect(await identity.status()).toMatchObject({ tier: 'hosted', quota: null }); - t.get(QuotaTracker).record({ tier: 'std', quota: { limit: 5, used: 1, remaining: 4, resetAt: 'x' } }); + const unanswered = await identity.status(); + expect(unanswered.tier).toBe('hosted'); + expect(unanswered.services[0]).toEqual({ service: 'places', inUse: true, quota: null }); + t.get(QuotaTracker).record('places.liketrek.com', { + tier: 'std', + quota: { limit: 5, used: 1, remaining: 4, resetAt: 'x' }, + }); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(Date.now() + 21_000); - expect(await identity.status()).toMatchObject({ tier: 'std', quota: { limit: 5 }, lastSeenAt: expect.any(String) }); + const remembered = await identity.status(); + expect(remembered).toMatchObject({ tier: 'std', lastSeenAt: expect.any(String) }); + expect(remembered.services[0].quota).toMatchObject({ limit: 5 }); }); it('SISVC-004: asks for the shared pool without a key, and asks nothing while off', async () => { keys.snapshot.mockResolvedValueOnce({ state: 'legacy', key: null, source: null, bearer: null }); await identity.status(); - expect(issuer.quota).toHaveBeenCalledWith(null); + expect(issuer.quota).toHaveBeenCalledWith('https://places.liketrek.com', null); keys.snapshot.mockResolvedValueOnce({ state: 'off', key: null, source: null, bearer: null }); expect(await identity.status()).toEqual({ state: 'off', tier: null, keySource: null, - quota: null, + services: [], lastSeenAt: null, }); expect(issuer.quota).toHaveBeenCalledTimes(1); @@ -107,12 +136,60 @@ describe('ServiceIdentityService.status', () => { const after = await identity.regenerate(); expect(keys.regenerate).toHaveBeenCalledTimes(1); expect(after.state).toBe('registering'); - expect(issuer.quota).toHaveBeenLastCalledWith(null); + expect(issuer.quota).toHaveBeenLastCalledWith('https://places.liketrek.com', null); }); it('SISVC-006: never shows the key to the issuer when TREK_SERVICES_HOSTS leaves it out', async () => { config.hosts = ['gate.example.org']; - expect(await identity.status()).toMatchObject({ state: 'connected', tier: 'hosted', quota: null }); + const status = await identity.status(); + expect(status).toMatchObject({ state: 'connected', tier: 'hosted' }); + expect(status.services.map(({ quota }) => quota)).toEqual([null, null, null]); expect(issuer.quota).not.toHaveBeenCalled(); }); + + it('SISVC-007: asks each listed host for its own allowance, once per 20 seconds each', async () => { + config.hosts = ALL_HOSTS; + const status = await identity.status(); + expect(status.services).toEqual([ + { service: 'places', inUse: true, quota: VIEW }, + { service: 'photon', inUse: true, quota: VIEW }, + { service: 'valhalla', inUse: true, quota: VIEW }, + ]); + expect(issuer.quota.mock.calls.map(([origin]) => origin)).toEqual([ + 'https://places.liketrek.com', + 'https://photon.liketrek.com', + 'https://valhalla.liketrek.com', + ]); + await identity.status(); + expect(issuer.quota).toHaveBeenCalledTimes(3); + }); + + it('SISVC-008: asks Photon and Valhalla nothing without a key, since only Places has a shared pool', async () => { + config.hosts = ALL_HOSTS; + keys.snapshot.mockResolvedValueOnce({ state: 'legacy', key: null, source: null, bearer: null }); + const status = await identity.status(); + expect(status.services.map(({ inUse }) => inUse)).toEqual([true, false, false]); + expect(issuer.quota).toHaveBeenCalledTimes(1); + }); + + it('SISVC-009: Photon is not in use beside an own Nominatim, the TREK Valhalla not beside another routing choice', async () => { + config.hosts = ALL_HOSTS; + nominatim.url = 'https://geocode.example.org'; + defaults.mockResolvedValue({ valhalla_base_url: 'https://valhalla.example.org' }); + const status = await identity.status(); + expect(status.services.map(({ inUse }) => inUse)).toEqual([true, false, false]); + + defaults.mockResolvedValue({ routing_base_url: 'https://osrm.example.org', routing_valhalla_provider: 'trek' }); + expect((await identity.status()).services[2].inUse).toBe(true); + }); + + it('SISVC-010: shows the last allowance a host stated while it does not answer, each host its own', async () => { + config.hosts = ALL_HOSTS; + issuer.quota.mockRejectedValue(new Error('fetch failed')); + const view = { limit: 7, used: 2, remaining: 5, resetAt: 'x' }; + t.get(QuotaTracker).record('valhalla.liketrek.com', { tier: 'std', quota: view }); + const status = await identity.status(); + expect(status.services.map(({ quota }) => quota)).toEqual([null, null, view]); + expect(status.tier).toBe('hosted'); + }); }); diff --git a/server/tests/unit/nest/service-identity/service-request-identity.test.ts b/server/tests/unit/nest/service-identity/service-request-identity.test.ts index 65670ef4c..1728a3580 100644 --- a/server/tests/unit/nest/service-identity/service-request-identity.test.ts +++ b/server/tests/unit/nest/service-identity/service-request-identity.test.ts @@ -90,7 +90,11 @@ describe('observe', () => { 'X-TREK-Quota-Reset': '1793059200', }), ); - expect(t.get(QuotaTracker).latest()).toMatchObject({ tier: 'std', quota: { limit: 10_000, used: 1_000 } }); + expect(t.get(QuotaTracker).latest('places.liketrek.com')).toMatchObject({ + tier: 'std', + quota: { limit: 10_000, used: 1_000 }, + }); + expect(t.get(QuotaTracker).latest('valhalla.liketrek.com')).toBeNull(); expect(t.get(ServiceClock).nowSeconds()).toBeGreaterThan(Math.floor(Date.now() / 1000) + 100); expect(keys.noteAccepted).toHaveBeenCalledTimes(1); }); @@ -107,10 +111,11 @@ describe('observe', () => { }); it('SIREQ-013: learns nothing from a host that is not allowlisted', () => { - const before = t.get(QuotaTracker).latest(); + const before = t.get(QuotaTracker).newest(); identity.observe(new URL('https://mirror.example.org/v1/search'), answer(401, '{}', { 'X-TREK-Tier': 'own' })); expect(keys.noteInvalid).not.toHaveBeenCalled(); - expect(t.get(QuotaTracker).latest()).toBe(before); + expect(t.get(QuotaTracker).newest()).toBe(before); + expect(t.get(QuotaTracker).latest('mirror.example.org')).toBeNull(); }); }); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index ff0f512a8..e94853b1c 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -179,6 +179,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'أُلغي مفتاح هذا التثبيت. تمر عمليات البحث عبر الحصة المشتركة حتى تنشئ هوية جديدة.', 'admin.trekApi.quota.unavailable': 'تعذّر قراءة الحصة الآن.', + 'admin.trekApi.quota.service.places': 'البحث عن الأماكن', + 'admin.trekApi.quota.service.photon': 'البحث عن العناوين', + 'admin.trekApi.quota.service.valhalla': 'التوجيه', + 'admin.trekApi.quota.notInUse': 'غير مستخدمة على هذا الخادم', 'admin.trekApi.quota.envKey': 'المفتاح مأخوذ من TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'هوية جديدة', 'admin.trekApi.quota.regenerateTitle': 'إنشاء هوية جديدة؟', diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index a49ec076f..9a91ac327 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -194,6 +194,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Bu quraşdırmanın açarı ləğv edilib. Yeni identiklik yaradana qədər axtarışlar ümumi hovuz vasitəsilə gedir.', 'admin.trekApi.quota.unavailable': 'Limiti hazırda oxumaq mümkün deyil.', + 'admin.trekApi.quota.service.places': 'Məkan axtarışı', + 'admin.trekApi.quota.service.photon': 'Ünvan axtarışı', + 'admin.trekApi.quota.service.valhalla': 'Marşrutlaşdırma', + 'admin.trekApi.quota.notInUse': 'Bu instansiyada istifadə olunmur', 'admin.trekApi.quota.envKey': 'Açar TREK_SERVICES_KEY dəyişənindən gəlir.', 'admin.trekApi.quota.regenerate': 'Yeni identiklik', 'admin.trekApi.quota.regenerateTitle': 'Yeni identiklik yaradılsın?', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 4764b5c8c..be1285307 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -137,6 +137,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'A chave desta instalação foi revogada. As pesquisas usam a reserva compartilhada até você criar uma nova identidade.', 'admin.trekApi.quota.unavailable': 'Não é possível ler a cota agora.', + 'admin.trekApi.quota.service.places': 'Busca de lugares', + 'admin.trekApi.quota.service.photon': 'Busca de endereços', + 'admin.trekApi.quota.service.valhalla': 'Rotas', + 'admin.trekApi.quota.notInUse': 'Não usado nesta instância', 'admin.trekApi.quota.envKey': 'A chave vem de TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nova identidade', 'admin.trekApi.quota.regenerateTitle': 'Criar uma nova identidade?', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 1b3898ae7..54cf5d599 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -144,6 +144,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': "La clau d'aquesta instal·lació s'ha revocat. Les cerques fan servir la reserva compartida fins que creïs una identitat nova.", 'admin.trekApi.quota.unavailable': 'Ara mateix no es pot llegir la quota.', + 'admin.trekApi.quota.service.places': 'Cerca de llocs', + 'admin.trekApi.quota.service.photon': 'Cerca d’adreces', + 'admin.trekApi.quota.service.valhalla': 'Rutes', + 'admin.trekApi.quota.notInUse': 'No s’utilitza en aquesta instància', 'admin.trekApi.quota.envKey': 'La clau ve de TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Identitat nova', 'admin.trekApi.quota.regenerateTitle': 'Vols crear una identitat nova?', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index a3d504a93..6f1b3cc4c 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -139,6 +139,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Klíč této instalace byl zneplatněn. Vyhledávání běží přes sdílený fond, dokud nevytvoříte novou identitu.', 'admin.trekApi.quota.unavailable': 'Limit teď nelze načíst.', + 'admin.trekApi.quota.service.places': 'Hledání míst', + 'admin.trekApi.quota.service.photon': 'Hledání adres', + 'admin.trekApi.quota.service.valhalla': 'Plánování tras', + 'admin.trekApi.quota.notInUse': 'Na této instanci se nepoužívá', 'admin.trekApi.quota.envKey': 'Klíč pochází z TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nová identita', 'admin.trekApi.quota.regenerateTitle': 'Vytvořit novou identitu?', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 3d74d54e2..0003a0946 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -140,6 +140,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Der Schlüssel dieser Installation wurde gesperrt. Suchen laufen über den gemeinsamen Topf, bis du eine neue Identität erzeugst.', 'admin.trekApi.quota.unavailable': 'Das Kontingent lässt sich gerade nicht abrufen.', + 'admin.trekApi.quota.service.places': 'Ortssuche', + 'admin.trekApi.quota.service.photon': 'Adresssuche', + 'admin.trekApi.quota.service.valhalla': 'Routenplanung', + 'admin.trekApi.quota.notInUse': 'Auf dieser Instanz nicht in Benutzung', 'admin.trekApi.quota.envKey': 'Der Schlüssel kommt aus TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Neue Identität', 'admin.trekApi.quota.regenerateTitle': 'Neue Identität erzeugen?', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 19210ba64..4adb76c05 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -189,6 +189,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'The key of this installation was revoked. Searches use the shared pool until you create a new identity.', 'admin.trekApi.quota.unavailable': 'The allowance cannot be read right now.', + 'admin.trekApi.quota.service.places': 'Place search', + 'admin.trekApi.quota.service.photon': 'Address search', + 'admin.trekApi.quota.service.valhalla': 'Routing', + 'admin.trekApi.quota.notInUse': 'Not in use on this instance', 'admin.trekApi.quota.envKey': 'The key comes from TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'New identity', 'admin.trekApi.quota.regenerateTitle': 'Create a new identity?', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 4b97b8de8..caf2bdee9 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -139,6 +139,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'La clave de esta instalación ha sido revocada. Las búsquedas usan el fondo compartido hasta que crees una nueva identidad.', 'admin.trekApi.quota.unavailable': 'No se puede leer el cupo en este momento.', + 'admin.trekApi.quota.service.places': 'Búsqueda de lugares', + 'admin.trekApi.quota.service.photon': 'Búsqueda de direcciones', + 'admin.trekApi.quota.service.valhalla': 'Rutas', + 'admin.trekApi.quota.notInUse': 'No se usa en esta instancia', 'admin.trekApi.quota.envKey': 'La clave viene de TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nueva identidad', 'admin.trekApi.quota.regenerateTitle': '¿Crear una nueva identidad?', diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index 1254e70b7..1a20b7739 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -190,6 +190,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Selle paigalduse võti on tühistatud. Otsingud käivad ühise kogumi kaudu, kuni lood uue identiteedi.', 'admin.trekApi.quota.unavailable': 'Kvooti ei saa praegu lugeda.', + 'admin.trekApi.quota.service.places': 'Kohtade otsing', + 'admin.trekApi.quota.service.photon': 'Aadresside otsing', + 'admin.trekApi.quota.service.valhalla': 'Marsruutimine', + 'admin.trekApi.quota.notInUse': 'Selles serveris pole kasutusel', 'admin.trekApi.quota.envKey': 'Võti tuleb muutujast TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Uus identiteet', 'admin.trekApi.quota.regenerateTitle': 'Kas luua uus identiteet?', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 5f687f833..048995099 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -139,6 +139,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': "La clé de cette installation a été révoquée. Les recherches passent par la réserve partagée jusqu'à ce que vous créiez une nouvelle identité.", 'admin.trekApi.quota.unavailable': 'Le quota ne peut pas être lu pour le moment.', + 'admin.trekApi.quota.service.places': 'Recherche de lieux', + 'admin.trekApi.quota.service.photon': 'Recherche d’adresses', + 'admin.trekApi.quota.service.valhalla': 'Itinéraires', + 'admin.trekApi.quota.notInUse': 'Non utilisé sur cette instance', 'admin.trekApi.quota.envKey': 'La clé provient de TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nouvelle identité', 'admin.trekApi.quota.regenerateTitle': 'Créer une nouvelle identité ?', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 6c54b5f31..0f3e4b881 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -195,6 +195,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Το κλειδί αυτής της εγκατάστασης ανακλήθηκε. Οι αναζητήσεις περνούν από την κοινή δεξαμενή μέχρι να δημιουργήσετε νέα ταυτότητα.', 'admin.trekApi.quota.unavailable': 'Το όριο δεν μπορεί να διαβαστεί αυτή τη στιγμή.', + 'admin.trekApi.quota.service.places': 'Αναζήτηση τοποθεσιών', + 'admin.trekApi.quota.service.photon': 'Αναζήτηση διευθύνσεων', + 'admin.trekApi.quota.service.valhalla': 'Δρομολόγηση', + 'admin.trekApi.quota.notInUse': 'Δεν χρησιμοποιείται σε αυτή την εγκατάσταση', 'admin.trekApi.quota.envKey': 'Το κλειδί προέρχεται από το TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Νέα ταυτότητα', 'admin.trekApi.quota.regenerateTitle': 'Δημιουργία νέας ταυτότητας;', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 2e13a2649..7df20b653 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -138,6 +138,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Ennek a telepítésnek a kulcsát visszavonták. A keresések a közös kereten át mennek, amíg új identitást nem hozol létre.', 'admin.trekApi.quota.unavailable': 'A keret most nem olvasható le.', + 'admin.trekApi.quota.service.places': 'Helykeresés', + 'admin.trekApi.quota.service.photon': 'Címkeresés', + 'admin.trekApi.quota.service.valhalla': 'Útvonaltervezés', + 'admin.trekApi.quota.notInUse': 'Ezen a példányon nincs használatban', 'admin.trekApi.quota.envKey': 'A kulcs a TREK_SERVICES_KEY változóból jön.', 'admin.trekApi.quota.regenerate': 'Új identitás', 'admin.trekApi.quota.regenerateTitle': 'Új identitást hozol létre?', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 55c8d45a5..015500238 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -186,6 +186,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Kunci instalasi ini telah dicabut. Pencarian memakai kumpulan bersama sampai kamu membuat identitas baru.', 'admin.trekApi.quota.unavailable': 'Kuota tidak dapat dibaca saat ini.', + 'admin.trekApi.quota.service.places': 'Pencarian tempat', + 'admin.trekApi.quota.service.photon': 'Pencarian alamat', + 'admin.trekApi.quota.service.valhalla': 'Perutean', + 'admin.trekApi.quota.notInUse': 'Tidak digunakan di instans ini', 'admin.trekApi.quota.envKey': 'Kunci berasal dari TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Identitas baru', 'admin.trekApi.quota.regenerateTitle': 'Buat identitas baru?', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 4bd822837..1b801228d 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -138,6 +138,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'La chiave di questa installazione è stata revocata. Le ricerche usano la riserva condivisa finché non crei una nuova identità.', 'admin.trekApi.quota.unavailable': 'Al momento non è possibile leggere la quota.', + 'admin.trekApi.quota.service.places': 'Ricerca di luoghi', + 'admin.trekApi.quota.service.photon': 'Ricerca di indirizzi', + 'admin.trekApi.quota.service.valhalla': 'Percorsi', + 'admin.trekApi.quota.notInUse': 'Non in uso su questa istanza', 'admin.trekApi.quota.envKey': 'La chiave proviene da TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nuova identità', 'admin.trekApi.quota.regenerateTitle': 'Creare una nuova identità?', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 0e74c03d3..c89590f76 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -177,6 +177,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'このインストールのキーは失効しました。新しい ID を作成するまで、検索は共有枠を使います。', 'admin.trekApi.quota.unavailable': '現在、上限を読み取れません。', + 'admin.trekApi.quota.service.places': '場所の検索', + 'admin.trekApi.quota.service.photon': '住所の検索', + 'admin.trekApi.quota.service.valhalla': 'ルーティング', + 'admin.trekApi.quota.notInUse': 'このインスタンスでは使用されていません', 'admin.trekApi.quota.envKey': 'キーは TREK_SERVICES_KEY から設定されています。', 'admin.trekApi.quota.regenerate': '新しい ID', 'admin.trekApi.quota.regenerateTitle': '新しい ID を作成しますか?', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 44d00602a..fded4867b 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -180,6 +180,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.off': '이 인스턴스에서는 사용하지 않습니다: 꺼져 있거나 자체 복사본을 가리킵니다.', 'admin.trekApi.quota.hint.revoked': '이 설치의 키가 폐기되었습니다. 새 ID를 만들 때까지 검색은 공용 풀을 사용합니다.', 'admin.trekApi.quota.unavailable': '지금은 할당량을 읽을 수 없습니다.', + 'admin.trekApi.quota.service.places': '장소 검색', + 'admin.trekApi.quota.service.photon': '주소 검색', + 'admin.trekApi.quota.service.valhalla': '경로 탐색', + 'admin.trekApi.quota.notInUse': '이 인스턴스에서 사용하지 않음', 'admin.trekApi.quota.envKey': '키는 TREK_SERVICES_KEY에서 가져옵니다.', 'admin.trekApi.quota.regenerate': '새 ID', 'admin.trekApi.quota.regenerateTitle': '새 ID를 만들까요?', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index f2aa742b9..5523ed088 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -139,6 +139,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'De sleutel van deze installatie is ingetrokken. Zoekopdrachten gaan via de gedeelde pot tot je een nieuwe identiteit aanmaakt.', 'admin.trekApi.quota.unavailable': 'Het tegoed kan nu niet worden gelezen.', + 'admin.trekApi.quota.service.places': 'Plaatsen zoeken', + 'admin.trekApi.quota.service.photon': 'Adressen zoeken', + 'admin.trekApi.quota.service.valhalla': 'Routeplanning', + 'admin.trekApi.quota.notInUse': 'Niet in gebruik op deze instantie', 'admin.trekApi.quota.envKey': 'De sleutel komt uit TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nieuwe identiteit', 'admin.trekApi.quota.regenerateTitle': 'Nieuwe identiteit aanmaken?', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 19f4c6c44..438c9b844 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -137,6 +137,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Klucz tej instalacji został unieważniony. Wyszukiwania idą przez wspólną pulę, dopóki nie utworzysz nowej tożsamości.', 'admin.trekApi.quota.unavailable': 'Nie można teraz odczytać limitu.', + 'admin.trekApi.quota.service.places': 'Wyszukiwanie miejsc', + 'admin.trekApi.quota.service.photon': 'Wyszukiwanie adresów', + 'admin.trekApi.quota.service.valhalla': 'Wyznaczanie tras', + 'admin.trekApi.quota.notInUse': 'Nieużywane w tej instancji', 'admin.trekApi.quota.envKey': 'Klucz pochodzi z TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nowa tożsamość', 'admin.trekApi.quota.regenerateTitle': 'Utworzyć nową tożsamość?', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 8062b1526..7136039a2 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -147,6 +147,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Ключ этой установки отозван. Поиск идёт через общий пул, пока вы не создадите новую идентичность.', 'admin.trekApi.quota.unavailable': 'Сейчас не удаётся получить лимит.', + 'admin.trekApi.quota.service.places': 'Поиск мест', + 'admin.trekApi.quota.service.photon': 'Поиск адресов', + 'admin.trekApi.quota.service.valhalla': 'Маршрутизация', + 'admin.trekApi.quota.notInUse': 'Не используется на этом экземпляре', 'admin.trekApi.quota.envKey': 'Ключ задан в TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Новая идентичность', 'admin.trekApi.quota.regenerateTitle': 'Создать новую идентичность?', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index 0b14eeb98..4d426b24a 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -192,6 +192,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Kľúč tejto inštalácie bol zneplatnený. Vyhľadávanie ide cez spoločný fond, kým nevytvoríte novú identitu.', 'admin.trekApi.quota.unavailable': 'Limit sa teraz nedá načítať.', + 'admin.trekApi.quota.service.places': 'Vyhľadávanie miest', + 'admin.trekApi.quota.service.photon': 'Vyhľadávanie adries', + 'admin.trekApi.quota.service.valhalla': 'Plánovanie trás', + 'admin.trekApi.quota.notInUse': 'Na tejto inštancii sa nepoužíva', 'admin.trekApi.quota.envKey': 'Kľúč pochádza z TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Nová identita', 'admin.trekApi.quota.regenerateTitle': 'Vytvoriť novú identitu?', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index bbc72d5bf..bb64e836d 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -191,6 +191,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Nyckeln för den här installationen har återkallats. Sökningar går via den gemensamma poolen tills du skapar en ny identitet.', 'admin.trekApi.quota.unavailable': 'Kvoten kan inte läsas just nu.', + 'admin.trekApi.quota.service.places': 'Platssökning', + 'admin.trekApi.quota.service.photon': 'Adressökning', + 'admin.trekApi.quota.service.valhalla': 'Ruttplanering', + 'admin.trekApi.quota.notInUse': 'Används inte på den här instansen', 'admin.trekApi.quota.envKey': 'Nyckeln kommer från TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Ny identitet', 'admin.trekApi.quota.regenerateTitle': 'Skapa en ny identitet?', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index 9e8178c2a..bb7769f21 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -821,6 +821,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'คีย์ของการติดตั้งนี้ถูกเพิกถอนแล้ว การค้นหาจะใช้โควตาส่วนกลางจนกว่าคุณจะสร้างตัวตนใหม่', 'admin.trekApi.quota.unavailable': 'ไม่สามารถอ่านโควตาได้ในขณะนี้', + 'admin.trekApi.quota.service.places': 'ค้นหาสถานที่', + 'admin.trekApi.quota.service.photon': 'ค้นหาที่อยู่', + 'admin.trekApi.quota.service.valhalla': 'การหาเส้นทาง', + 'admin.trekApi.quota.notInUse': 'ไม่ได้ใช้บนอินสแตนซ์นี้', 'admin.trekApi.quota.envKey': 'คีย์มาจาก TREK_SERVICES_KEY', 'admin.trekApi.quota.regenerate': 'ตัวตนใหม่', 'admin.trekApi.quota.regenerateTitle': 'สร้างตัวตนใหม่หรือไม่?', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 549b31a6c..6f792fa3e 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -191,6 +191,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Bu kurulumun anahtarı iptal edildi. Yeni bir kimlik oluşturana kadar aramalar ortak havuzdan yapılır.', 'admin.trekApi.quota.unavailable': 'Kota şu anda okunamıyor.', + 'admin.trekApi.quota.service.places': 'Yer arama', + 'admin.trekApi.quota.service.photon': 'Adres arama', + 'admin.trekApi.quota.service.valhalla': 'Rota planlama', + 'admin.trekApi.quota.notInUse': 'Bu sunucuda kullanılmıyor', 'admin.trekApi.quota.envKey': 'Anahtar TREK_SERVICES_KEY değişkeninden geliyor.', 'admin.trekApi.quota.regenerate': 'Yeni kimlik', 'admin.trekApi.quota.regenerateTitle': 'Yeni bir kimlik oluşturulsun mu?', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 81e772016..29725b5eb 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -146,6 +146,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Ключ цієї інсталяції відкликано. Пошук іде через спільний пул, доки ви не створите нову ідентичність.', 'admin.trekApi.quota.unavailable': 'Зараз не вдається отримати ліміт.', + 'admin.trekApi.quota.service.places': 'Пошук місць', + 'admin.trekApi.quota.service.photon': 'Пошук адрес', + 'admin.trekApi.quota.service.valhalla': 'Маршрутизація', + 'admin.trekApi.quota.notInUse': 'Не використовується на цьому екземплярі', 'admin.trekApi.quota.envKey': 'Ключ задано в TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Нова ідентичність', 'admin.trekApi.quota.regenerateTitle': 'Створити нову ідентичність?', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 0a17b2fe6..1f3d065f9 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -186,6 +186,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.revoked': 'Khóa của bản cài đặt này đã bị thu hồi. Các lượt tìm kiếm dùng nhóm dùng chung cho đến khi bạn tạo danh tính mới.', 'admin.trekApi.quota.unavailable': 'Hiện không thể đọc hạn mức.', + 'admin.trekApi.quota.service.places': 'Tìm kiếm địa điểm', + 'admin.trekApi.quota.service.photon': 'Tìm kiếm địa chỉ', + 'admin.trekApi.quota.service.valhalla': 'Định tuyến', + 'admin.trekApi.quota.notInUse': 'Không dùng trên phiên bản này', 'admin.trekApi.quota.envKey': 'Khóa được lấy từ TREK_SERVICES_KEY.', 'admin.trekApi.quota.regenerate': 'Danh tính mới', 'admin.trekApi.quota.regenerateTitle': 'Tạo danh tính mới?', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 8bf1438f3..7d1508a34 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -175,6 +175,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.off': '此執行個體未使用:已關閉,或指向你自己的副本。', 'admin.trekApi.quota.hint.revoked': '此安裝的金鑰已被撤銷。在你建立新身分之前,搜尋會使用共用池。', 'admin.trekApi.quota.unavailable': '目前無法讀取額度。', + 'admin.trekApi.quota.service.places': '地點搜尋', + 'admin.trekApi.quota.service.photon': '地址搜尋', + 'admin.trekApi.quota.service.valhalla': '路線規劃', + 'admin.trekApi.quota.notInUse': '此執行個體未使用', 'admin.trekApi.quota.envKey': '金鑰來自 TREK_SERVICES_KEY。', 'admin.trekApi.quota.regenerate': '新身分', 'admin.trekApi.quota.regenerateTitle': '要建立新身分嗎?', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index d835ef29d..f43091342 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -127,6 +127,10 @@ const admin: TranslationStrings = { 'admin.trekApi.quota.hint.off': '此实例未使用:已关闭,或指向你自己的副本。', 'admin.trekApi.quota.hint.revoked': '此安装的密钥已被吊销。在你创建新身份之前,搜索使用共享池。', 'admin.trekApi.quota.unavailable': '暂时无法读取额度。', + 'admin.trekApi.quota.service.places': '地点搜索', + 'admin.trekApi.quota.service.photon': '地址搜索', + 'admin.trekApi.quota.service.valhalla': '路线规划', + 'admin.trekApi.quota.notInUse': '此实例未使用', 'admin.trekApi.quota.envKey': '密钥来自 TREK_SERVICES_KEY。', 'admin.trekApi.quota.regenerate': '新身份', 'admin.trekApi.quota.regenerateTitle': '创建新身份?', diff --git a/shared/src/service-identity/service-identity.schema.spec.ts b/shared/src/service-identity/service-identity.schema.spec.ts index 4f1a108d5..e8fae7ef6 100644 --- a/shared/src/service-identity/service-identity.schema.spec.ts +++ b/shared/src/service-identity/service-identity.schema.spec.ts @@ -42,18 +42,29 @@ describe('serviceKeyGrantSchema and serviceQuotaSchema', () => { describe('serviceIdentityStatusSchema', () => { it('describes an installation that is switched off', () => { - const off = { state: 'off', tier: null, keySource: null, quota: null, lastSeenAt: null }; + const off = { state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null }; expect(serviceIdentityStatusSchema.parse(off)).toEqual(off); }); - it('describes a connected one with its allowance', () => { + it('describes a connected one with the allowance of each service', () => { + const quota = { limit: 10_000, used: 12, remaining: 9_988, resetAt: '2026-10-30T00:00:00.000Z' }; const connected = { state: 'connected', tier: 'std', keySource: 'registered', - quota: { limit: 10_000, used: 12, remaining: 9_988, resetAt: '2026-10-30T00:00:00.000Z' }, + services: [ + { service: 'places', inUse: true, quota }, + { service: 'photon', inUse: false, quota: null }, + { service: 'valhalla', inUse: true, quota }, + ], lastSeenAt: '2026-10-29T12:00:00.000Z', }; expect(serviceIdentityStatusSchema.parse(connected)).toEqual(connected); }); + + it('knows only the three services', () => { + const status = { state: 'connected', tier: null, keySource: null, lastSeenAt: null }; + const unknown = [{ service: 'nominatim', inUse: true, quota: null }]; + expect(serviceIdentityStatusSchema.safeParse({ ...status, services: unknown }).success).toBe(false); + }); }); diff --git a/shared/src/service-identity/service-identity.schema.ts b/shared/src/service-identity/service-identity.schema.ts index dfe95d9f6..5c346601f 100644 --- a/shared/src/service-identity/service-identity.schema.ts +++ b/shared/src/service-identity/service-identity.schema.ts @@ -64,12 +64,25 @@ const serviceQuotaViewSchema = z.object({ }); export type ServiceQuotaView = z.infer; +/** The liketrek services an installation key opens, in the order the admin card lists them. */ +export const SERVICE_NAMES = ['places', 'photon', 'valhalla'] as const; +export const serviceNameSchema = z.enum(SERVICE_NAMES); +export type ServiceName = z.infer; + +/** One service: whether this instance asks it right now, and today's allowance its host reports. */ +const serviceAllowanceSchema = z.object({ + service: serviceNameSchema, + inUse: z.boolean(), + quota: serviceQuotaViewSchema.nullable(), +}); +export type ServiceAllowance = z.infer; + /** GET /api/admin/service-identity and the answer of its regenerate action. */ export const serviceIdentityStatusSchema = z.object({ state: serviceIdentityStateSchema, tier: serviceTierSchema.nullable(), keySource: z.enum(['env', 'registered']).nullable(), - quota: serviceQuotaViewSchema.nullable(), + services: z.array(serviceAllowanceSchema).max(SERVICE_NAMES.length), lastSeenAt: z.string().nullable(), }); export type ServiceIdentityStatus = z.infer;