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.
This commit is contained in:
Maurice
2026-10-29 10:44:50 +01:00
committed by Maurice
parent 091ebed8e4
commit bc62eb0d6f
43 changed files with 531 additions and 89 deletions
@@ -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(<ServiceIdentityPanel />);
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(<ServiceIdentityPanel />);
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(<ServiceIdentityPanel />);
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(<ServiceIdentityPanel />);
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(<ServiceIdentityPanel />);
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(<ServiceIdentityPanel />);
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(<ServiceIdentityPanel managed />);
expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
@@ -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<ServiceIdentityState, string> = {
revoked: 'bg-danger',
};
const SERVICE_LABEL: Record<ServiceName, string> = {
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 }) {
<div className="flex flex-col gap-2.5">
<div
role="progressbar"
aria-label={t('admin.trekApi.quota.title')}
aria-label={label}
aria-valuemin={0}
aria-valuemax={quota.limit}
aria-valuenow={quota.used}
@@ -71,15 +83,39 @@ function QuotaMeter({ quota }: { quota: ServiceQuotaView }) {
);
}
function rowNote(t: TranslationFn, allowance: ServiceAllowance): string | null {
if (!allowance.inUse) return t('admin.trekApi.quota.notInUse');
return allowance.quota ? null : t('admin.trekApi.quota.unavailable');
}
/**
* One service under the key: its name, today's allowance where its host told
* it, and whether this instance asks it at all. A service not in use is
* dimmed rather than hidden, so the list reads the same on every install.
*/
function ServiceRow({ allowance }: { allowance: ServiceAllowance }) {
const { t } = useTranslation();
const name = t(SERVICE_LABEL[allowance.service]);
const note = rowNote(t, allowance);
return (
<li className={`flex flex-col gap-2 py-2.5 first:pt-0 last:pb-0 ${allowance.inUse ? '' : 'opacity-60'}`}>
<p className="m-0 text-caption font-medium text-content-secondary">{name}</p>
{allowance.quota && <QuotaMeter quota={allowance.quota} label={name} />}
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
</li>
);
}
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
)}
</div>
{status?.quota && status.state !== 'off' && <QuotaMeter quota={status.quota} />}
{status && status.services.length > 0 && (
<ul aria-label={t('admin.trekApi.quota.title')} className="m-0 list-none divide-y divide-edge-faint p-0">
{status.services.map((allowance) => (
<ServiceRow key={allowance.service} allowance={allowance} />
))}
</ul>
)}
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
{status?.keySource === 'env' && (
@@ -17,7 +17,7 @@ const LEGACY: ServiceIdentityStatus = {
state: 'legacy',
tier: 'legacy',
keySource: null,
quota: null,
services: [],
lastSeenAt: null,
};
const card = { current: null };
+7 -1
View File
@@ -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,
};
+1 -1
View File
@@ -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 });
}),
];
@@ -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<IssuerAnswer<ServiceChallenge>> {
@@ -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<IssuerAnswer<ServiceQuota>> {
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<IssuerAnswer<ServiceQuota>> {
return this.call('GET', '/v1/quota', serviceQuotaSchema, { bearer: key, origin });
}
private async call<T>(
method: 'GET' | 'POST',
path: string,
schema: z.ZodType<T>,
opts: { body?: RegistrationRequest; bearer?: string | null } = {},
opts: { body?: RegistrationRequest; bearer?: string | null; origin?: string } = {},
): Promise<IssuerAnswer<T>> {
const headers: Record<string, string> = { 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,
@@ -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<string, QuotaSighting>();
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;
}
}
@@ -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';
@@ -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<string, CachedQuota>();
constructor(
@Inject(servicesConfig.KEY) private readonly config: ConfigType<typeof servicesConfig>,
@@ -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<ServiceIdentityStatus> {
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<ServiceIdentityStatus> {
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<ServiceQuota | null> {
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<Record<ServiceName, boolean>> {
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<ServiceQuota | null> {
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,
@@ -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());
+57 -7
View File
@@ -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<ServicesConfig> = {}) {
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<ServicesConfig> = {}) {
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<string, string> }];
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')
@@ -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);
@@ -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<KeySnapshot> => 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<Record<string, unknown>> => ({}));
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');
});
});
@@ -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();
});
});
+4
View File
@@ -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': 'إنشاء هوية جديدة؟',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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é ?',
+4
View File
@@ -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': 'Δημιουργία νέας ταυτότητας;',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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à?',
+4
View File
@@ -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 を作成しますか?',
+4
View File
@@ -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를 만들까요?',
+4
View File
@@ -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?',
+4
View File
@@ -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ść?',
+4
View File
@@ -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': 'Создать новую идентичность?',
+4
View File
@@ -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?',
+4
View File
@@ -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?',
+4
View File
@@ -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': 'สร้างตัวตนใหม่หรือไม่?',
+4
View File
@@ -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?',
+4
View File
@@ -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': 'Створити нову ідентичність?',
+4
View File
@@ -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?',
+4
View File
@@ -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': '要建立新身分嗎?',
+4
View File
@@ -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': '创建新身份?',
@@ -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);
});
});
@@ -64,12 +64,25 @@ const serviceQuotaViewSchema = z.object({
});
export type ServiceQuotaView = z.infer<typeof serviceQuotaViewSchema>;
/** 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<typeof serviceNameSchema>;
/** 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<typeof serviceAllowanceSchema>;
/** 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<typeof serviceIdentityStatusSchema>;