+
{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;