mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
feat(server): give each installation a key for the TREK services and report its allowance
A new service-identity domain registers the installation with places.liketrek.com on its first Places request, in the background and behind a light scrypt proof of work, keeps the key encrypted in app_settings, renews it from day 60 through a daily job, drops it after three 401s in a row with up to six hours of jitter, and freezes it on a revocation until an admin starts over. TREK_SERVICES_KEY wins and turns registration off; TREK_SERVICES=off sends no key; a mirror set with TREK_PLACES_URL never registers and never sees the key, which goes only to the hosts in TREK_SERVICES_HOSTS over https. The Places client takes its headers from the identity port, sends a TREK/<version> User-Agent and keeps the instance token. A 401 or 403 does not open its breaker. GET /api/admin/service-identity reports the state, tier and today's allowance, asking the issuer at most every 20 seconds; POST .../regenerate starts a new identity.
This commit is contained in:
@@ -70,6 +70,9 @@ DEMO_MODE=false # Demo mode: resets data hourly
|
||||
|
||||
# TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding, offline downloads, and the OpenStreetMap ways and horse routes Tours reads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true)
|
||||
# TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
|
||||
# TREK_SERVICES=auto # How this installation identifies itself to the TREK services. On first use it registers itself in the background and keeps a key in the database; nothing to click, no account. off sends no key, and searches use the shared pool. (default: auto)
|
||||
# TREK_SERVICES_KEY= # A key issued to you for this installation. Wins over the stored one and turns self-registration off; meant for hosted and pre-provisioned instances. (default: none)
|
||||
# TREK_SERVICES_HOSTS=places.liketrek.com # Comma-separated hosts that receive the installation key over https. No other host ever sees it. (default: places.liketrek.com)
|
||||
|
||||
# PLACES_API_KEY= # Google Maps API key (Places API (New) enabled) for place search, photos, details and Google transit. Overrides the key set in Admin > Settings > API Keys, whose field then shows this variable read-only. The value never reaches the browser.
|
||||
# GOOGLE_LIST_DATA_BASE= # Optional HTTP(S) base URL of a list-data service, e.g. https://maps.example.com. Paste a URL beginning with this base to fetch and parse it directly. Unset keeps the original Google list import flow.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* mutate mid-lifetime, belongs in derive.ts instead.
|
||||
*/
|
||||
import type { RawEnv } from './derive';
|
||||
import { parseBool, positiveIntOr, stripTrailingSlashes } from './parsers';
|
||||
import { csvListFiltered, parseBool, positiveIntOr, stripTrailingSlashes } from './parsers';
|
||||
|
||||
/**
|
||||
* Longer than the upstream idle timeout of the common reverse proxies and load
|
||||
@@ -62,12 +62,31 @@ export function deriveKitinerary(raw: RawEnv) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The liketrek hosts that receive the installation key when nothing else is configured. */
|
||||
const DEFAULT_SERVICE_HOSTS = ['places.liketrek.com'];
|
||||
|
||||
/**
|
||||
* How this installation identifies itself to the liketrek services. Unset is
|
||||
* `auto`; any other value than `auto` switches them off, so a typo fails closed.
|
||||
* A key from the environment wins over the stored one and stops registration.
|
||||
*/
|
||||
export function deriveServices(raw: RawEnv) {
|
||||
const mode = raw.TREK_SERVICES?.trim().toLowerCase() || 'auto';
|
||||
const hosts = csvListFiltered(raw.TREK_SERVICES_HOSTS?.toLowerCase());
|
||||
return {
|
||||
mode: mode === 'auto' ? ('auto' as const) : ('off' as const),
|
||||
key: raw.TREK_SERVICES_KEY?.trim() || null,
|
||||
hosts: hosts?.length ? hosts : DEFAULT_SERVICE_HOSTS,
|
||||
};
|
||||
}
|
||||
|
||||
/** One entry per token: the namespace it registers under and the function it derives with. */
|
||||
export const BOOT_DERIVERS = {
|
||||
http: deriveHttpBoot,
|
||||
storage: deriveStorage,
|
||||
transit: deriveTransit,
|
||||
kitinerary: deriveKitinerary,
|
||||
services: deriveServices,
|
||||
} as const;
|
||||
|
||||
export type BootNamespace = keyof typeof BOOT_DERIVERS;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import {
|
||||
isP256PrivateKey,
|
||||
isServiceKeyShape,
|
||||
isUncompressedP256Key,
|
||||
isVapidSubject,
|
||||
parseDurationMs,
|
||||
@@ -184,6 +185,9 @@ export const envSchema = z.object({
|
||||
GOOGLE_LIST_DATA_BASE: url,
|
||||
TREK_PLACES_URL: url,
|
||||
TREK_PLACES_ENABLED: boolStr,
|
||||
TREK_SERVICES: oneOf(['auto', 'off']),
|
||||
TREK_SERVICES_KEY: optionalWith(isServiceKeyShape, 'must be a TREK services key (tk1.<kid>.<payload>.<signature>)'),
|
||||
TREK_SERVICES_HOSTS: anyString,
|
||||
PLACES_API_KEY: anyString,
|
||||
AMAP_API_BASE: url,
|
||||
AMAP_API_KEY: anyString,
|
||||
|
||||
@@ -27,6 +27,7 @@ export const SECRET_ENV_KEYS: ReadonlySet<string> = new Set([
|
||||
'UNSPLASH_ACCESS_KEY',
|
||||
'ADMIN_PASSWORD',
|
||||
'PLACES_API_KEY',
|
||||
'TREK_SERVICES_KEY',
|
||||
'AMAP_API_KEY',
|
||||
'AMAP_API_SECRET',
|
||||
'MAPBOX_ACCESS_TOKEN',
|
||||
|
||||
@@ -253,3 +253,11 @@ export function isVapidSubject(value: string): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/** A TREK services key: `tk1.<kid>.<payload>.<signature>`, the last three base64url. */
|
||||
export function isServiceKeyShape(value: string): boolean {
|
||||
const [version, ...parts] = value.trim().split('.');
|
||||
return version === 'tk1' && parts.length === 3 && parts.every((part) => BASE64URL_SEGMENT.test(part));
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ export class AppSettingsRepository extends TrekRepository<AppSettings> {
|
||||
return rows.map((row) => toRow(row) as AppSettingsRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* `INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`:
|
||||
* the first writer keeps the row, so several processes on one database can
|
||||
* race to create a value and all read back the same one afterwards.
|
||||
*/
|
||||
async insertIfAbsent(key: string, value: string): Promise<void> {
|
||||
await this.upsert({ key, value }, { onConflictFields: ['key'], onConflictAction: 'ignore' });
|
||||
}
|
||||
|
||||
/** `DELETE FROM app_settings WHERE key = ?` */
|
||||
async deleteValue(key: string): Promise<number> {
|
||||
return this.nativeDelete({ key });
|
||||
|
||||
@@ -31,5 +31,7 @@ export const storageConfig = registerAs('storage', () => BOOT_DERIVERS.storage(p
|
||||
export const transitConfig = registerAs('transit', () => BOOT_DERIVERS.transit(process.env));
|
||||
/** KitineraryExtractorService's binary probe (KITINERARY_EXTRACTOR_PATH, then PATH). */
|
||||
export const kitineraryConfig = registerAs('kitinerary', () => BOOT_DERIVERS.kitinerary(process.env));
|
||||
/** The installation identity towards the liketrek services (TREK_SERVICES, TREK_SERVICES_KEY, TREK_SERVICES_HOSTS). */
|
||||
export const servicesConfig = registerAs('services', () => BOOT_DERIVERS.services(process.env));
|
||||
|
||||
export const BOOT_STABLE_TOKENS = [httpConfig, storageConfig, transitConfig, kitineraryConfig];
|
||||
export const BOOT_STABLE_TOKENS = [httpConfig, storageConfig, transitConfig, kitineraryConfig, servicesConfig];
|
||||
|
||||
@@ -86,6 +86,7 @@ import { RoadtripModule } from './roadtrip/roadtrip.module';
|
||||
import { RouteUsageModule } from './route-usage/route-usage.module';
|
||||
import { SchedulingModule } from './scheduling/scheduling.module';
|
||||
import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
|
||||
import { ServiceIdentityModule } from './service-identity/service-identity.module';
|
||||
import { SessionsModule } from './sessions/sessions.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
@@ -182,6 +183,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
TransitModule,
|
||||
FeedsModule,
|
||||
SettingsModule,
|
||||
ServiceIdentityModule,
|
||||
StorageModule,
|
||||
BackupModule,
|
||||
AuthModule,
|
||||
|
||||
@@ -139,6 +139,10 @@ export const APP_SETTINGS = {
|
||||
ai_assistant_web_search: { schema: flag, default: 'true' },
|
||||
/** `urn:uuid:<uuid>`: this install as one host to Sign in with ChatGPT, created on the first sign-in. */
|
||||
ai_siwc_host_id: { schema: text, default: null },
|
||||
/** This installation's key towards the liketrek services, issued on first use (service-identity). */
|
||||
service_key: { schema: text, default: null, encrypted: 'always', managedLocked: true },
|
||||
/** The key's bookkeeping as JSON: when to renew, when to ask again after a refusal, a revocation. */
|
||||
service_key_state: { schema: text, default: null },
|
||||
} as const satisfies Record<string, AppSettingDef>;
|
||||
|
||||
export type AppSettingKey = keyof typeof APP_SETTINGS;
|
||||
|
||||
@@ -92,6 +92,8 @@ export const MANAGED_LOCKED_SETTING_KEYS = [
|
||||
'oidc_registration',
|
||||
'openweather_api_key',
|
||||
'routing_base_url',
|
||||
// The operator issues a managed install its key through TREK_SERVICES_KEY.
|
||||
'service_key',
|
||||
'smtp_from',
|
||||
'smtp_host',
|
||||
'smtp_pass',
|
||||
|
||||
@@ -125,6 +125,7 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
|
||||
'PluginsController.link',
|
||||
'PluginsController.reload',
|
||||
'PluginsController.upload',
|
||||
'ServiceIdentityController.regenerate',
|
||||
'StorageAdminController.backfillCancel',
|
||||
'StorageAdminController.backfillStart',
|
||||
'StorageAdminController.get',
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
* autocomplete, and paying Google for a text search whose terms then forbid
|
||||
* storing what comes back.
|
||||
*
|
||||
* Everything here is read-only, unauthenticated and cacheable. There is no key
|
||||
* to configure and no quota to exhaust, so the failure modes are the network
|
||||
* Everything here is read-only and cacheable. There is no key to configure:
|
||||
* service-identity adds the installation's headers and reads each answer, and a
|
||||
* refused key falls back to the shared pool. The failure modes stay the network
|
||||
* and the service being down, both of which fall back to what TREK did before.
|
||||
*/
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { serviceIdentitySlot } from '../service-identity/service-identity.interface';
|
||||
import { toApiLang } from './maps.helpers';
|
||||
import {
|
||||
normalizePlaceWebsite,
|
||||
@@ -253,6 +255,17 @@ function noteFailure(err: unknown, breaker: Breaker): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A request carrying who is asking: the instance token, and the installation key where it may go. */
|
||||
async function identifiedFetch(url: URL, headers: Record<string, string>, signal: AbortSignal): Promise<Response> {
|
||||
const identity = serviceIdentitySlot.get();
|
||||
const res = await fetch(url, {
|
||||
signal,
|
||||
headers: { ...headers, 'X-TREK-Instance': instanceToken(), ...identity.headersFor(url) },
|
||||
});
|
||||
identity.observe(url, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function statusError(status: number): Error & { status: number } {
|
||||
const err = new Error(`TREK Places API ${status}`) as Error & { status: number };
|
||||
err.status = status;
|
||||
@@ -275,13 +288,7 @@ async function getJson<T>(
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-TREK-Instance': instanceToken(),
|
||||
},
|
||||
});
|
||||
const res = await identifiedFetch(url, { Accept: 'application/json' }, controller.signal);
|
||||
if (!res.ok) throw statusError(res.status);
|
||||
// Read with a cap rather than res.json(): a hostile or broken upstream
|
||||
// should not be able to hand us an unbounded body to buffer.
|
||||
@@ -928,9 +935,9 @@ async function getWinter(
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: accept, 'X-TREK-Instance': instanceToken() };
|
||||
const headers: Record<string, string> = { Accept: accept };
|
||||
if (etag) headers['If-None-Match'] = `"${etag}"`;
|
||||
const res = await fetch(url, { signal: controller.signal, headers });
|
||||
const res = await identifiedFetch(url, headers, controller.signal);
|
||||
if (res.status === 304 && etag) {
|
||||
layersBreaker.failures = 0;
|
||||
return { status: 'not_modified' };
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { safeFetch } from '../../utils/ssrfGuard';
|
||||
import { SERVICE_ISSUER_ORIGIN } from './service-identity.constants';
|
||||
import { retryAfterMs, serverDateMs } from './service-identity.helpers';
|
||||
import { serviceUserAgent } from './user-agent';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
serviceChallengeSchema,
|
||||
serviceKeyGrantSchema,
|
||||
serviceQuotaSchema,
|
||||
type ServiceChallenge,
|
||||
type ServiceKeyGrant,
|
||||
type ServiceQuota,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
const TIMEOUT_MS = 10_000;
|
||||
/** Every answer of the issuer is a few hundred bytes. */
|
||||
const MAX_BYTES = 4096;
|
||||
|
||||
interface IssuerAnswer<T> {
|
||||
body: T;
|
||||
/** The issuer's clock from its Date header. */
|
||||
serverMs: number | null;
|
||||
}
|
||||
|
||||
interface RegistrationRequest {
|
||||
c: string;
|
||||
nonce: string;
|
||||
v: string;
|
||||
}
|
||||
|
||||
/** A refusal of the issuer, with how long it asked to be left alone. */
|
||||
export class IssuerError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly retryAfterMs: number | null,
|
||||
readonly serverMs: number | null,
|
||||
) {
|
||||
super(`the TREK services issuer answered ${status}`);
|
||||
this.name = 'IssuerError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The four calls TREK makes to the key issuer at places.liketrek.com. */
|
||||
@Injectable()
|
||||
export class IssuerClient {
|
||||
challenge(): Promise<IssuerAnswer<ServiceChallenge>> {
|
||||
return this.call('GET', '/v1/register/challenge', serviceChallengeSchema);
|
||||
}
|
||||
|
||||
register(request: RegistrationRequest): Promise<IssuerAnswer<ServiceKeyGrant>> {
|
||||
return this.call('POST', '/v1/register', serviceKeyGrantSchema, { body: request });
|
||||
}
|
||||
|
||||
renew(key: string): Promise<IssuerAnswer<ServiceKeyGrant>> {
|
||||
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 });
|
||||
}
|
||||
|
||||
private async call<T>(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
opts: { body?: RegistrationRequest; bearer?: string | null } = {},
|
||||
): 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}`,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
},
|
||||
{ maxBytes: MAX_BYTES },
|
||||
);
|
||||
const serverMs = serverDateMs(res.headers);
|
||||
if (!res.ok) throw new IssuerError(res.status, retryAfterMs(res.headers), serverMs);
|
||||
return { body: schema.parse(await res.json()), serverMs };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { QuotaReading } from './service-identity.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
interface QuotaSighting extends QuotaReading {
|
||||
/** When a liketrek service 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.
|
||||
*/
|
||||
@Injectable()
|
||||
export class QuotaTracker {
|
||||
private last: QuotaSighting | null = null;
|
||||
|
||||
record(reading: QuotaReading | null, at = new Date()): void {
|
||||
this.last = {
|
||||
tier: reading?.tier ?? this.last?.tier ?? null,
|
||||
quota: reading?.quota ?? this.last?.quota ?? null,
|
||||
seenAt: at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
latest(): QuotaSighting | null {
|
||||
return this.last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* The liketrek services' clock, as far as this process has seen it. Key times
|
||||
* (`iat`, `exp`, `renew_after`, a challenge's `expires_at`) are theirs, so a
|
||||
* server whose own clock is off still renews on time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceClock {
|
||||
private offsetMs = 0;
|
||||
|
||||
/** Takes the offset from a Date header the services sent. */
|
||||
learn(serverMs: number | null): void {
|
||||
if (serverMs !== null) this.offsetMs = serverMs - Date.now();
|
||||
}
|
||||
|
||||
nowSeconds(): number {
|
||||
return Math.floor((Date.now() + this.offsetMs) / 1000);
|
||||
}
|
||||
|
||||
/** A time on the services' clock, read on this one. */
|
||||
toLocalMs(serviceSeconds: number): number {
|
||||
return serviceSeconds * 1000 - this.offsetMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 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';
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { User } from '../../types';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { getClientIp } from '../audit/client-ip';
|
||||
import { AdminGuard } from '../auth-core/admin.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ManagedForbidden } from '../common/managed';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { ServiceIdentityService } from './service-identity.service';
|
||||
import { Controller, Get, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { serviceIdentityStatusSchema, type ServiceIdentityStatus } from '@trek/shared';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
/** /api/admin/service-identity: how this installation stands with the liketrek services, and a fresh start. */
|
||||
@Controller('api/admin/service-identity')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export class ServiceIdentityController {
|
||||
constructor(
|
||||
private readonly identity: ServiceIdentityService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ResponseContract(serviceIdentityStatusSchema)
|
||||
async status(): Promise<ServiceIdentityStatus> {
|
||||
return this.identity.status();
|
||||
}
|
||||
|
||||
@Post('regenerate')
|
||||
@HttpCode(200)
|
||||
@ManagedForbidden('the installation key of a managed install comes from its operator through TREK_SERVICES_KEY')
|
||||
@ResponseContract(serviceIdentityStatusSchema)
|
||||
async regenerate(@CurrentUser() user: User, @Req() req: Request): Promise<ServiceIdentityStatus> {
|
||||
const status = await this.identity.regenerate();
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.service_identity_regenerate', ip: getClientIp(req) });
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { serviceTierSchema, type ServiceQuotaView, type ServiceTier } from '@trek/shared';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Two service addresses name the same service when host and path agree. The
|
||||
* scheme, the case and trailing slashes do not count, so a compose line that
|
||||
* was only uncommented still reads as the public instance.
|
||||
*/
|
||||
export function sameServiceUrl(a: string, b: string): boolean {
|
||||
const left = serviceAddress(a);
|
||||
return left !== null && left === serviceAddress(b);
|
||||
}
|
||||
|
||||
function serviceAddress(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
||||
return `${url.host}${url.pathname.replace(/\/+$/, '')}`.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only an https request to a listed host carries the installation key. */
|
||||
export function isAllowlisted(url: URL, hosts: readonly string[]): boolean {
|
||||
return url.protocol === 'https:' && hosts.includes(url.hostname.toLowerCase());
|
||||
}
|
||||
|
||||
export interface QuotaReading {
|
||||
tier: ServiceTier | null;
|
||||
quota: ServiceQuotaView | null;
|
||||
}
|
||||
|
||||
/** The X-TREK-Tier and X-TREK-Quota-* headers a liketrek answer carries, or null without any. */
|
||||
export function readQuotaHeaders(headers: Headers): QuotaReading | null {
|
||||
const tier = serviceTierSchema.safeParse(headers.get('x-trek-tier'));
|
||||
const limit = wholeNumber(headers.get('x-trek-quota-limit'));
|
||||
const remaining = wholeNumber(headers.get('x-trek-quota-remaining'));
|
||||
const reset = wholeNumber(headers.get('x-trek-quota-reset'));
|
||||
const quota =
|
||||
limit !== null && remaining !== null && reset !== null
|
||||
? { limit, used: Math.max(0, limit - remaining), remaining, resetAt: new Date(reset * 1000).toISOString() }
|
||||
: null;
|
||||
if (!tier.success && !quota) return null;
|
||||
return { tier: tier.success ? tier.data : null, quota };
|
||||
}
|
||||
|
||||
function wholeNumber(raw: string | null): number | null {
|
||||
if (raw === null || !/^\d{1,12}$/.test(raw.trim())) return null;
|
||||
return Number(raw.trim());
|
||||
}
|
||||
|
||||
/** Retry-After as milliseconds from now, in either of its two spellings. */
|
||||
export function retryAfterMs(headers: Headers, now = Date.now()): number | null {
|
||||
const raw = headers.get('retry-after')?.trim();
|
||||
if (!raw) return null;
|
||||
if (/^\d{1,9}$/.test(raw)) return Number(raw) * 1000;
|
||||
const at = Date.parse(raw);
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - now);
|
||||
}
|
||||
|
||||
/** The answering server's clock from its Date header. */
|
||||
export function serverDateMs(headers: Headers): number | null {
|
||||
const raw = headers.get('date');
|
||||
const at = raw ? Date.parse(raw) : Number.NaN;
|
||||
return Number.isNaN(at) ? null : at;
|
||||
}
|
||||
|
||||
/** Names a key in stored bookkeeping without storing the key itself. */
|
||||
export function keyFingerprint(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex').slice(0, 16);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ProcessStoreSlot } from '../common/process-store-slot';
|
||||
|
||||
/**
|
||||
* What a client of a liketrek service asks before and after each request: the
|
||||
* headers that identify this installation, and a look at the answer (quota
|
||||
* headers, the server's clock, a refused key).
|
||||
*
|
||||
* The Places client is plain functions, so it reads the port from the slot
|
||||
* below instead of taking it through a constructor. ServiceIdentityModule
|
||||
* installs the implementation the container resolved.
|
||||
*/
|
||||
export abstract class ServiceIdentityPort {
|
||||
abstract headersFor(url: URL): Record<string, string>;
|
||||
abstract observe(url: URL, response: Response): void;
|
||||
}
|
||||
|
||||
/** A process without the module (a hand-built test) sends what it sent before. */
|
||||
class AnonymousIdentity extends ServiceIdentityPort {
|
||||
headersFor(): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
observe(): void {}
|
||||
}
|
||||
|
||||
export const serviceIdentitySlot = new ProcessStoreSlot<ServiceIdentityPort>(new AnonymousIdentity());
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { IssuerClient } from './issuer.client';
|
||||
import { QuotaTracker } from './quota-tracker';
|
||||
import { ServiceClock } from './service-clock';
|
||||
import { ServiceIdentityController } from './service-identity.controller';
|
||||
import { ServiceIdentityPort, serviceIdentitySlot } from './service-identity.interface';
|
||||
import { ServiceIdentityService } from './service-identity.service';
|
||||
import { ServiceKeyJob } from './service-key.job';
|
||||
import { ServiceKeyService } from './service-key.service';
|
||||
import { ServiceKeyStore } from './service-key.store';
|
||||
import { ServiceRequestIdentity } from './service-request-identity';
|
||||
import { Module, type OnModuleDestroy } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* The installation identity towards the liketrek services: the key, its
|
||||
* registration and renewal, and the admin view of it. The Places client reads
|
||||
* the request side through serviceIdentitySlot, which this module fills with
|
||||
* the port the container resolved.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, AuditModule, SchedulingModule, SettingsModule],
|
||||
controllers: [ServiceIdentityController],
|
||||
providers: [
|
||||
IssuerClient,
|
||||
QuotaTracker,
|
||||
ServiceClock,
|
||||
ServiceIdentityService,
|
||||
ServiceKeyJob,
|
||||
ServiceKeyService,
|
||||
ServiceKeyStore,
|
||||
{ provide: ServiceIdentityPort, useClass: ServiceRequestIdentity },
|
||||
],
|
||||
})
|
||||
export class ServiceIdentityModule implements OnModuleDestroy {
|
||||
constructor(private readonly port: ServiceIdentityPort) {
|
||||
serviceIdentitySlot.install(port);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
serviceIdentitySlot.release(this.port);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { logDebug } from '../audit/audit-log.logger';
|
||||
import { IssuerClient } from './issuer.client';
|
||||
import { QuotaTracker } from './quota-tracker';
|
||||
import { ServiceClock } from './service-clock';
|
||||
import { ServiceKeyService, type KeySnapshot } from './service-key.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
serviceTierSchema,
|
||||
type ServiceIdentityStatus,
|
||||
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. */
|
||||
const QUOTA_CACHE_MS = 20_000;
|
||||
|
||||
const OFF: ServiceIdentityStatus = { state: 'off', tier: null, keySource: null, quota: null, lastSeenAt: 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;
|
||||
|
||||
constructor(
|
||||
private readonly keys: ServiceKeyService,
|
||||
private readonly issuer: IssuerClient,
|
||||
private readonly tracker: QuotaTracker,
|
||||
private readonly clock: ServiceClock,
|
||||
) {}
|
||||
|
||||
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();
|
||||
return {
|
||||
state: key.state,
|
||||
tier: fresh?.tier ?? seen?.tier ?? keyTier(key),
|
||||
keySource: key.source,
|
||||
quota: fresh ? quotaView(fresh) : (seen?.quota ?? null),
|
||||
lastSeenAt: seen?.seenAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async regenerate(): Promise<ServiceIdentityStatus> {
|
||||
await this.keys.regenerate();
|
||||
this.cached = null;
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** GET /v1/quota, which costs no allowance; null when the issuer cannot be asked right now. */
|
||||
private async freshQuota(bearer: string | null): Promise<ServiceQuota | null> {
|
||||
if (this.cached && this.cached.bearer === bearer && Date.now() - this.cached.at < QUOTA_CACHE_MS) {
|
||||
return this.cached.quota;
|
||||
}
|
||||
let quota: ServiceQuota | null = null;
|
||||
try {
|
||||
const answer = await this.issuer.quota(bearer);
|
||||
this.clock.learn(answer.serverMs);
|
||||
quota = answer.body;
|
||||
} catch (err) {
|
||||
logDebug(`TREK services: quota not available: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
this.cached = { bearer, at: Date.now(), quota };
|
||||
return quota;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return {
|
||||
limit: quota.limit,
|
||||
used: quota.used,
|
||||
remaining: quota.remaining,
|
||||
resetAt: new Date(quota.reset_at * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
i: z.string().min(1).max(64).regex(BASE64URL),
|
||||
iat: z.number().int().nonnegative(),
|
||||
exp: z.number().int().positive(),
|
||||
q: z.string().min(1).max(32),
|
||||
});
|
||||
|
||||
type ServiceKeyPayload = z.infer<typeof payloadSchema>;
|
||||
|
||||
export interface ServiceKey {
|
||||
token: string;
|
||||
kid: string;
|
||||
payload: ServiceKeyPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `tk1.<kid>.<payload>.<signature>`. The signature is the gate's to
|
||||
* check; TREK only needs the expiry and the class it was given, and treats
|
||||
* anything that does not read as no key at all.
|
||||
*/
|
||||
export function parseServiceKey(token: string): ServiceKey | null {
|
||||
const parts = token.trim().split('.');
|
||||
if (parts.length !== 4 || parts[0] !== 'tk1') return null;
|
||||
const [, kid, body, signature] = parts;
|
||||
if (![kid, body, signature].every((part) => BASE64URL.test(part))) return null;
|
||||
const payload = payloadSchema.safeParse(decodeSegment(body));
|
||||
return payload.success ? { token: parts.join('.'), kid, payload: payload.data } : null;
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): unknown {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { ServiceKeyService } from './service-key.service';
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Once a day, at a minute and hour picked per start, renews the installation
|
||||
* key when it is due. Spread out so the installations of the world do not all
|
||||
* knock on the issuer at the same moment.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceKeyJob implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly keys: ServiceKeyService,
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
this.registrar.register('service-key-renewal', `${randomInt(60)} ${randomInt(24)} * * *`, () =>
|
||||
this.keys.renewIfDue(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ServiceChallenge } from '@trek/shared';
|
||||
|
||||
import { scrypt } from 'node:crypto';
|
||||
|
||||
const SALT = 'trek-pow-v1';
|
||||
const HASH_BYTES = 32;
|
||||
|
||||
type ScryptCost = Pick<ServiceChallenge, 'N' | 'r' | 'p'>;
|
||||
|
||||
/** scrypt(c + "." + nonce, "trek-pow-v1"), off the event loop. */
|
||||
export function powHash(challenge: string, nonce: string, cost: ScryptCost): Promise<Buffer> {
|
||||
const { N, r, p } = cost;
|
||||
// Twice what scrypt needs, so Node's 32 MB default never refuses a valid challenge.
|
||||
const maxmem = 2 * 128 * N * r + 128 * r * p;
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(`${challenge}.${nonce}`, SALT, HASH_BYTES, { N, r, p, maxmem }, (err, hash) =>
|
||||
err ? reject(err) : resolve(hash),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function leadingZeroBits(hash: Uint8Array): number {
|
||||
let bits = 0;
|
||||
for (const byte of hash) {
|
||||
if (byte !== 0) return bits + Math.clz32(byte) - 24;
|
||||
bits += 8;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
/** The first decimal nonce whose hash has at least `bits` leading zero bits, tried until `deadlineMs`. */
|
||||
export async function solvePow(challenge: ServiceChallenge, deadlineMs: number): Promise<string> {
|
||||
for (let nonce = 0; Date.now() < deadlineMs; nonce++) {
|
||||
const hash = await powHash(challenge.c, String(nonce), challenge);
|
||||
if (leadingZeroBits(hash) >= challenge.bits) return String(nonce);
|
||||
}
|
||||
throw new Error('the registration challenge expired before it was solved');
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { runningVersion } from '../../app-config';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { servicesConfig } from '../app-config/tokens';
|
||||
import { logInfo, logWarn } from '../audit/audit-log.logger';
|
||||
import { conflict } from '../common/domain-error';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
import { IssuerClient, IssuerError } from './issuer.client';
|
||||
import { ServiceClock } from './service-clock';
|
||||
import { SERVICE_ISSUER_ORIGIN } from './service-identity.constants';
|
||||
import { isAllowlisted, keyFingerprint, sameServiceUrl } from './service-identity.helpers';
|
||||
import { parseServiceKey, type ServiceKey } from './service-key.format';
|
||||
import { solvePow } from './service-key.pow';
|
||||
import { EMPTY_KEY_STATE, ServiceKeyStore, type KeyState, type StoredIdentity } from './service-key.store';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import type { ServiceIdentityState, ServiceKeyGrant } from '@trek/shared';
|
||||
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
const DAY_SECONDS = 86_400;
|
||||
/** A key is renewed from day 60 of its 90, unless the issuer said otherwise. */
|
||||
const RENEW_AGE_SECONDS = 60 * DAY_SECONDS;
|
||||
/** An expired key can still be renewed for this long; after that it registers anew. */
|
||||
const RENEW_GRACE_SECONDS = 30 * DAY_SECONDS;
|
||||
/** Refusals in a row before a key counts as gone, so one broken deploy upstream drops nothing. */
|
||||
const INVALID_STREAK_LIMIT = 3;
|
||||
/** Spreads the new registrations of every installation a broken key hit, instead of all at once. */
|
||||
const REREGISTER_JITTER_MS = 6 * 3_600_000;
|
||||
const FIRST_BACKOFF_MS = 5 * 60_000;
|
||||
const MAX_BACKOFF_MS = 6 * 3_600_000;
|
||||
|
||||
export interface KeySnapshot {
|
||||
state: ServiceIdentityState;
|
||||
key: ServiceKey | null;
|
||||
source: 'env' | 'registered' | null;
|
||||
/** What a request may carry right now; null while revoked or without a key. */
|
||||
bearer: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The installation key: loaded lazily, registered in the background on the
|
||||
* first call to a liketrek service, renewed from day 60, given up after
|
||||
* repeated refusals and frozen when the services revoke it.
|
||||
*
|
||||
* Nothing here ever makes a request wait. Until a key is there, requests go
|
||||
* out as they always did, and the shared legacy pool answers them.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceKeyService {
|
||||
private loaded = false;
|
||||
private key: ServiceKey | null = null;
|
||||
/** The stored row exists but cannot be used, so a new key overwrites it instead of claiming. */
|
||||
private staleRow = false;
|
||||
private state: KeyState = EMPTY_KEY_STATE;
|
||||
private work: Promise<void> | null = null;
|
||||
private registering = false;
|
||||
private nextAttemptAt = 0;
|
||||
private failures = 0;
|
||||
private invalidStreak = 0;
|
||||
|
||||
constructor(
|
||||
@Inject(servicesConfig.KEY) private readonly config: ConfigType<typeof servicesConfig>,
|
||||
private readonly env: RuntimeEnvService,
|
||||
private readonly store: ServiceKeyStore,
|
||||
private readonly issuer: IssuerClient,
|
||||
private readonly clock: ServiceClock,
|
||||
private readonly orm: MikroORM,
|
||||
) {}
|
||||
|
||||
/** Whether the Places service this instance uses is one the key goes to. */
|
||||
inUse(): boolean {
|
||||
const maps = this.env.env().maps;
|
||||
if (this.config.mode === 'off' || !maps.trekPlacesEnabled) return false;
|
||||
try {
|
||||
return isAllowlisted(new URL(this.placesUrl()), this.config.hosts);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The bearer for a request to an allowlisted host; starts a registration in the background when there is none. */
|
||||
bearer(): string | null {
|
||||
if (!this.inUse()) return null;
|
||||
this.settleInBackground();
|
||||
const token = this.config.key ?? this.key?.token ?? null;
|
||||
return token && !this.isRevoked(token) ? token : null;
|
||||
}
|
||||
|
||||
/** What the admin card shows. Reads only: a GET must not register. */
|
||||
async snapshot(): Promise<KeySnapshot> {
|
||||
if (!this.inUse()) return { state: 'off', key: null, source: null, bearer: null };
|
||||
await this.load();
|
||||
const token = this.config.key ?? this.key?.token ?? null;
|
||||
if (!token) return { state: this.registering ? 'registering' : 'legacy', key: null, source: null, bearer: null };
|
||||
const source = this.config.key ? 'env' : 'registered';
|
||||
const key = parseServiceKey(token);
|
||||
if (this.isRevoked(token)) return { state: 'revoked', key, source, bearer: null };
|
||||
return { state: 'connected', key, source, bearer: token };
|
||||
}
|
||||
|
||||
/** A liketrek service accepted the key. */
|
||||
noteAccepted(): void {
|
||||
this.invalidStreak = 0;
|
||||
}
|
||||
|
||||
/** A liketrek service answered 401 to the key; the third in a row gives it up. */
|
||||
noteInvalid(): void {
|
||||
if (!this.config.key && !this.key) return;
|
||||
this.invalidStreak += 1;
|
||||
if (this.invalidStreak < INVALID_STREAK_LIMIT) return;
|
||||
this.invalidStreak = 0;
|
||||
if (this.config.key) {
|
||||
logWarn(
|
||||
'TREK services: the key in TREK_SERVICES_KEY is refused. Place search continues through the shared pool.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.inBackground(() => this.dropKey());
|
||||
}
|
||||
|
||||
/** A liketrek service answered that the key is revoked. */
|
||||
noteRevoked(): void {
|
||||
const token = this.config.key ?? this.key?.token;
|
||||
if (!token || this.isRevoked(token)) return;
|
||||
this.inBackground(() => this.markRevoked(token));
|
||||
}
|
||||
|
||||
/** The daily check: renews the key once it is due. A key past its grace period is already gone on load. */
|
||||
async renewIfDue(): Promise<void> {
|
||||
if (!this.inUse() || this.config.key) return;
|
||||
await this.load();
|
||||
const key = this.key;
|
||||
if (!key || this.isRevoked(key.token)) return;
|
||||
if (this.clock.nowSeconds() < (this.state.renewAfter ?? key.payload.iat + RENEW_AGE_SECONDS)) return;
|
||||
const renewal = await this.issuer.renew(key.token).then(
|
||||
(grant) => ({ grant, error: null }),
|
||||
(error: unknown) => ({ grant: null, error }),
|
||||
);
|
||||
if (!renewal.grant) {
|
||||
await this.refused(renewal.error, key.token);
|
||||
return;
|
||||
}
|
||||
this.clock.learn(renewal.grant.serverMs);
|
||||
const renewed = this.readGrant(renewal.grant.body);
|
||||
this.state = { ...this.state, renewAfter: renewal.grant.body.renew_after };
|
||||
await this.store.replace(renewed, this.state);
|
||||
this.key = renewed;
|
||||
logInfo('TREK services: renewed the key of this installation.');
|
||||
}
|
||||
|
||||
/** The admin's way out of a revoked or cloned identity: forget the key and ask for a new one. */
|
||||
async regenerate(): Promise<void> {
|
||||
if (this.config.key) throw conflict('The key is set by TREK_SERVICES_KEY and is changed there');
|
||||
if (!this.inUse()) throw conflict('TREK services are not in use on this instance');
|
||||
await this.store.clear();
|
||||
this.adopt({ key: null, unreadable: false, state: EMPTY_KEY_STATE });
|
||||
this.nextAttemptAt = 0;
|
||||
this.failures = 0;
|
||||
this.invalidStreak = 0;
|
||||
logInfo('TREK services: an admin asked for a new identity for this installation.');
|
||||
this.settleInBackground();
|
||||
}
|
||||
|
||||
/** Test seam: resolves once the background work queued so far has run. */
|
||||
async whenIdle(): Promise<void> {
|
||||
const work = this.work;
|
||||
if (!work) return;
|
||||
await work;
|
||||
await this.whenIdle();
|
||||
}
|
||||
|
||||
private placesUrl(): string {
|
||||
return this.env.env().maps.trekPlacesUrl?.trim() || SERVICE_ISSUER_ORIGIN;
|
||||
}
|
||||
|
||||
private mayRegister(): boolean {
|
||||
return !this.config.key && !this.env.isTest() && sameServiceUrl(this.placesUrl(), SERVICE_ISSUER_ORIGIN);
|
||||
}
|
||||
|
||||
private wantsRegistration(): boolean {
|
||||
const now = Date.now();
|
||||
return (
|
||||
this.mayRegister() &&
|
||||
this.key === null &&
|
||||
this.state.revoked === null &&
|
||||
now >= this.nextAttemptAt &&
|
||||
now >= (this.state.reregisterAt ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
private isRevoked(token: string): boolean {
|
||||
return this.state.revoked !== null && this.state.revoked === keyFingerprint(token);
|
||||
}
|
||||
|
||||
private settleInBackground(): void {
|
||||
if (this.work || (this.loaded && !this.wantsRegistration())) return;
|
||||
this.inBackground(async () => {
|
||||
await this.load();
|
||||
if (this.wantsRegistration()) await this.register();
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs work outside the request that triggered it, one piece at a time, and never lets it fail a caller. */
|
||||
private inBackground(task: () => Promise<void>): void {
|
||||
const previous = this.work ?? Promise.resolve();
|
||||
const next = previous
|
||||
.then(() => withRequestContext(this.orm, task))
|
||||
.catch((err: unknown) => logWarn(`TREK services: ${err instanceof Error ? err.message : String(err)}`))
|
||||
.finally(() => {
|
||||
if (this.work === next) this.work = null;
|
||||
});
|
||||
this.work = next;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
this.adopt(await this.store.read());
|
||||
}
|
||||
|
||||
private adopt(stored: StoredIdentity): void {
|
||||
const expired = stored.key !== null && this.clock.nowSeconds() > stored.key.payload.exp + RENEW_GRACE_SECONDS;
|
||||
this.key = expired ? null : stored.key;
|
||||
this.staleRow = stored.unreadable || expired;
|
||||
this.state = stored.state;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async register(): Promise<void> {
|
||||
this.registering = true;
|
||||
try {
|
||||
const challenge = await this.issuer.challenge();
|
||||
this.clock.learn(challenge.serverMs);
|
||||
const nonce = await solvePow(challenge.body, this.clock.toLocalMs(challenge.body.expires_at));
|
||||
const grant = await this.issuer.register({ c: challenge.body.c, nonce, v: runningVersion() });
|
||||
this.clock.learn(grant.serverMs);
|
||||
const key = this.readGrant(grant.body);
|
||||
const state: KeyState = { ...EMPTY_KEY_STATE, renewAfter: grant.body.renew_after };
|
||||
if (this.staleRow) {
|
||||
await this.store.replace(key, state);
|
||||
this.adopt({ key, unreadable: false, state });
|
||||
} else {
|
||||
this.adopt(await this.store.claim(key, state));
|
||||
}
|
||||
this.failures = 0;
|
||||
logInfo('TREK services: this installation is registered.');
|
||||
} catch (err) {
|
||||
this.backOff(err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
private readGrant(grant: ServiceKeyGrant): ServiceKey {
|
||||
const key = parseServiceKey(grant.key);
|
||||
if (!key) throw new Error('the issuer handed out a key TREK cannot read');
|
||||
return key;
|
||||
}
|
||||
|
||||
private backOff(err: unknown): void {
|
||||
this.failures += 1;
|
||||
const asked = err instanceof IssuerError ? err.retryAfterMs : null;
|
||||
const backoff = Math.min(MAX_BACKOFF_MS, FIRST_BACKOFF_MS * 2 ** (this.failures - 1));
|
||||
this.nextAttemptAt = Date.now() + (asked ?? backoff);
|
||||
}
|
||||
|
||||
private async refused(err: unknown, token: string): Promise<void> {
|
||||
if (!(err instanceof IssuerError) || (err.status !== 401 && err.status !== 403)) throw err;
|
||||
if (err.status === 403) await this.markRevoked(token);
|
||||
else await this.dropKey();
|
||||
}
|
||||
|
||||
private async dropKey(): Promise<void> {
|
||||
this.key = null;
|
||||
this.state = { ...this.state, renewAfter: null, reregisterAt: Date.now() + randomInt(REREGISTER_JITTER_MS) };
|
||||
await this.store.dropKey(this.state);
|
||||
logWarn('TREK services: the key of this installation is no longer accepted; a new one is requested later.');
|
||||
}
|
||||
|
||||
private async markRevoked(token: string): Promise<void> {
|
||||
await this.load();
|
||||
this.state = { ...this.state, revoked: keyFingerprint(token) };
|
||||
await this.store.writeState(this.state);
|
||||
logWarn(
|
||||
'TREK services: the key of this installation was revoked. Place search continues through the shared pool; ' +
|
||||
'an admin can create a new identity under Admin, Settings, API Keys.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { decrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { parseServiceKey, type ServiceKey } from './service-key.format';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const keyStateSchema = z.object({
|
||||
/** Fingerprint of a key the services revoked; cleared only by an admin's new identity. */
|
||||
revoked: z.string().nullable().catch(null),
|
||||
/** Local epoch ms before which no new registration is tried, after a key was refused. */
|
||||
reregisterAt: z.number().nullable().catch(null),
|
||||
/** When the key is due for renewal, on the services' clock (unix seconds). */
|
||||
renewAfter: z.number().nullable().catch(null),
|
||||
});
|
||||
|
||||
export type KeyState = z.infer<typeof keyStateSchema>;
|
||||
|
||||
export const EMPTY_KEY_STATE: KeyState = { revoked: null, reregisterAt: null, renewAfter: null };
|
||||
|
||||
export interface StoredIdentity {
|
||||
key: ServiceKey | null;
|
||||
/** A row is there but does not open with this ENCRYPTION_KEY, or does not read as a key. */
|
||||
unreadable: boolean;
|
||||
state: KeyState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The installation key in app_settings, encrypted like the other instance
|
||||
* secrets, and the small state record beside it. Nothing here talks to the
|
||||
* services; it only keeps what they handed out.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceKeyStore {
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
async read(): Promise<StoredIdentity> {
|
||||
const ciphertext = await this.settings.getAppSetting('service_key');
|
||||
const state = readState(await this.settings.getAppSetting('service_key_state'));
|
||||
if (!ciphertext) return { key: null, unreadable: false, state };
|
||||
const key = parseServiceKey(decrypt_api_key(ciphertext) ?? '');
|
||||
return { key, unreadable: key === null, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a freshly issued key unless another process on this database stored
|
||||
* one first, then answers whatever is stored now.
|
||||
*/
|
||||
async claim(key: ServiceKey, state: KeyState): Promise<StoredIdentity> {
|
||||
await this.uow.transactional(async () => {
|
||||
await this.settings.insertAppSettingIfAbsent('service_key', encrypt_api_key(key.token));
|
||||
await this.settings.setAppSettings({ service_key_state: JSON.stringify(state) });
|
||||
});
|
||||
return this.read();
|
||||
}
|
||||
|
||||
/** Overwrites the stored key: a renewal, or a row that could not be read. */
|
||||
async replace(key: ServiceKey, state: KeyState): Promise<void> {
|
||||
await this.settings.setAppSettings({
|
||||
service_key: encrypt_api_key(key.token),
|
||||
service_key_state: JSON.stringify(state),
|
||||
});
|
||||
}
|
||||
|
||||
async writeState(state: KeyState): Promise<void> {
|
||||
await this.settings.setAppSettings({ service_key_state: JSON.stringify(state) });
|
||||
}
|
||||
|
||||
/** Forgets the key but keeps the state that says when to ask again. */
|
||||
async dropKey(state: KeyState): Promise<void> {
|
||||
await this.uow.transactional(async () => {
|
||||
await this.settings.deleteAppSettings(['service_key']);
|
||||
await this.writeState(state);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.settings.deleteAppSettings(['service_key', 'service_key_state']);
|
||||
}
|
||||
}
|
||||
|
||||
function readState(raw: string | null): KeyState {
|
||||
if (!raw) return EMPTY_KEY_STATE;
|
||||
try {
|
||||
return keyStateSchema.parse({ ...EMPTY_KEY_STATE, ...(JSON.parse(raw) as object) });
|
||||
} catch {
|
||||
return EMPTY_KEY_STATE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { servicesConfig } from '../app-config/tokens';
|
||||
import { QuotaTracker } from './quota-tracker';
|
||||
import { ServiceClock } from './service-clock';
|
||||
import { isAllowlisted, readQuotaHeaders, serverDateMs } from './service-identity.helpers';
|
||||
import { ServiceIdentityPort } from './service-identity.interface';
|
||||
import { ServiceKeyService } from './service-key.service';
|
||||
import { serviceUserAgent } from './user-agent';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
|
||||
/** A revocation notice is one short JSON object; nothing longer is read. */
|
||||
const NOTICE_MAX_CHARS = 2048;
|
||||
|
||||
/**
|
||||
* The identity every request to a liketrek service carries, and what TREK
|
||||
* learns from each answer. The key goes to the allowlisted hosts only; a
|
||||
* mirror, a plugin's egress or any other host never sees it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServiceRequestIdentity extends ServiceIdentityPort {
|
||||
constructor(
|
||||
@Inject(servicesConfig.KEY) private readonly config: ConfigType<typeof servicesConfig>,
|
||||
private readonly keys: ServiceKeyService,
|
||||
private readonly tracker: QuotaTracker,
|
||||
private readonly clock: ServiceClock,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
headersFor(url: URL): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'User-Agent': serviceUserAgent() };
|
||||
if (!isAllowlisted(url, this.config.hosts)) return headers;
|
||||
const bearer = this.keys.bearer();
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
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));
|
||||
if (response.ok) this.keys.noteAccepted();
|
||||
else if (response.status === 401) this.keys.noteInvalid();
|
||||
else if (response.status === 403) void this.checkRevocation(response.clone());
|
||||
}
|
||||
|
||||
private async checkRevocation(response: Response): Promise<void> {
|
||||
if (await saysRevoked(response)) this.keys.noteRevoked();
|
||||
}
|
||||
}
|
||||
|
||||
/** A 403 means a revoked key only when the body says so; anything else leaves the key alone. */
|
||||
async function saysRevoked(response: Response): Promise<boolean> {
|
||||
try {
|
||||
return (await readUpTo(response, NOTICE_MAX_CHARS)).includes('key_revoked');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readUpTo(response: Response, maxChars: number): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return '';
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
while (text.length <= maxChars) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return text;
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
await reader.cancel();
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { runningVersion } from '../../app-config';
|
||||
|
||||
/** How every request to a liketrek service names its sender. */
|
||||
export function serviceUserAgent(): string {
|
||||
return `TREK/${runningVersion()} (+https://github.com/liketrek/TREK)`;
|
||||
}
|
||||
@@ -383,6 +383,21 @@ export class SettingsService {
|
||||
return await readAppSetting(this.appSettings, key);
|
||||
}
|
||||
|
||||
/** Writes a registered instance setting only while it has no row; the first of several processes keeps it. */
|
||||
async insertAppSettingIfAbsent(key: AppSettingKey, value: string): Promise<void> {
|
||||
await this.appSettings.insertIfAbsent(key, value);
|
||||
}
|
||||
|
||||
/** Removes registered instance settings together. */
|
||||
async deleteAppSettings(keys: AppSettingKey[]): Promise<void> {
|
||||
await this.uow.transactional(() =>
|
||||
keys.reduce<Promise<void>>(async (previous, key) => {
|
||||
await previous;
|
||||
await this.appSettings.deleteValue(key);
|
||||
}, Promise.resolve()),
|
||||
);
|
||||
}
|
||||
|
||||
/** Registered instance settings written together: every value lands, or none does. */
|
||||
async setAppSettings(values: Partial<Record<AppSettingKey, string>>): Promise<void> {
|
||||
const entries = Object.entries(values).filter((entry): entry is [string, string] => entry[1] !== undefined);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* /api/admin/service-identity e2e: the real JwtAuthGuard, AdminGuard and
|
||||
* response contract over ServiceIdentityModule on a migrated temp database,
|
||||
* against a fake issuer standing in for places.liketrek.com.
|
||||
*
|
||||
* Replaced: safeFetch, so the issuer is the fake below, and the audit writer.
|
||||
* NODE_ENV=test keeps registration off, as it is in every suite.
|
||||
*/
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { servicesConfig } from '../../src/nest/app-config/tokens';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { ServiceIdentityModule } from '../../src/nest/service-identity/service-identity.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { deleteRows, findRow, upsertRow } from '../helpers/factories/rows';
|
||||
import { makeAdmin, makeUser } from '../helpers/factories/users';
|
||||
import { serviceKeyTokenFor } from '../helpers/service-keys';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { safeFetch } = vi.hoisted(() => ({ safeFetch: vi.fn() }));
|
||||
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
|
||||
safeFetch,
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const USER = 1;
|
||||
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 writeAudit = vi.fn();
|
||||
let orm: TestOrm;
|
||||
|
||||
type ServicesConfig = { mode: 'auto' | 'off'; key: string | null; hosts: 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 })
|
||||
.overrideProvider(AuditService)
|
||||
.useValue({ writeAudit })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
/** The issuer as places.liketrek.com answers /v1/quota. */
|
||||
function issuerAnswers(status = 200) {
|
||||
safeFetch.mockImplementation(async () =>
|
||||
status === 200
|
||||
? new Response(JSON.stringify(QUOTA), { status, headers: { 'content-type': 'application/json' } })
|
||||
: new Response('{"error":"unavailable"}', { status }),
|
||||
);
|
||||
}
|
||||
|
||||
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: USER, username: 'traveler', email: 'traveler@example.test' });
|
||||
await makeAdmin(orm, { id: ADMIN, username: 'admin', email: 'admin@example.test' });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
safeFetch.mockReset();
|
||||
writeAudit.mockReset();
|
||||
issuerAnswers();
|
||||
await deleteRows(orm, AppSettings, { key: { $in: ['service_key', 'service_key_state'] } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
describe('with a key from TREK_SERVICES_KEY', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await build({ key: ENV_KEY });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('SIE2E-001: answers 401 without a session and 403 to a user who is no admin', async () => {
|
||||
const server = app.getHttpServer();
|
||||
expect((await request(server).get('/api/admin/service-identity')).status).toBe(401);
|
||||
expect((await request(server).get('/api/admin/service-identity').set(as(USER))).status).toBe(403);
|
||||
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 () => {
|
||||
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() },
|
||||
lastSeenAt: null,
|
||||
});
|
||||
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}`);
|
||||
expect(init.headers['User-Agent']).toMatch(/^TREK\//);
|
||||
});
|
||||
|
||||
it('SIE2E-003: will not start over a key the environment sets', async () => {
|
||||
const res = await request(app.getHttpServer()).post('/api/admin/service-identity/regenerate').set(as(ADMIN));
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: 'The key is set by TREK_SERVICES_KEY and is changed there' });
|
||||
expect(writeAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a key the installation registered', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await build();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('SIE2E-010: a stored key counts as connected, and the card still reads while the issuer is down', async () => {
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key', value: encrypt_api_key(STORED_KEY) });
|
||||
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 });
|
||||
});
|
||||
|
||||
it('SIE2E-011: a new identity drops the stored key, is audited, and leaves the shared pool until the next search', async () => {
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key', value: encrypt_api_key(STORED_KEY) });
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key_state', value: '{"revoked":"abc"}' });
|
||||
const res = await request(app.getHttpServer()).post('/api/admin/service-identity/regenerate').set(as(ADMIN));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ state: 'legacy', keySource: null });
|
||||
expect(await findRow(orm, AppSettings, { key: 'service_key' })).toBeNull();
|
||||
expect(await findRow(orm, AppSettings, { key: 'service_key_state' })).toBeNull();
|
||||
expect(writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: ADMIN, action: 'admin.service_identity_regenerate' }),
|
||||
);
|
||||
const [, init] = safeFetch.mock.calls.at(-1) as [string, RequestInit & { headers: Record<string, string> }];
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
});
|
||||
|
||||
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(safeFetch).not.toHaveBeenCalled();
|
||||
const regenerate = await request(app.getHttpServer())
|
||||
.post('/api/admin/service-identity/regenerate')
|
||||
.set(as(ADMIN));
|
||||
expect(regenerate.status).toBe(409);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"format": "tk1.<kid>.<base64url(payload_json)>.<base64url(ed25519(issuer_sk, 'tk1.'+kid+'.'+payload_b64url))>",
|
||||
"base64url": "RFC 4648 section 5, no padding",
|
||||
"kid": "test1",
|
||||
"public_key_b64url": "2Vf8tqqkTtNVzw401JcQEwSc-MUATUozBLyK2h79bY0",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "std",
|
||||
"payload": {
|
||||
"i": "AAECAwQFBgcICQoLDA0ODw",
|
||||
"iat": 1793000000,
|
||||
"exp": 1800776000,
|
||||
"q": "std"
|
||||
},
|
||||
"payload_json": "{\"i\":\"AAECAwQFBgcICQoLDA0ODw\",\"iat\":1793000000,\"exp\":1800776000,\"q\":\"std\"}",
|
||||
"token": "tk1.test1.eyJpIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsImlhdCI6MTc5MzAwMDAwMCwiZXhwIjoxODAwNzc2MDAwLCJxIjoic3RkIn0.UyQGzHwmtYj4s93PR6l5kqAsbEAZwURmhVlhljanHRTBB-FAE_20rbLWLsAlRQNn5faeigKXpFX_ZV5Ulr53BQ",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"name": "own",
|
||||
"payload": {
|
||||
"i": "EBESExQVFhcYGRobHB0eHw",
|
||||
"iat": 1793000000,
|
||||
"exp": 1800776000,
|
||||
"q": "own"
|
||||
},
|
||||
"payload_json": "{\"i\":\"EBESExQVFhcYGRobHB0eHw\",\"iat\":1793000000,\"exp\":1800776000,\"q\":\"own\"}",
|
||||
"token": "tk1.test1.eyJpIjoiRUJFU0V4UVZGaGNZR1JvYkhCMGVIdyIsImlhdCI6MTc5MzAwMDAwMCwiZXhwIjoxODAwNzc2MDAwLCJxIjoib3duIn0.VQcnAlqeWD6BvPR0hfqyEM0Hri154soKz2dtYOEE8SY8Kt08oRV0Q4PbS-phJ2JZxisLLfIYndbdwnsM-WwFDQ",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"name": "tampered_signature",
|
||||
"token": "tk1.test1.eyJpIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsImlhdCI6MTc5MzAwMDAwMCwiZXhwIjoxODAwNzc2MDAwLCJxIjoic3RkIn0.UiQGzHwmtYj4s93PR6l5kqAsbEAZwURmhVlhljanHRTBB-FAE_20rbLWLsAlRQNn5faeigKXpFX_ZV5Ulr53BQ",
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"name": "tampered_payload",
|
||||
"token": "tk1.test1.eyJpIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsImlhdCI6MTc5MzAwMDAwMCwiZXhwIjoxODAwNzc2MDAwLCJxIjoib3duIn0.UyQGzHwmtYj4s93PR6l5kqAsbEAZwURmhVlhljanHRTBB-FAE_20rbLWLsAlRQNn5faeigKXpFX_ZV5Ulr53BQ",
|
||||
"valid": false
|
||||
},
|
||||
{
|
||||
"name": "unknown_kid",
|
||||
"token": "tk1.nope.eyJpIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsImlhdCI6MTc5MzAwMDAwMCwiZXhwIjoxODAwNzc2MDAwLCJxIjoic3RkIn0.UyQGzHwmtYj4s93PR6l5kqAsbEAZwURmhVlhljanHRTBB-FAE_20rbLWLsAlRQNn5faeigKXpFX_ZV5Ulr53BQ",
|
||||
"valid": false
|
||||
}
|
||||
],
|
||||
"pow": {
|
||||
"definition": "Find decimal-string nonce (\"0\",\"1\",...) such that scrypt(password=UTF8(c + '.' + nonce), salt=UTF8('trek-pow-v1'), N, r, p, dkLen=32) has at least `bits` leading zero bits (MSB first).",
|
||||
"c": "v1.test-challenge",
|
||||
"N": 16384,
|
||||
"r": 8,
|
||||
"p": 1,
|
||||
"bits": 6,
|
||||
"valid_nonce": "17",
|
||||
"hash_hex": "008fe949e117a88520a7926fad815af49b1f92b543cd88d83240d2e7a4521bb2",
|
||||
"invalid_nonce": "18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** A key in the tk1 shape for a payload of the test's choosing; the signature is never checked by TREK. */
|
||||
export function serviceKeyTokenFor(payload: { i: string; iat: number; exp: number; q?: string }): string {
|
||||
const body = Buffer.from(JSON.stringify({ q: 'std', ...payload })).toString('base64url');
|
||||
return `tk1.test.${body}.c2lnbmF0dXJl`;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { deriveHttpBoot, deriveKitinerary, deriveStorage, deriveTransit } from '../../../src/app-config/boot-derive';
|
||||
import {
|
||||
deriveHttpBoot,
|
||||
deriveKitinerary,
|
||||
deriveServices,
|
||||
deriveStorage,
|
||||
deriveTransit,
|
||||
} from '../../../src/app-config/boot-derive';
|
||||
import {
|
||||
deriveApp,
|
||||
deriveHttp,
|
||||
@@ -278,6 +284,30 @@ describe('deriveKitinerary (kitineraryConfig token)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveServices (servicesConfig token)', () => {
|
||||
it('registers by itself unless TREK_SERVICES says off, and any value but auto is off', () => {
|
||||
expect(deriveServices({}).mode).toBe('auto');
|
||||
expect(deriveServices({ TREK_SERVICES: ' AUTO ' }).mode).toBe('auto');
|
||||
expect(deriveServices({ TREK_SERVICES: 'off' }).mode).toBe('off');
|
||||
expect(deriveServices({ TREK_SERVICES: 'of' }).mode).toBe('off');
|
||||
});
|
||||
|
||||
it('takes a key from TREK_SERVICES_KEY, trimmed, and no key for a blank one', () => {
|
||||
expect(deriveServices({ TREK_SERVICES_KEY: ' tk1.k.p.s ' }).key).toBe('tk1.k.p.s');
|
||||
expect(deriveServices({ TREK_SERVICES_KEY: ' ' }).key).toBeNull();
|
||||
expect(deriveServices({}).key).toBeNull();
|
||||
});
|
||||
|
||||
it('sends the key to places.liketrek.com unless TREK_SERVICES_HOSTS names others', () => {
|
||||
expect(deriveServices({}).hosts).toEqual(['places.liketrek.com']);
|
||||
expect(deriveServices({ TREK_SERVICES_HOSTS: 'Places.LikeTrek.com, , photon.liketrek.com' }).hosts).toEqual([
|
||||
'places.liketrek.com',
|
||||
'photon.liketrek.com',
|
||||
]);
|
||||
expect(deriveServices({ TREK_SERVICES_HOSTS: ' , ' }).hosts).toEqual(['places.liketrek.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveFiles (#1364)', () => {
|
||||
it('defaults the upload limit to 50 MB and takes FILE_UPLOAD_LIMIT_MB', () => {
|
||||
expect(deriveFiles({}).uploadLimitMb).toBe(50);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
csvListFiltered,
|
||||
decodeBase64Url,
|
||||
isP256PrivateKey,
|
||||
isServiceKeyShape,
|
||||
isUncompressedP256Key,
|
||||
isVapidSubject,
|
||||
nonNegativeIntOr,
|
||||
@@ -237,3 +238,16 @@ describe('Web Push key material', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isServiceKeyShape', () => {
|
||||
it('accepts tk1.<kid>.<payload>.<signature> with base64url parts, surrounding whitespace included', () => {
|
||||
expect(isServiceKeyShape('tk1.test1.eyJpIjoiYSJ9.UyQG_zH-wmtY')).toBe(true);
|
||||
expect(isServiceKeyShape(' tk1.k.p.s\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses another version, a part too many or too few, and characters outside base64url', () => {
|
||||
for (const bad of ['tk2.k.p.s', 'tk1.k.p', 'tk1.k.p.s.x', 'tk1.k.p+.s', 'tk1..p.s', 'Bearer tk1.k.p.s', '']) {
|
||||
expect(isServiceKeyShape(bad), bad).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('the derived key lists', () => {
|
||||
'admin_ntfy_token',
|
||||
'admin_webhook_url',
|
||||
'oidc_client_secret',
|
||||
'service_key',
|
||||
'smtp_pass',
|
||||
]);
|
||||
expect((APP_SETTINGS.smtp_pass as AppSettingDef).encrypted).toBe('always');
|
||||
|
||||
@@ -39,12 +39,14 @@ describe('managed key assignment', () => {
|
||||
expect(MANAGED_CUSTOMER_KEYS.filter((k) => !SOURCE_KEYS.includes(k))).toEqual([]);
|
||||
});
|
||||
|
||||
it('MANAGED-KEYS-003: the only locked names outside both source lists are the users columns', () => {
|
||||
it('MANAGED-KEYS-003: the only locked names outside both source lists are the users columns and the service key', () => {
|
||||
// service_key is written by the server alone, never through a settings
|
||||
// form; it is locked so a managed install keeps the operator's key.
|
||||
expect(
|
||||
MANAGED_LOCKED_SETTING_KEYS.filter((k) => !SOURCE_KEYS.includes(k))
|
||||
.slice()
|
||||
.sort(),
|
||||
).toEqual([...MANAGED_LOCKED_PROFILE_KEYS].sort());
|
||||
).toEqual([...MANAGED_LOCKED_PROFILE_KEYS, 'service_key'].sort());
|
||||
});
|
||||
|
||||
it('MANAGED-KEYS-004: neither set repeats itself', () => {
|
||||
@@ -67,6 +69,7 @@ describe('managed key assignment', () => {
|
||||
'oidc_registration',
|
||||
'openweather_api_key',
|
||||
'routing_base_url',
|
||||
'service_key',
|
||||
'smtp_from',
|
||||
'smtp_host',
|
||||
'smtp_pass',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* IssuerClient: the four calls to the key issuer, each bounded in time and
|
||||
* size, each answer read through its schema, and every refusal turned into
|
||||
* an IssuerError that says how long to wait.
|
||||
*/
|
||||
import { IssuerClient, IssuerError } from '../../../../src/nest/service-identity/issuer.client';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { safeFetch } = vi.hoisted(() => ({ safeFetch: vi.fn() }));
|
||||
vi.mock('../../../../src/utils/ssrfGuard', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/ssrfGuard')>()),
|
||||
safeFetch,
|
||||
}));
|
||||
|
||||
const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
|
||||
|
||||
const GRANT = { key: 'tk1.a.b.c', exp: 1_800_776_000, renew_after: 1_798_184_000 };
|
||||
const issuer = new IssuerClient();
|
||||
|
||||
beforeEach(() => safeFetch.mockReset());
|
||||
|
||||
function lastCall(): { url: string; init: RequestInit & { headers: Record<string, string> }; options: unknown } {
|
||||
const [url, init, options] = safeFetch.mock.calls.at(-1) ?? [];
|
||||
return { url, init, options };
|
||||
}
|
||||
|
||||
describe('IssuerClient', () => {
|
||||
it('SIISS-001: asks for a challenge, with a deadline and a small size cap', async () => {
|
||||
const challenge = { c: 'v1.x', alg: 'scrypt', N: 16_384, r: 8, p: 1, bits: 6, expires_at: 1_793_000_600 };
|
||||
safeFetch.mockResolvedValueOnce(json(challenge, 200, { Date: 'Thu, 29 Oct 2026 12:00:00 GMT' }));
|
||||
const answer = await issuer.challenge();
|
||||
expect(answer).toEqual({ body: challenge, serverMs: Date.parse('2026-10-29T12:00:00Z') });
|
||||
const { url, init, options } = lastCall();
|
||||
expect(url).toBe('https://places.liketrek.com/v1/register/challenge');
|
||||
expect(init.method).toBe('GET');
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(init.headers['User-Agent']).toMatch(/^TREK\//);
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
expect(options).toEqual({ maxBytes: 4096 });
|
||||
});
|
||||
|
||||
it('SIISS-002: registers with the solved challenge and the version as JSON', async () => {
|
||||
safeFetch.mockResolvedValueOnce(json(GRANT, 201));
|
||||
expect((await issuer.register({ c: 'v1.x', nonce: '17', v: '5.1.0' })).body).toEqual(GRANT);
|
||||
const { url, init } = lastCall();
|
||||
expect(url).toBe('https://places.liketrek.com/v1/register');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
expect(JSON.parse(String(init.body))).toEqual({ c: 'v1.x', nonce: '17', v: '5.1.0' });
|
||||
});
|
||||
|
||||
it('SIISS-003: renews and reads the allowance with the key as bearer, the legacy pool without one', async () => {
|
||||
safeFetch.mockResolvedValueOnce(json(GRANT));
|
||||
await issuer.renew('tk1.old.key.sig');
|
||||
expect(lastCall().url).toBe('https://places.liketrek.com/v1/register/renew');
|
||||
expect(lastCall().init.headers.Authorization).toBe('Bearer tk1.old.key.sig');
|
||||
|
||||
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(lastCall().init.headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
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);
|
||||
expect(err).toBeInstanceOf(IssuerError);
|
||||
expect(err).toMatchObject({ status: 429, retryAfterMs: 90_000 });
|
||||
});
|
||||
|
||||
it('SIISS-005: refuses an answer that does not fit its schema', async () => {
|
||||
safeFetch.mockResolvedValueOnce(json({ key: 42 }));
|
||||
await expect(issuer.renew('tk1.a.b.c')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
isAllowlisted,
|
||||
keyFingerprint,
|
||||
readQuotaHeaders,
|
||||
retryAfterMs,
|
||||
sameServiceUrl,
|
||||
serverDateMs,
|
||||
} from '../../../../src/nest/service-identity/service-identity.helpers';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('sameServiceUrl', () => {
|
||||
it.each([
|
||||
['https://places.liketrek.com', 'https://places.liketrek.com'],
|
||||
['https://places.liketrek.com/', 'https://places.liketrek.com'],
|
||||
['HTTPS://Places.LikeTrek.com//', 'https://places.liketrek.com'],
|
||||
['http://places.liketrek.com', 'https://places.liketrek.com'],
|
||||
['places.liketrek.com', 'https://places.liketrek.com'],
|
||||
[' https://places.liketrek.com ', 'https://places.liketrek.com'],
|
||||
])('SIHELP-001: reads %s as the public instance', (a, b) => {
|
||||
expect(sameServiceUrl(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://places.example.org', 'https://places.liketrek.com'],
|
||||
['https://places.liketrek.com:8443', 'https://places.liketrek.com'],
|
||||
['https://places.liketrek.com/mirror', 'https://places.liketrek.com'],
|
||||
['', 'https://places.liketrek.com'],
|
||||
['http://[broken', 'https://places.liketrek.com'],
|
||||
])('SIHELP-002: reads %s as another service', (a, b) => {
|
||||
expect(sameServiceUrl(a, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowlisted', () => {
|
||||
const hosts = ['places.liketrek.com'];
|
||||
|
||||
it('SIHELP-010: lets the key go to a listed host over https only', () => {
|
||||
expect(isAllowlisted(new URL('https://places.liketrek.com/v1/search'), hosts)).toBe(true);
|
||||
expect(isAllowlisted(new URL('https://PLACES.liketrek.com/v1/search'), hosts)).toBe(true);
|
||||
expect(isAllowlisted(new URL('http://places.liketrek.com/v1/search'), hosts)).toBe(false);
|
||||
expect(isAllowlisted(new URL('https://places.liketrek.com.evil.example/v1'), hosts)).toBe(false);
|
||||
expect(isAllowlisted(new URL('https://mirror.example.org/v1/search'), hosts)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readQuotaHeaders', () => {
|
||||
it('SIHELP-020: reads tier and allowance, with the reset as an instant', () => {
|
||||
const headers = new Headers({
|
||||
'X-TREK-Tier': 'std',
|
||||
'X-TREK-Quota-Limit': '10000',
|
||||
'X-TREK-Quota-Remaining': '9960',
|
||||
'X-TREK-Quota-Reset': '1793059200',
|
||||
});
|
||||
expect(readQuotaHeaders(headers)).toEqual({
|
||||
tier: 'std',
|
||||
quota: { limit: 10_000, used: 40, remaining: 9_960, resetAt: new Date(1_793_059_200_000).toISOString() },
|
||||
});
|
||||
});
|
||||
|
||||
it('SIHELP-021: keeps a tier without numbers, and numbers without a tier it knows', () => {
|
||||
expect(readQuotaHeaders(new Headers({ 'X-TREK-Tier': 'legacy' }))).toEqual({ tier: 'legacy', quota: null });
|
||||
const numbers = new Headers({
|
||||
'X-TREK-Tier': 'gold',
|
||||
'X-TREK-Quota-Limit': '5',
|
||||
'X-TREK-Quota-Remaining': '9',
|
||||
'X-TREK-Quota-Reset': '1',
|
||||
});
|
||||
expect(readQuotaHeaders(numbers)?.quota).toMatchObject({ limit: 5, used: 0, remaining: 9 });
|
||||
expect(readQuotaHeaders(numbers)?.tier).toBeNull();
|
||||
});
|
||||
|
||||
it('SIHELP-022: answers null for an answer without them, or with numbers that are none', () => {
|
||||
expect(readQuotaHeaders(new Headers())).toBeNull();
|
||||
expect(readQuotaHeaders(new Headers({ 'X-TREK-Quota-Limit': '-1', 'X-TREK-Quota-Remaining': '1e3' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryAfterMs and serverDateMs', () => {
|
||||
it('SIHELP-030: reads Retry-After in seconds and as a date', () => {
|
||||
expect(retryAfterMs(new Headers({ 'Retry-After': '120' }))).toBe(120_000);
|
||||
const now = Date.parse('2026-10-29T12:00:00Z');
|
||||
expect(retryAfterMs(new Headers({ 'Retry-After': 'Thu, 29 Oct 2026 12:05:00 GMT' }), now)).toBe(300_000);
|
||||
expect(retryAfterMs(new Headers({ 'Retry-After': 'Thu, 29 Oct 2026 11:00:00 GMT' }), now)).toBe(0);
|
||||
expect(retryAfterMs(new Headers({ 'Retry-After': 'soon' }))).toBeNull();
|
||||
expect(retryAfterMs(new Headers())).toBeNull();
|
||||
});
|
||||
|
||||
it('SIHELP-031: reads the Date header, or nothing', () => {
|
||||
expect(serverDateMs(new Headers({ Date: 'Thu, 29 Oct 2026 12:00:00 GMT' }))).toBe(
|
||||
Date.parse('2026-10-29T12:00:00Z'),
|
||||
);
|
||||
expect(serverDateMs(new Headers({ Date: 'yesterday' }))).toBeNull();
|
||||
expect(serverDateMs(new Headers())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyFingerprint', () => {
|
||||
it('SIHELP-040: names a key without containing it', () => {
|
||||
const token = 'tk1.kid.payload.signature';
|
||||
expect(keyFingerprint(token)).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(keyFingerprint(token)).toBe(keyFingerprint(token));
|
||||
expect(keyFingerprint(token)).not.toBe(keyFingerprint(`${token}x`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { IssuerClient } from '../../../../src/nest/service-identity/issuer.client';
|
||||
import { QuotaTracker } from '../../../../src/nest/service-identity/quota-tracker';
|
||||
import { ServiceClock } from '../../../../src/nest/service-identity/service-clock';
|
||||
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 { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { serviceKeyTokenFor } from '../../../helpers/service-keys';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TOKEN = serviceKeyTokenFor({ i: 'installation-a', iat: 1_793_000_000, exp: 1_800_776_000, q: 'hosted' });
|
||||
const CONNECTED: KeySnapshot = { state: 'connected', key: parseServiceKey(TOKEN), source: 'registered', bearer: TOKEN };
|
||||
const QUOTA = { tier: 'std', limit: 10_000, used: 250, remaining: 9_750, reset_at: 1_793_059_200, per_minute: 120 };
|
||||
|
||||
const keys = { snapshot: vi.fn(async (): Promise<KeySnapshot> => CONNECTED), regenerate: vi.fn(async () => undefined) };
|
||||
const issuer = { quota: vi.fn() };
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let identity: ServiceIdentityService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
issuer.quota.mockResolvedValue({ body: QUOTA, serverMs: null });
|
||||
t = await createTestModule({
|
||||
db: testDb,
|
||||
imports: [],
|
||||
providers: [
|
||||
ServiceIdentityService,
|
||||
QuotaTracker,
|
||||
ServiceClock,
|
||||
{ provide: ServiceKeyService, useValue: keys },
|
||||
{ provide: IssuerClient, useValue: issuer },
|
||||
],
|
||||
});
|
||||
identity = t.get(ServiceIdentityService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await t.close();
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
describe('ServiceIdentityService.status', () => {
|
||||
it('SISVC-001: a connected installation shows the allowance the issuer reports for its key', async () => {
|
||||
expect(await identity.status()).toEqual({
|
||||
state: 'connected',
|
||||
tier: 'std',
|
||||
keySource: 'registered',
|
||||
quota: { limit: 10_000, used: 250, remaining: 9_750, resetAt: new Date(1_793_059_200_000).toISOString() },
|
||||
lastSeenAt: null,
|
||||
});
|
||||
expect(issuer.quota).toHaveBeenCalledWith(TOKEN);
|
||||
});
|
||||
|
||||
it('SISVC-002: asks the issuer once per 20 seconds, however often the card refreshes', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
await identity.status();
|
||||
await identity.status();
|
||||
expect(issuer.quota).toHaveBeenCalledTimes(1);
|
||||
vi.setSystemTime(Date.now() + 21_000);
|
||||
await identity.status();
|
||||
expect(issuer.quota).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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' } });
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(Date.now() + 21_000);
|
||||
expect(await identity.status()).toMatchObject({ tier: 'std', quota: { limit: 5 }, lastSeenAt: expect.any(String) });
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
keys.snapshot.mockResolvedValueOnce({ state: 'off', key: null, source: null, bearer: null });
|
||||
expect(await identity.status()).toEqual({
|
||||
state: 'off',
|
||||
tier: null,
|
||||
keySource: null,
|
||||
quota: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
expect(issuer.quota).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SISVC-005: a new identity drops the cached allowance and answers the new status', async () => {
|
||||
await identity.status();
|
||||
keys.snapshot.mockResolvedValueOnce({ state: 'registering', key: null, source: null, bearer: null });
|
||||
const after = await identity.regenerate();
|
||||
expect(keys.regenerate).toHaveBeenCalledTimes(1);
|
||||
expect(after.state).toBe('registering');
|
||||
expect(issuer.quota).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The tk1 key format, checked against the vectors the issuer is tested with
|
||||
* too (tests/fixtures/service-identity/tk1-vectors.json), so TREK and the gate
|
||||
* read the same bytes the same way.
|
||||
*/
|
||||
import { parseServiceKey } from '../../../../src/nest/service-identity/service-key.format';
|
||||
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface Vector {
|
||||
name: string;
|
||||
token: string;
|
||||
valid: boolean;
|
||||
payload?: { i: string; iat: number; exp: number; q: string };
|
||||
payload_json?: string;
|
||||
}
|
||||
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', '..', 'fixtures', 'service-identity', 'tk1-vectors.json'), 'utf8'),
|
||||
) as { kid: string; public_key_b64url: string; vectors: Vector[] };
|
||||
|
||||
const issuerKey = createPublicKey({
|
||||
key: { kty: 'OKP', crv: 'Ed25519', x: fixture.public_key_b64url },
|
||||
format: 'jwk',
|
||||
});
|
||||
|
||||
/** What the gate checks: the issuer's signature over `tk1.<kid>.<payload>`, for a kid it knows. */
|
||||
function gateAccepts(token: string): boolean {
|
||||
const [version, kid, payload, signature] = token.split('.');
|
||||
if (version !== 'tk1' || kid !== fixture.kid) return false;
|
||||
return verify(null, Buffer.from(`tk1.${kid}.${payload}`), issuerKey, Buffer.from(signature, 'base64url'));
|
||||
}
|
||||
|
||||
const valid = fixture.vectors.filter((v) => v.valid);
|
||||
const invalid = fixture.vectors.filter((v) => !v.valid);
|
||||
|
||||
describe('parseServiceKey against the shared vectors', () => {
|
||||
it.each(valid.map((v) => [v.name, v] as const))(
|
||||
'SIKEY-001: reads the %s vector as the issuer wrote it',
|
||||
(_, vector) => {
|
||||
const key = parseServiceKey(vector.token);
|
||||
expect(key).not.toBeNull();
|
||||
expect(key?.kid).toBe(fixture.kid);
|
||||
expect(key?.payload).toEqual(vector.payload);
|
||||
const body = vector.token.split('.')[2];
|
||||
expect(Buffer.from(body, 'base64url').toString('utf8')).toBe(vector.payload_json);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(valid.map((v) => [v.name, v.token] as const))(
|
||||
'SIKEY-002: the %s vector is signed as the format says',
|
||||
(_, token) => {
|
||||
expect(gateAccepts(token)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(invalid.map((v) => [v.name, v.token] as const))(
|
||||
'SIKEY-003: the %s vector fails the signature check',
|
||||
(_, token) => {
|
||||
expect(gateAccepts(token)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('SIKEY-004: leaves the signature to the gate, so a tampered key still reads', () => {
|
||||
const tampered = invalid.find((v) => v.name === 'tampered_payload')?.token ?? '';
|
||||
expect(parseServiceKey(tampered)?.payload.q).toBe('own');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseServiceKey on anything else', () => {
|
||||
const body = Buffer.from(JSON.stringify({ i: 'AAECAwQFBgcICQoLDA0ODw', iat: 1, exp: 2, q: 'std' })).toString(
|
||||
'base64url',
|
||||
);
|
||||
|
||||
it.each([
|
||||
['another version', `tk2.kid.${body}.sig`],
|
||||
['three parts', `tk1.kid.${body}`],
|
||||
['five parts', `tk1.kid.${body}.sig.extra`],
|
||||
['a part outside base64url', `tk1.k+d.${body}.sig`],
|
||||
['a payload that is no JSON', 'tk1.kid.bm90LWpzb24.sig'],
|
||||
['a payload without an expiry', `tk1.kid.${Buffer.from('{"i":"a","iat":1,"q":"std"}').toString('base64url')}.sig`],
|
||||
['nothing', ''],
|
||||
])('SIKEY-010: refuses %s', (_, token) => {
|
||||
expect(parseServiceKey(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('SIKEY-011: tolerates the whitespace an environment file leaves around it', () => {
|
||||
expect(parseServiceKey(` tk1.kid.${body}.sig\n`)?.token).toBe(`tk1.kid.${body}.sig`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* ServiceKeyJob: one daily renewal check through the registrar, at a minute
|
||||
* and hour of its own, and nothing at all where the registrar schedules
|
||||
* nothing (NODE_ENV=test).
|
||||
*/
|
||||
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
||||
import { ServiceKeyJob } from '../../../../src/nest/service-identity/service-key.job';
|
||||
import { ServiceKeyService } from '../../../../src/nest/service-identity/service-key.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const registrar = { isEnabled: vi.fn(() => true), register: vi.fn() };
|
||||
const keys = { renewIfDue: vi.fn(async () => undefined) };
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db: testDb,
|
||||
imports: [],
|
||||
providers: [
|
||||
ServiceKeyJob,
|
||||
{ provide: CronRegistrarService, useValue: registrar },
|
||||
{ provide: ServiceKeyService, useValue: keys },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ServiceKeyJob', () => {
|
||||
it('SIJOB-001: registers a daily check at a minute and hour of its own, which renews when due', async () => {
|
||||
t.get(ServiceKeyJob).onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledTimes(1);
|
||||
const [name, expression, tick] = registrar.register.mock.calls[0] as [string, string, () => Promise<void>];
|
||||
expect(name).toBe('service-key-renewal');
|
||||
const [minute, hour, ...rest] = expression.split(' ');
|
||||
expect(Number(minute)).toBeGreaterThanOrEqual(0);
|
||||
expect(Number(minute)).toBeLessThan(60);
|
||||
expect(Number(hour)).toBeLessThan(24);
|
||||
expect(rest).toEqual(['*', '*', '*']);
|
||||
await tick();
|
||||
expect(keys.renewIfDue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIJOB-002: schedules nothing where the registrar is off', () => {
|
||||
registrar.isEnabled.mockReturnValueOnce(false);
|
||||
t.get(ServiceKeyJob).onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The registration proof of work, against the vector the issuer checks with:
|
||||
* scrypt(c + "." + nonce, "trek-pow-v1", N, r, p, 32 bytes), at least `bits`
|
||||
* leading zero bits.
|
||||
*/
|
||||
import { leadingZeroBits, powHash, solvePow } from '../../../../src/nest/service-identity/service-key.pow';
|
||||
import type { ServiceChallenge } from '@trek/shared';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const { pow } = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', '..', 'fixtures', 'service-identity', 'tk1-vectors.json'), 'utf8'),
|
||||
) as {
|
||||
pow: {
|
||||
c: string;
|
||||
N: number;
|
||||
r: number;
|
||||
p: number;
|
||||
bits: number;
|
||||
valid_nonce: string;
|
||||
hash_hex: string;
|
||||
invalid_nonce: string;
|
||||
};
|
||||
};
|
||||
|
||||
describe('powHash against the shared vector', () => {
|
||||
it('SIPOW-001: hashes the valid nonce to the published hash, which meets the target', async () => {
|
||||
const hash = await powHash(pow.c, pow.valid_nonce, pow);
|
||||
expect(hash.toString('hex')).toBe(pow.hash_hex);
|
||||
expect(leadingZeroBits(hash)).toBeGreaterThanOrEqual(pow.bits);
|
||||
});
|
||||
|
||||
it('SIPOW-002: the next nonce misses the target', async () => {
|
||||
expect(leadingZeroBits(await powHash(pow.c, pow.invalid_nonce, pow))).toBeLessThan(pow.bits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leadingZeroBits', () => {
|
||||
it.each([
|
||||
[[0x80], 0],
|
||||
[[0x01], 7],
|
||||
[[0x00, 0x40], 9],
|
||||
[[0x00, 0x00], 16],
|
||||
])('SIPOW-010: counts %j as %i', (bytes, bits) => {
|
||||
expect(leadingZeroBits(Uint8Array.from(bytes))).toBe(bits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('solvePow', () => {
|
||||
const cheap: ServiceChallenge = { c: 'v1.cheap', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 4, expires_at: 1 };
|
||||
|
||||
it('SIPOW-020: finds the first nonce that meets the target, at low difficulty', async () => {
|
||||
const nonce = await solvePow(cheap, Date.now() + 60_000);
|
||||
expect(nonce).toMatch(/^\d+$/);
|
||||
expect(leadingZeroBits(await powHash(cheap.c, nonce, cheap))).toBeGreaterThanOrEqual(cheap.bits);
|
||||
for (let earlier = 0; earlier < Number(nonce); earlier++) {
|
||||
expect(leadingZeroBits(await powHash(cheap.c, String(earlier), cheap))).toBeLessThan(cheap.bits);
|
||||
}
|
||||
});
|
||||
|
||||
it('SIPOW-021: gives up once the challenge has expired', async () => {
|
||||
await expect(solvePow({ ...cheap, bits: 24 }, Date.now() - 1)).rejects.toThrow(/expired/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* ServiceKeyService: when an installation registers, what it sends, how it
|
||||
* keeps, renews and gives up its key, and that nothing of it ever makes a
|
||||
* request wait. The store and the issuer are fakes; the clock, the proof of
|
||||
* work and the key format are the real ones.
|
||||
*/
|
||||
import { runningVersion } from '../../../../src/app-config';
|
||||
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
|
||||
import { servicesConfig } from '../../../../src/nest/app-config/tokens';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { IssuerClient, IssuerError } from '../../../../src/nest/service-identity/issuer.client';
|
||||
import { ServiceClock } from '../../../../src/nest/service-identity/service-clock';
|
||||
import { keyFingerprint } from '../../../../src/nest/service-identity/service-identity.helpers';
|
||||
import { ServiceKeyService } from '../../../../src/nest/service-identity/service-key.service';
|
||||
import {
|
||||
EMPTY_KEY_STATE,
|
||||
ServiceKeyStore,
|
||||
type KeyState,
|
||||
type StoredIdentity,
|
||||
} from '../../../../src/nest/service-identity/service-key.store';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { serviceKeyTokenFor } from '../../../helpers/service-keys';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const DAY = 86_400;
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
const KEY = serviceKeyTokenFor({ i: 'installation-a', iat: now(), exp: now() + 90 * DAY });
|
||||
const OTHER_KEY = serviceKeyTokenFor({ i: 'installation-b', iat: now(), exp: now() + 90 * DAY });
|
||||
const RENEWED_KEY = serviceKeyTokenFor({ i: 'installation-a', iat: now(), exp: now() + 180 * DAY });
|
||||
|
||||
function fakeStore(initial: Partial<StoredIdentity> = {}) {
|
||||
let stored: StoredIdentity = { key: null, unreadable: false, state: EMPTY_KEY_STATE, ...initial };
|
||||
let raced: StoredIdentity['key'] = null;
|
||||
return {
|
||||
current: () => stored,
|
||||
/** The next claim finds this key already stored by another process. */
|
||||
raceWith: (key: StoredIdentity['key']) => {
|
||||
raced = key;
|
||||
},
|
||||
read: vi.fn(async () => stored),
|
||||
claim: vi.fn(async (key: NonNullable<StoredIdentity['key']>, state: KeyState) => {
|
||||
if (raced) stored = { key: raced, unreadable: false, state };
|
||||
else if (!stored.key && !stored.unreadable) stored = { key, unreadable: false, state };
|
||||
return stored;
|
||||
}),
|
||||
replace: vi.fn(async (key: NonNullable<StoredIdentity['key']>, state: KeyState) => {
|
||||
stored = { key, unreadable: false, state };
|
||||
}),
|
||||
writeState: vi.fn(async (state: KeyState) => {
|
||||
stored = { ...stored, state };
|
||||
}),
|
||||
dropKey: vi.fn(async (state: KeyState) => {
|
||||
stored = { key: null, unreadable: false, state };
|
||||
}),
|
||||
clear: vi.fn(async () => {
|
||||
stored = { key: null, unreadable: false, state: EMPTY_KEY_STATE };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeIssuer(issued = KEY) {
|
||||
const serverMs = () => Date.now();
|
||||
return {
|
||||
challenge: vi.fn(async () => ({
|
||||
body: { c: 'v1.challenge', alg: 'scrypt' as const, N: 2, r: 1, p: 1, bits: 0, expires_at: now() + 600 },
|
||||
serverMs: serverMs(),
|
||||
})),
|
||||
register: vi.fn(async () => ({
|
||||
body: { key: issued, exp: now() + 90 * DAY, renew_after: now() + 60 * DAY },
|
||||
serverMs: serverMs(),
|
||||
})),
|
||||
renew: vi.fn(async () => ({
|
||||
body: { key: RENEWED_KEY, exp: now() + 180 * DAY, renew_after: now() + 150 * DAY },
|
||||
serverMs: serverMs(),
|
||||
})),
|
||||
quota: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestModule | null = null;
|
||||
|
||||
interface Setup {
|
||||
config?: Partial<{ mode: 'auto' | 'off'; key: string | null; hosts: string[] }>;
|
||||
places?: { enabled?: boolean; url?: string };
|
||||
isTest?: boolean;
|
||||
store?: ReturnType<typeof fakeStore>;
|
||||
issuer?: ReturnType<typeof fakeIssuer>;
|
||||
}
|
||||
|
||||
async function build(setup: Setup = {}) {
|
||||
const store = setup.store ?? fakeStore();
|
||||
const issuer = setup.issuer ?? fakeIssuer();
|
||||
const config = { mode: 'auto' as const, key: null, hosts: ['places.liketrek.com'], ...setup.config };
|
||||
const maps = { trekPlacesEnabled: setup.places?.enabled ?? true, trekPlacesUrl: setup.places?.url };
|
||||
t = await createTestModule({
|
||||
db: testDb,
|
||||
imports: [],
|
||||
providers: [
|
||||
ServiceKeyService,
|
||||
ServiceClock,
|
||||
{ provide: ServiceKeyStore, useValue: store },
|
||||
{ provide: IssuerClient, useValue: issuer },
|
||||
{ provide: servicesConfig.KEY, useValue: config },
|
||||
{ provide: RuntimeEnvService, useValue: { env: () => ({ maps }), isTest: () => setup.isTest ?? false } },
|
||||
],
|
||||
});
|
||||
return { keys: t.get(ServiceKeyService), store, issuer };
|
||||
}
|
||||
|
||||
/** Lets the background work of the service run to its end. */
|
||||
const settled = (keys: ServiceKeyService) => keys.whenIdle();
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await t?.close();
|
||||
t = null;
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
describe('registration', () => {
|
||||
it('SIKEYS-001: the first request goes out without a key, and the installation registers in the background', async () => {
|
||||
const { keys, store, issuer } = await build();
|
||||
expect(keys.bearer()).toBeNull();
|
||||
await settled(keys);
|
||||
expect(issuer.register).toHaveBeenCalledWith({ c: 'v1.challenge', nonce: '0', v: runningVersion() });
|
||||
expect(store.claim).toHaveBeenCalledTimes(1);
|
||||
expect(store.current().state.renewAfter).toBeGreaterThan(now());
|
||||
expect(keys.bearer()).toBe(KEY);
|
||||
});
|
||||
|
||||
it('SIKEYS-002: registers once however many requests ask at the same time', async () => {
|
||||
const { keys, issuer } = await build();
|
||||
for (let i = 0; i < 5; i++) keys.bearer();
|
||||
await settled(keys);
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIKEYS-003: keeps the key another process stored first', async () => {
|
||||
const store = fakeStore();
|
||||
const { keys } = await build({ store });
|
||||
store.raceWith({
|
||||
token: OTHER_KEY,
|
||||
kid: 'test',
|
||||
payload: { i: 'installation-b', iat: now(), exp: now() + DAY, q: 'std' },
|
||||
});
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(keys.bearer()).toBe(OTHER_KEY);
|
||||
});
|
||||
|
||||
it('SIKEYS-004: registers anew without a word when the stored key cannot be read', async () => {
|
||||
const store = fakeStore({ unreadable: true });
|
||||
const { keys, issuer } = await build({ store });
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(issuer.register).toHaveBeenCalledTimes(1);
|
||||
expect(store.replace).toHaveBeenCalledTimes(1);
|
||||
expect(store.claim).not.toHaveBeenCalled();
|
||||
expect(keys.bearer()).toBe(KEY);
|
||||
});
|
||||
|
||||
it('SIKEYS-005: a compose line that was only uncommented still counts as the public service', async () => {
|
||||
const { keys, issuer } = await build({ places: { url: 'HTTPS://Places.LikeTrek.com/' } });
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(issuer.register).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIKEYS-006: backs off after a refusal, as long as the issuer asks', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const issuer = fakeIssuer();
|
||||
issuer.challenge.mockRejectedValueOnce(new IssuerError(429, 60_000, null));
|
||||
const { keys } = await build({ issuer });
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).toHaveBeenCalledTimes(1);
|
||||
vi.setSystemTime(Date.now() + 61_000);
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).toHaveBeenCalledTimes(2);
|
||||
expect(keys.bearer()).toBe(KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when it never registers', () => {
|
||||
it('SIKEYS-010: never under NODE_ENV=test', async () => {
|
||||
const { keys, issuer } = await build({ isTest: true });
|
||||
expect(keys.bearer()).toBeNull();
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).not.toHaveBeenCalled();
|
||||
expect((await keys.snapshot()).state).toBe('legacy');
|
||||
});
|
||||
|
||||
it('SIKEYS-011: never with the index switched off, nor with TREK_SERVICES=off', async () => {
|
||||
for (const setup of [{ places: { enabled: false } }, { config: { mode: 'off' as const } }]) {
|
||||
const { keys, issuer } = await build(setup);
|
||||
expect(keys.bearer()).toBeNull();
|
||||
expect((await keys.snapshot()).state).toBe('off');
|
||||
expect(issuer.challenge).not.toHaveBeenCalled();
|
||||
await t?.close();
|
||||
t = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('SIKEYS-012: never for a copy of the index the operator runs', async () => {
|
||||
const { keys, issuer } = await build({ places: { url: 'https://places.example.org' } });
|
||||
expect(keys.bearer()).toBeNull();
|
||||
expect((await keys.snapshot()).state).toBe('off');
|
||||
expect(issuer.challenge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SIKEYS-013: never for an allowlisted mirror, which gets the stored key but is no issuer', async () => {
|
||||
const store = fakeStore();
|
||||
const { keys, issuer } = await build({
|
||||
store,
|
||||
places: { url: 'https://mirror.example.org' },
|
||||
config: { hosts: ['places.liketrek.com', 'mirror.example.org'] },
|
||||
});
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).not.toHaveBeenCalled();
|
||||
expect((await keys.snapshot()).state).toBe('legacy');
|
||||
});
|
||||
|
||||
it('SIKEYS-014: TREK_SERVICES_KEY wins over the stored key and turns registration off', async () => {
|
||||
const store = fakeStore({
|
||||
key: { token: OTHER_KEY, kid: 'test', payload: { i: 'b', iat: 1, exp: now() + DAY, q: 'std' } },
|
||||
});
|
||||
const { keys, issuer } = await build({ store, config: { key: KEY } });
|
||||
expect(keys.bearer()).toBe(KEY);
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).not.toHaveBeenCalled();
|
||||
expect(await keys.snapshot()).toMatchObject({ state: 'connected', source: 'env', bearer: KEY });
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusals', () => {
|
||||
async function registered(setup: Setup = {}) {
|
||||
const built = await build(setup);
|
||||
built.keys.bearer();
|
||||
await settled(built.keys);
|
||||
expect(built.keys.bearer()).toBe(KEY);
|
||||
return built;
|
||||
}
|
||||
|
||||
it('SIKEYS-020: gives the key up only on the third 401 in a row, and asks again within six hours', async () => {
|
||||
const { keys, store, issuer } = await registered();
|
||||
keys.noteInvalid();
|
||||
keys.noteInvalid();
|
||||
keys.noteAccepted();
|
||||
keys.noteInvalid();
|
||||
keys.noteInvalid();
|
||||
expect(store.dropKey).not.toHaveBeenCalled();
|
||||
const before = Date.now();
|
||||
keys.noteInvalid();
|
||||
await settled(keys);
|
||||
expect(store.dropKey).toHaveBeenCalledTimes(1);
|
||||
const reregisterAt = store.current().state.reregisterAt ?? 0;
|
||||
expect(reregisterAt).toBeGreaterThanOrEqual(before);
|
||||
expect(reregisterAt).toBeLessThanOrEqual(Date.now() + 6 * 3_600_000);
|
||||
expect(keys.bearer()).toBeNull();
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).toHaveBeenCalledTimes(Date.now() >= reregisterAt ? 2 : 1);
|
||||
});
|
||||
|
||||
it('SIKEYS-021: a 401 without a key changes nothing', async () => {
|
||||
const { keys, store } = await build({ isTest: true });
|
||||
for (let i = 0; i < 5; i++) keys.noteInvalid();
|
||||
await settled(keys);
|
||||
expect(store.dropKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SIKEYS-022: a revoked key stops going out and stays given up until an admin starts over', async () => {
|
||||
const { keys, store, issuer } = await registered();
|
||||
keys.noteRevoked();
|
||||
await settled(keys);
|
||||
expect(store.current().state.revoked).toBe(keyFingerprint(KEY));
|
||||
expect(keys.bearer()).toBeNull();
|
||||
await settled(keys);
|
||||
expect(issuer.challenge).toHaveBeenCalledTimes(1);
|
||||
expect((await keys.snapshot()).state).toBe('revoked');
|
||||
|
||||
issuer.register.mockResolvedValueOnce({
|
||||
body: { key: OTHER_KEY, exp: now() + 90 * DAY, renew_after: now() + 60 * DAY },
|
||||
serverMs: Date.now(),
|
||||
});
|
||||
await keys.regenerate();
|
||||
await settled(keys);
|
||||
expect(store.clear).toHaveBeenCalledTimes(1);
|
||||
expect(keys.bearer()).toBe(OTHER_KEY);
|
||||
});
|
||||
|
||||
it('SIKEYS-023: an admin cannot start over a key that comes from the environment, nor with the services off', async () => {
|
||||
const env = await build({ config: { key: KEY } });
|
||||
await expect(env.keys.regenerate()).rejects.toBeInstanceOf(DomainError);
|
||||
await t?.close();
|
||||
const off = await build({ config: { mode: 'off' } });
|
||||
await expect(off.keys.regenerate()).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewal', () => {
|
||||
const oldKey = serviceKeyTokenFor({ i: 'installation-a', iat: now() - 61 * DAY, exp: now() + 29 * DAY });
|
||||
const stored = (state: Partial<KeyState> = {}) =>
|
||||
fakeStore({
|
||||
key: {
|
||||
token: oldKey,
|
||||
kid: 'test',
|
||||
payload: { i: 'installation-a', iat: now() - 61 * DAY, exp: now() + 29 * DAY, q: 'std' },
|
||||
},
|
||||
state: { ...EMPTY_KEY_STATE, ...state },
|
||||
});
|
||||
|
||||
it('SIKEYS-030: leaves a key alone before it is due', async () => {
|
||||
const { keys, issuer } = await build({ store: stored({ renewAfter: now() + DAY }) });
|
||||
await keys.renewIfDue();
|
||||
expect(issuer.renew).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SIKEYS-031: renews from day 60 with the old key, keeping the same installation', async () => {
|
||||
const store = stored();
|
||||
const { keys, issuer } = await build({ store });
|
||||
await keys.renewIfDue();
|
||||
expect(issuer.renew).toHaveBeenCalledWith(oldKey);
|
||||
expect(store.current().key?.token).toBe(RENEWED_KEY);
|
||||
expect(store.current().state.renewAfter).toBeGreaterThan(now() + 149 * DAY);
|
||||
expect(keys.bearer()).toBe(RENEWED_KEY);
|
||||
});
|
||||
|
||||
it('SIKEYS-032: reads due-ness on the issuer clock, not on a server clock that is behind', async () => {
|
||||
const store = stored({ renewAfter: now() + DAY });
|
||||
const issuer = fakeIssuer();
|
||||
const { keys } = await build({ store, issuer });
|
||||
t?.get(ServiceClock).learn(Date.now() + 2 * DAY * 1000);
|
||||
await keys.renewIfDue();
|
||||
expect(issuer.renew).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIKEYS-033: a refused renewal drops the key, a revoked one freezes it, a network failure changes nothing', async () => {
|
||||
const refused = fakeIssuer();
|
||||
refused.renew.mockRejectedValueOnce(new IssuerError(401, null, null));
|
||||
const first = await build({ store: stored(), issuer: refused });
|
||||
await first.keys.renewIfDue();
|
||||
expect(first.store.dropKey).toHaveBeenCalledTimes(1);
|
||||
await t?.close();
|
||||
|
||||
const revoked = fakeIssuer();
|
||||
revoked.renew.mockRejectedValueOnce(new IssuerError(403, null, null));
|
||||
const second = await build({ store: stored(), issuer: revoked });
|
||||
await second.keys.renewIfDue();
|
||||
expect(second.store.current().state.revoked).toBe(keyFingerprint(oldKey));
|
||||
await t?.close();
|
||||
|
||||
const down = fakeIssuer();
|
||||
down.renew.mockRejectedValueOnce(new Error('fetch failed'));
|
||||
const third = await build({ store: stored(), issuer: down });
|
||||
await expect(third.keys.renewIfDue()).rejects.toThrow('fetch failed');
|
||||
expect(third.store.current().key?.token).toBe(oldKey);
|
||||
});
|
||||
|
||||
it('SIKEYS-034: a key past its 30 days of grace registers anew instead', async () => {
|
||||
const lapsed = serviceKeyTokenFor({ i: 'installation-a', iat: now() - 130 * DAY, exp: now() - 40 * DAY });
|
||||
const store = fakeStore({
|
||||
key: {
|
||||
token: lapsed,
|
||||
kid: 'test',
|
||||
payload: { i: 'installation-a', iat: now() - 130 * DAY, exp: now() - 40 * DAY, q: 'std' },
|
||||
},
|
||||
});
|
||||
const { keys, issuer } = await build({ store });
|
||||
await keys.renewIfDue();
|
||||
expect(issuer.renew).not.toHaveBeenCalled();
|
||||
keys.bearer();
|
||||
await settled(keys);
|
||||
expect(store.replace).toHaveBeenCalledTimes(1);
|
||||
expect(keys.bearer()).toBe(KEY);
|
||||
});
|
||||
|
||||
it('SIKEYS-035: never renews a key from the environment', async () => {
|
||||
const { keys, issuer } = await build({ store: stored(), config: { key: KEY } });
|
||||
await keys.renewIfDue();
|
||||
expect(issuer.renew).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* ServiceKeyStore over the real SettingsService and a migrated database: the
|
||||
* key is stored encrypted, the first of two processes keeps its key, and a
|
||||
* row that cannot be opened reads as unreadable rather than failing.
|
||||
*/
|
||||
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
|
||||
import { encrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
|
||||
import { parseServiceKey, type ServiceKey } from '../../../../src/nest/service-identity/service-key.format';
|
||||
import { EMPTY_KEY_STATE, ServiceKeyStore } from '../../../../src/nest/service-identity/service-key.store';
|
||||
import { SettingsModule } from '../../../../src/nest/settings/settings.module';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { deleteRows, findRow, upsertRow } from '../../../helpers/factories/rows';
|
||||
import { serviceKeyTokenFor } from '../../../helpers/service-keys';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let store: ServiceKeyStore;
|
||||
|
||||
const keyFor = (i: string): ServiceKey => {
|
||||
const key = parseServiceKey(serviceKeyTokenFor({ i, iat: 1_793_000_000, exp: 1_800_776_000 }));
|
||||
if (!key) throw new Error('fixture key does not parse');
|
||||
return key;
|
||||
};
|
||||
|
||||
const rawValue = async (key: string) => (await findRow(orm, AppSettings, { key }))?.value ?? null;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db: testDb, imports: [SettingsModule], providers: [ServiceKeyStore] });
|
||||
orm = await createTestOrm(testDb);
|
||||
store = t.get(ServiceKeyStore);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
await orm.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await deleteRows(orm, AppSettings, { key: { $in: ['service_key', 'service_key_state'] } });
|
||||
});
|
||||
|
||||
describe('ServiceKeyStore', () => {
|
||||
it('SISTORE-001: reads nothing on a fresh install', async () => {
|
||||
expect(await store.read()).toEqual({ key: null, unreadable: false, state: EMPTY_KEY_STATE });
|
||||
});
|
||||
|
||||
it('SISTORE-002: keeps the key encrypted at rest and reads it back', async () => {
|
||||
const key = keyFor('installation-a');
|
||||
const state = { ...EMPTY_KEY_STATE, renewAfter: 1_798_184_000 };
|
||||
expect(await store.claim(key, state)).toEqual({ key, unreadable: false, state });
|
||||
const raw = await rawValue('service_key');
|
||||
expect(raw).toMatch(/^enc:v1:/);
|
||||
expect(raw).not.toContain(key.token);
|
||||
expect(JSON.parse((await rawValue('service_key_state')) ?? '{}')).toEqual(state);
|
||||
});
|
||||
|
||||
it('SISTORE-003: the first of two processes keeps its key', async () => {
|
||||
const first = keyFor('installation-a');
|
||||
await store.claim(first, EMPTY_KEY_STATE);
|
||||
const answer = await store.claim(keyFor('installation-b'), EMPTY_KEY_STATE);
|
||||
expect(answer.key?.token).toBe(first.token);
|
||||
});
|
||||
|
||||
it('SISTORE-004: a row the encryption key cannot open, or that is no key, reads as unreadable', async () => {
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key', value: 'enc:v1:bm90IGEgY2lwaGVydGV4dA==' });
|
||||
expect(await store.read()).toMatchObject({ key: null, unreadable: true });
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key', value: encrypt_api_key('not a key') });
|
||||
expect(await store.read()).toMatchObject({ key: null, unreadable: true });
|
||||
});
|
||||
|
||||
it('SISTORE-005: replace overwrites what is there, which claim never does', async () => {
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key', value: 'enc:v1:broken' });
|
||||
const key = keyFor('installation-c');
|
||||
await store.replace(key, EMPTY_KEY_STATE);
|
||||
expect((await store.read()).key?.token).toBe(key.token);
|
||||
});
|
||||
|
||||
it('SISTORE-006: dropping the key keeps the state that says when to ask again, clear removes both', async () => {
|
||||
await store.claim(keyFor('installation-a'), EMPTY_KEY_STATE);
|
||||
const state = { ...EMPTY_KEY_STATE, reregisterAt: 1_793_000_000_000 };
|
||||
await store.dropKey(state);
|
||||
expect(await store.read()).toEqual({ key: null, unreadable: false, state });
|
||||
await store.clear();
|
||||
expect(await rawValue('service_key_state')).toBeNull();
|
||||
});
|
||||
|
||||
it('SISTORE-007: a state row of another shape reads as the empty state, field by field', async () => {
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key_state', value: '{"revoked":7,"renewAfter":1}' });
|
||||
expect((await store.read()).state).toEqual({ ...EMPTY_KEY_STATE, renewAfter: 1 });
|
||||
await upsertRow(orm, AppSettings, { key: 'service_key_state', value: 'not json' });
|
||||
expect((await store.read()).state).toEqual(EMPTY_KEY_STATE);
|
||||
await store.writeState({ ...EMPTY_KEY_STATE, revoked: 'abc' });
|
||||
expect((await store.read()).state.revoked).toBe('abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The identity a request to a liketrek service carries, and what TREK takes
|
||||
* from the answer. The key goes to the allowlisted hosts over https and to
|
||||
* nothing else: not to a mirror of the index, not over plain http.
|
||||
*/
|
||||
import { servicesConfig } from '../../../../src/nest/app-config/tokens';
|
||||
import { resetTrekPlacesBreaker, trekPlacesSearch } from '../../../../src/nest/maps/trek-places.client';
|
||||
import { QuotaTracker } from '../../../../src/nest/service-identity/quota-tracker';
|
||||
import { ServiceClock } from '../../../../src/nest/service-identity/service-clock';
|
||||
import { serviceIdentitySlot } from '../../../../src/nest/service-identity/service-identity.interface';
|
||||
import { ServiceKeyService } from '../../../../src/nest/service-identity/service-key.service';
|
||||
import { ServiceRequestIdentity } from '../../../../src/nest/service-identity/service-request-identity';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const KEY = 'tk1.test.eyJpIjoiYSJ9.c2ln';
|
||||
const PLACES = new URL('https://places.liketrek.com/v1/search?q=x');
|
||||
|
||||
const keys = {
|
||||
bearer: vi.fn((): string | null => KEY),
|
||||
noteAccepted: vi.fn(),
|
||||
noteInvalid: vi.fn(),
|
||||
noteRevoked: vi.fn(),
|
||||
};
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let identity: ServiceRequestIdentity;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db: testDb,
|
||||
imports: [],
|
||||
providers: [
|
||||
ServiceRequestIdentity,
|
||||
QuotaTracker,
|
||||
ServiceClock,
|
||||
{ provide: ServiceKeyService, useValue: keys },
|
||||
{ provide: servicesConfig.KEY, useValue: { mode: 'auto', key: null, hosts: ['places.liketrek.com'] } },
|
||||
],
|
||||
});
|
||||
identity = t.get(ServiceRequestIdentity);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('headersFor', () => {
|
||||
it('SIREQ-001: names TREK and its version on every request', () => {
|
||||
for (const url of [PLACES, new URL('https://mirror.example.org/v1/search')]) {
|
||||
expect(identity.headersFor(url)['User-Agent']).toMatch(/^TREK\/\S+ \(\+https:\/\/github\.com\/liketrek\/TREK\)$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('SIREQ-002: carries the key to an allowlisted host over https', () => {
|
||||
expect(identity.headersFor(PLACES).Authorization).toBe(`Bearer ${KEY}`);
|
||||
});
|
||||
|
||||
it('SIREQ-003: never hands the key to another host or over plain http', () => {
|
||||
for (const url of ['https://mirror.example.org/v1/search', 'http://places.liketrek.com/v1/search']) {
|
||||
expect(identity.headersFor(new URL(url)).Authorization).toBeUndefined();
|
||||
}
|
||||
expect(keys.bearer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SIREQ-004: sends no Authorization while there is no key', () => {
|
||||
keys.bearer.mockReturnValueOnce(null);
|
||||
expect(identity.headersFor(PLACES)).not.toHaveProperty('Authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('observe', () => {
|
||||
const answer = (status: number, body = '{}', headers: Record<string, string> = {}) =>
|
||||
new Response(body, { status, headers });
|
||||
|
||||
it('SIREQ-010: keeps the allowance the answer states and its server clock', () => {
|
||||
identity.observe(
|
||||
PLACES,
|
||||
answer(200, '{}', {
|
||||
Date: new Date(Date.now() + 120_000).toUTCString(),
|
||||
'X-TREK-Tier': 'std',
|
||||
'X-TREK-Quota-Limit': '10000',
|
||||
'X-TREK-Quota-Remaining': '9000',
|
||||
'X-TREK-Quota-Reset': '1793059200',
|
||||
}),
|
||||
);
|
||||
expect(t.get(QuotaTracker).latest()).toMatchObject({ tier: 'std', quota: { limit: 10_000, used: 1_000 } });
|
||||
expect(t.get(ServiceClock).nowSeconds()).toBeGreaterThan(Math.floor(Date.now() / 1000) + 100);
|
||||
expect(keys.noteAccepted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIREQ-011: reports a 401 as a refused key', () => {
|
||||
identity.observe(PLACES, answer(401));
|
||||
expect(keys.noteInvalid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SIREQ-012: reports a 403 as revoked only when the answer says so', async () => {
|
||||
identity.observe(PLACES, answer(403, '{"error":"not_a_trek_instance"}'));
|
||||
identity.observe(PLACES, answer(403, '{"error":"key_revoked"}'));
|
||||
await vi.waitFor(() => expect(keys.noteRevoked).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('SIREQ-013: learns nothing from a host that is not allowlisted', () => {
|
||||
const before = t.get(QuotaTracker).latest();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('through the Places client', () => {
|
||||
const sent: Array<{ url: string; headers: Record<string, string> }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
sent.length = 0;
|
||||
resetTrekPlacesBreaker();
|
||||
serviceIdentitySlot.install(identity);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: URL, init: RequestInit) => {
|
||||
sent.push({ url: String(url), headers: init.headers as Record<string, string> });
|
||||
return new Response(JSON.stringify({ results: [] }), { status: 200 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
serviceIdentitySlot.release(identity);
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('SIREQ-020: the public index gets the key beside the instance token', async () => {
|
||||
vi.stubEnv('TREK_PLACES_URL', '');
|
||||
await trekPlacesSearch('harbour');
|
||||
expect(sent[0]?.headers).toMatchObject({ Authorization: `Bearer ${KEY}`, Accept: 'application/json' });
|
||||
expect(sent[0]?.headers['X-TREK-Instance']).toMatch(/^trek-/);
|
||||
});
|
||||
|
||||
it('SIREQ-021: a mirror the operator runs never sees the key', async () => {
|
||||
vi.stubEnv('TREK_PLACES_URL', 'https://places.example.org');
|
||||
await trekPlacesSearch('harbour');
|
||||
expect(sent[0]?.url).toContain('places.example.org');
|
||||
expect(sent[0]?.headers).not.toHaveProperty('Authorization');
|
||||
expect(sent[0]?.headers['User-Agent']).toMatch(/^TREK\//);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
serviceLang,
|
||||
type TrekPlace,
|
||||
} from '../../../src/nest/maps/trek-places.client';
|
||||
import { serviceIdentitySlot } from '../../../src/nest/service-identity/service-identity.interface';
|
||||
import { oneByOne } from '../../helpers/one-by-one';
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
@@ -460,6 +461,54 @@ describe('the circuit breaker', () => {
|
||||
stubFetch({ query: 'x', results: [PLACE] });
|
||||
await expect(trekPlacesSearch('x')).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a refused or revoked installation key is an answer, a 429 is not', async () => {
|
||||
// A 401 or 403 says the service is there and judged the key; the identity
|
||||
// falls back to the shared pool on its own. A 429 means the next request
|
||||
// costs the same and gets the same, like a server error.
|
||||
for (const status of [401, 403]) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
stubFetch({}, status);
|
||||
await expect(trekPlacesSearch('x')).rejects.toThrow(String(status));
|
||||
}
|
||||
}
|
||||
stubFetch({}, 429);
|
||||
for (let i = 0; i < 4; i++) await expect(trekPlacesSearch('x')).rejects.toThrow(/429/);
|
||||
await expect(trekPlacesSearch('x')).rejects.toThrow('unreachable, not retrying yet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the installation identity', () => {
|
||||
it('takes the headers from the identity port on every request and shows it every answer', async () => {
|
||||
const seen: string[] = [];
|
||||
const port = {
|
||||
headersFor: vi.fn(() => ({ 'User-Agent': 'TREK/test', Authorization: 'Bearer installation-key' })),
|
||||
observe: vi.fn((url: URL) => {
|
||||
seen.push(url.pathname);
|
||||
}),
|
||||
};
|
||||
serviceIdentitySlot.install(port);
|
||||
try {
|
||||
stubFetch({ query: 'x', results: [] });
|
||||
await trekPlacesSearch('x');
|
||||
expect(calls[0].init?.headers).toMatchObject({
|
||||
Accept: 'application/json',
|
||||
'X-TREK-Instance': expect.stringMatching(/^trek-/),
|
||||
'User-Agent': 'TREK/test',
|
||||
Authorization: 'Bearer installation-key',
|
||||
});
|
||||
expect(port.headersFor).toHaveBeenCalledWith(expect.any(URL));
|
||||
expect(seen).toEqual(['/v1/search']);
|
||||
} finally {
|
||||
serviceIdentitySlot.release(port);
|
||||
}
|
||||
});
|
||||
|
||||
it('sends what it always sent while no identity is installed', async () => {
|
||||
stubFetch({ query: 'x', results: [] });
|
||||
await trekPlacesSearch('x');
|
||||
expect(Object.keys(calls[0].init?.headers ?? {}).sort()).toEqual(['Accept', 'X-TREK-Instance']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getJson request shape', () => {
|
||||
|
||||
@@ -188,6 +188,9 @@ export default defineConfig({
|
||||
'src/nest/route-usage/**/*.ts': { statements: 84, branches: 66, functions: 77, lines: 83 },
|
||||
'src/nest/scheduling/**/*.ts': { statements: 99, branches: 92, functions: 99, lines: 99 },
|
||||
'src/nest/school-holidays/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 97 },
|
||||
// New domain in this change, unmeasured: on the 80 floor until the first
|
||||
// full CI run, then raised with scripts/coverage-thresholds.mjs, never lowered.
|
||||
'src/nest/service-identity/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
|
||||
// New domain in this change, unmeasured: pinned at the 80 floor so it
|
||||
// has its own entry rather than hiding in the catch-all. Its suites
|
||||
// cover every branch they can reach; regenerate with
|
||||
|
||||
Reference in New Issue
Block a user