mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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': 'إنشاء هوية جديدة؟',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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é ?',
|
||||
|
||||
@@ -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': 'Δημιουργία νέας ταυτότητας;',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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à?',
|
||||
|
||||
@@ -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 を作成しますか?',
|
||||
|
||||
@@ -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를 만들까요?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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ść?',
|
||||
|
||||
@@ -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': 'Создать новую идентичность?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'สร้างตัวตนใหม่หรือไม่?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'Створити нову ідентичність?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': '要建立新身分嗎?',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user