feat(server): publish a stable installation identity for client connections

The iOS app lets an account reach one server through several addresses, such as a home network
IP and a public name. Before it sends credentials to an alternative address it needs to know the
address leads to the same installation, so expose a public, credential-free GET /server-identity
returning a persisted UUID, and add the same optional instanceId to app-info with the
server-connection-identity feature flag.

The UUID lives in a singleton server_identity row shared by every replica, created on first use
and cached per process. An unreadable identity omits instanceId instead of failing app-info,
which clients rely on for feature detection. Existing routes and bodies are unchanged and the
web client does not call the new route; the iOS app does.

The migration uses IF NOT EXISTS because an unreleased build created the same table as 0109.
This commit is contained in:
neon
2026-10-13 19:37:52 -06:00
parent 294bb13987
commit fbaf0b63df
17 changed files with 24098 additions and 27 deletions
+36
View File
@@ -0,0 +1,36 @@
# Server connection identity
The iOS client supports several user-entered connection addresses for one
BookOrbit installation. `GET /api/v1/server-identity` is public and returns only
`product: "bookorbit"`, `protocolVersion: 1`, and a persisted UUID `instanceId`.
It sends `Cache-Control: no-store`. The authenticated `/api/v1/app-info` response
adds the same optional `instanceId` and advertises `server-connection-identity`.
Existing routes and request bodies are unchanged. Released clients ignore the
additional fields. New clients keep single-address behavior on older servers.
The iOS client consumes this route even though the web client does not call it.
The singleton `server_identity` row is generated on first use and shared by all
replicas through PostgreSQL. Keep it in database backups. Application restart,
container replacement, DNS changes, and reverse-proxy aliases preserve it.
Multiple addresses must reach the same database and installation.
The UUID detects accidental connection to another installation through an
alternative address. It is public and is not a replacement for TLS certificate
validation. Clients never send account credentials when probing an alternative
and never bypass TLS trust. The address a user signed in with keeps the trust
released clients gave it, including following its redirects. An alternative is
used only after it answers with the enrolled UUID, and its redirects must stay on
its host. Users who explicitly choose HTTP retain that transport's existing trust
limits. If the identity row cannot be read, `/app-info` still answers and omits
`instanceId`.
A restored database belonging to the same installation keeps its UUID. When the
UUID changes (an independent clone, a fresh database, or a backup taken before
this table existed), clients keep using the address they signed in with, adopt
the new UUID from the authenticated `/app-info` response, and ask the user to
verify each alternative again. For an independent clone, stop every application
replica, remove only the singleton `server_identity` row through an
administrative database session, and restart. The next request creates a fresh
UUID. Never regenerate the row while live replicas hold a cached identity.
Regression coverage is available through `pnpm e2e:run -- server-connection-identity`.
+10
View File
@@ -17,11 +17,14 @@ export const SERVER_FEATURES = [
"metadata-search-cache",
"read-aloud-delivery",
"koreader-delivery-settings",
"server-connection-identity",
] as const;
export type ServerFeature = (typeof SERVER_FEATURES)[number];
export interface AppInfoResponse {
/** Stable installation identity, absent on older servers. */
instanceId?: string;
version: string;
updateAvailable: boolean | null;
latestVersion: string | null;
@@ -29,3 +32,10 @@ export interface AppInfoResponse {
/** Absent on servers older than the field itself, which a client treats as an empty list. */
features?: ServerFeature[];
}
/** Public, credential-free identity check for explicitly enrolled connection aliases. */
export interface ServerIdentityResponse {
product: "bookorbit";
protocolVersion: 1;
instanceId: string;
}
+18
View File
@@ -549,6 +549,24 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"server-connection-identity": {
id: "server-connection-identity",
name: "Server Connection Identity",
timeout: 60,
lane: "full",
description: "Public installation identity and concurrent singleton persistence",
vitestTarget: "test/server-connection-identity.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/server-connection-identity-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/server-identity/**",
"server/src/modules/app-info/**",
"server/test/server-connection-identity.e2e-spec.ts",
...SHARED_TYPES_PATHS,
...SHARED_DB_AND_HELPER_PATHS,
],
},
"app-settings-oidc-contract": {
id: "app-settings-oidc-contract",
name: "App Settings OIDC Contract",
@@ -0,0 +1,6 @@
-- An unreleased build created this table as migration 0109; keep its row and installation UUID.
CREATE TABLE IF NOT EXISTS "server_identity" (
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
"instance_id" uuid DEFAULT gen_random_uuid() NOT NULL,
CONSTRAINT "server_identity_singleton" CHECK ("server_identity"."id" = 1)
);
File diff suppressed because it is too large Load Diff
@@ -785,6 +785,13 @@
"when": 1791853301183,
"tag": "0111_add_koreader_session_tombstones",
"breakpoints": true
},
{
"idx": 112,
"version": "7",
"when": 1791941743914,
"tag": "0112_add_server_connection_identity",
"breakpoints": true
}
]
}
+1
View File
@@ -45,3 +45,4 @@ export * from './upload-sessions';
export * from './podcasts';
export * from './metadata-cache';
export * from './delivery';
export * from './server-identity';
+11
View File
@@ -0,0 +1,11 @@
import { sql } from 'drizzle-orm';
import { check, integer, pgTable, uuid } from 'drizzle-orm/pg-core';
export const serverIdentity = pgTable(
'server_identity',
{
id: integer('id').primaryKey().default(1),
instanceId: uuid('instance_id').notNull().defaultRandom(),
},
(t) => [check('server_identity_singleton', sql`${t.id} = 1`)],
);
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { ServerIdentityModule } from '../server-identity/server-identity.module';
import { AppInfoController } from './app-info.controller';
import { AppInfoService } from './app-info.service';
@Module({
imports: [AppSettingsModule],
imports: [AppSettingsModule, ServerIdentityModule],
controllers: [AppInfoController],
providers: [AppInfoService],
})
@@ -5,6 +5,11 @@ import { SERVER_FEATURES } from '@bookorbit/types';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { GITHUB_RELEASES_API } from './app-info.constants';
import { AppInfoService } from './app-info.service';
import { ServerIdentityService } from '../server-identity/server-identity.service';
const identity = {
getIdentity: vi.fn().mockResolvedValue({ product: 'bookorbit', protocolVersion: 1, instanceId: '538a27ca-87ef-47bc-a195-13c8597c3529' }),
} as unknown as ServerIdentityService;
function makeConfig(version = 'v1.2.3'): jest.Mocked<ConfigService> {
return {
@@ -45,25 +50,25 @@ describe('AppInfoService', () => {
describe('getAppInfo', () => {
it('returns version from config', async () => {
const service = new AppInfoService(makeConfig('v2.0.0'), makeAppSettings());
const service = new AppInfoService(makeConfig('v2.0.0'), makeAppSettings(), identity);
expect((await service.getAppInfo()).version).toBe('v2.0.0');
});
it('defaults to "Local build" when config returns undefined', async () => {
const config = { get: vi.fn().mockReturnValue(undefined) } as unknown as ConfigService;
const service = new AppInfoService(config, makeAppSettings());
const service = new AppInfoService(config, makeAppSettings(), identity);
expect((await service.getAppInfo()).version).toBe('Local build');
});
it('returns null for updateAvailable when update check is skipped', async () => {
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings());
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(), identity);
const info = await service.getAppInfo();
expect(info.updateAvailable).toBeNull();
expect(info.latestVersion).toBeNull();
});
it('does not expose the Book Dock filesystem path', async () => {
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings());
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(), identity);
const info = await service.getAppInfo();
expect(info).toMatchObject({
version: 'Local build',
@@ -74,8 +79,24 @@ describe('AppInfoService', () => {
expect(info).not.toHaveProperty('bookDockPath');
});
it('includes the installation identity for connection verification', async () => {
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(), identity);
expect((await service.getAppInfo()).instanceId).toBe('538a27ca-87ef-47bc-a195-13c8597c3529');
});
it('still answers without the identity when it cannot be read', async () => {
const unavailable = {
getIdentity: vi.fn().mockRejectedValue(new Error('database unavailable')),
} as unknown as ServerIdentityService;
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(), unavailable);
const info = await service.getAppInfo();
expect(info).not.toHaveProperty('instanceId');
expect(info.features).toEqual([...SERVER_FEATURES]);
expect(warnSpy).toHaveBeenCalledOnce();
});
it('advertises every server feature so clients can detect support', async () => {
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings());
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(), identity);
const info = await service.getAppInfo();
expect(info.features).toEqual([...SERVER_FEATURES]);
expect(info.features).toEqual(
@@ -84,7 +105,7 @@ describe('AppInfoService', () => {
});
it('returns a fresh features array per call so a caller cannot mutate the shared list', async () => {
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings());
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(), identity);
const first = await service.getAppInfo();
first.features?.push('book-journal');
expect((await service.getAppInfo()).features).toEqual([...SERVER_FEATURES]);
@@ -92,7 +113,7 @@ describe('AppInfoService', () => {
it('fetches from GitHub if checked for the first time', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
const info = await service.getAppInfo();
expect(info.updateAvailable).toBe(true);
@@ -101,7 +122,7 @@ describe('AppInfoService', () => {
it('returns cached result and does not fetch if less than 10 minutes have passed', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.getAppInfo(); // First call triggers fetch
expect(fetchSpy).toHaveBeenCalledTimes(1);
@@ -112,7 +133,7 @@ describe('AppInfoService', () => {
it('re-fetches if more than 10 minutes have elapsed', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.getAppInfo(); // First call
expect(fetchSpy).toHaveBeenCalledTimes(1);
@@ -131,7 +152,7 @@ describe('AppInfoService', () => {
it('fetches GitHub API and sets updateAvailable: true when newer version exists', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
const info = await service.getAppInfo();
@@ -143,7 +164,7 @@ describe('AppInfoService', () => {
it('sets updateAvailable: false when already on latest', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.3'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBe(false);
@@ -152,7 +173,7 @@ describe('AppInfoService', () => {
it('sets updateAvailable: false when running newer than latest (downgrade scenario)', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.2'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBe(false);
@@ -161,7 +182,7 @@ describe('AppInfoService', () => {
it('correctly compares minor version bumps', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.9'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.9'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBe(true);
@@ -170,7 +191,7 @@ describe('AppInfoService', () => {
it('correctly compares major version bumps', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v2.0.0'));
const service = new AppInfoService(makeConfig('v1.9.9'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.9.9'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBe(true);
@@ -179,7 +200,7 @@ describe('AppInfoService', () => {
it('skips fetch and leaves updateAvailable: null when version is "Local build"', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect(fetchSpy).not.toHaveBeenCalled();
@@ -189,7 +210,7 @@ describe('AppInfoService', () => {
it('skips fetch when version is a sha-prefixed string', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const service = new AppInfoService(makeConfig('sha-abc1234'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('sha-abc1234'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect(fetchSpy).not.toHaveBeenCalled();
@@ -199,7 +220,7 @@ describe('AppInfoService', () => {
it('skips fetch when update checks are disabled', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(false));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(false), identity);
await service.onApplicationBootstrap();
expect(fetchSpy).not.toHaveBeenCalled();
@@ -209,7 +230,7 @@ describe('AppInfoService', () => {
it('leaves updateAvailable: null on network error (does not throw)', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new TypeError('fetch failed'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
expect((await service.getAppInfo()).updateAvailable).toBeNull();
@@ -221,7 +242,7 @@ describe('AppInfoService', () => {
abortError.name = 'AbortError';
vi.spyOn(global, 'fetch').mockRejectedValue(abortError);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
expect((await service.getAppInfo()).updateAvailable).toBeNull();
@@ -236,7 +257,7 @@ describe('AppInfoService', () => {
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(badResponse);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBeNull();
@@ -251,7 +272,7 @@ describe('AppInfoService', () => {
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBeNull();
@@ -266,7 +287,7 @@ describe('AppInfoService', () => {
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBeNull();
@@ -281,7 +302,7 @@ describe('AppInfoService', () => {
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBeNull();
@@ -296,7 +317,7 @@ describe('AppInfoService', () => {
} as unknown as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(response);
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect((await service.getAppInfo()).updateAvailable).toBeNull();
@@ -306,7 +327,7 @@ describe('AppInfoService', () => {
it('logs start and end on success', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect(logSpy).toHaveBeenCalledTimes(2);
@@ -317,7 +338,7 @@ describe('AppInfoService', () => {
it('logs warn on failure', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error'));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true), identity);
await service.onApplicationBootstrap();
expect(warnSpy).toHaveBeenCalledOnce();
@@ -5,6 +5,7 @@ import { SERVER_FEATURES, type AppInfoResponse } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { ServerIdentityService } from '../server-identity/server-identity.service';
import { GITHUB_RELEASES_API, SEMVER_RE, UPDATE_CHECK_TIMEOUT_MS } from './app-info.constants';
@Injectable()
@@ -17,6 +18,7 @@ export class AppInfoService implements OnApplicationBootstrap {
constructor(
private readonly config: ConfigService,
private readonly appSettingsService: AppSettingsService,
private readonly identity: ServerIdentityService,
) {}
async onApplicationBootstrap(): Promise<void> {
@@ -43,8 +45,10 @@ export class AppInfoService implements OnApplicationBootstrap {
}
const maxUploadSizeMb = await this.appSettingsService.getMaxUploadSizeMb();
const instanceId = await this.instanceId();
return {
...(instanceId ? { instanceId } : {}),
version,
updateAvailable: this.updateAvailable,
latestVersion: this.latestVersion,
@@ -53,6 +57,20 @@ export class AppInfoService implements OnApplicationBootstrap {
};
}
/** Clients detect features through app-info, so an identity failure omits only the identity. */
private async instanceId(): Promise<string | undefined> {
try {
return (await this.identity.getIdentity()).instanceId;
} catch (error) {
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[app-info.identity] [fail] errorClass=${errorClass} error="${sanitizeLogValue(message)}" - installation identity unavailable, omitting instanceId`,
);
return undefined;
}
}
private async checkForUpdate(currentVersion: string): Promise<void> {
const start = Date.now();
this.logger.log(`[app-info.update_check] [start] version=${currentVersion} - checking for updates`);
@@ -0,0 +1,17 @@
import { Controller, Get, Header } from '@nestjs/common';
import type { ServerIdentityResponse } from '@bookorbit/types';
import { Public } from '../../common/decorators/public.decorator';
import { ServerIdentityService } from './server-identity.service';
@Controller('server-identity')
export class ServerIdentityController {
constructor(private readonly service: ServerIdentityService) {}
@Get()
@Public()
@Header('Cache-Control', 'no-store')
getIdentity(): Promise<ServerIdentityResponse> {
return this.service.getIdentity();
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ServerIdentityController } from './server-identity.controller';
import { ServerIdentityRepository } from './server-identity.repository';
import { ServerIdentityService } from './server-identity.service';
@Module({
controllers: [ServerIdentityController],
providers: [ServerIdentityRepository, ServerIdentityService],
exports: [ServerIdentityService],
})
export class ServerIdentityModule {}
@@ -0,0 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@Injectable()
export class ServerIdentityRepository {
constructor(@Inject(DB) private readonly db: NodePgDatabase<typeof schema>) {}
async getOrCreate(): Promise<string | undefined> {
// Every replica reads the winning insert, never its own discarded UUID.
await this.db.insert(schema.serverIdentity).values({ id: 1 }).onConflictDoNothing();
const row = await this.db.query.serverIdentity.findFirst({ where: eq(schema.serverIdentity.id, 1) });
return row?.instanceId;
}
}
@@ -0,0 +1,21 @@
import { ServerIdentityRepository } from './server-identity.repository';
import { ServerIdentityService } from './server-identity.service';
describe('ServerIdentityService', () => {
it('shares the durable identity across concurrent requests', async () => {
const getOrCreate = vi.fn().mockResolvedValue('538a27ca-87ef-47bc-a195-13c8597c3529');
const service = new ServerIdentityService({ getOrCreate } as unknown as ServerIdentityRepository);
const [first, second] = await Promise.all([service.getIdentity(), service.getIdentity()]);
expect(first).toEqual({ product: 'bookorbit', protocolVersion: 1, instanceId: '538a27ca-87ef-47bc-a195-13c8597c3529' });
expect(second).toEqual(first);
expect(getOrCreate).toHaveBeenCalledOnce();
});
it('retries after a failed database read', async () => {
const getOrCreate = vi.fn().mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue('restored');
const service = new ServerIdentityService({ getOrCreate } as unknown as ServerIdentityRepository);
await expect(service.getIdentity()).rejects.toThrow('unavailable');
expect((await service.getIdentity()).instanceId).toBe('restored');
expect(getOrCreate).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,25 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import type { ServerIdentityResponse } from '@bookorbit/types';
import { ServerIdentityRepository } from './server-identity.repository';
@Injectable()
export class ServerIdentityService {
private identityFlight?: Promise<ServerIdentityResponse>;
constructor(private readonly repository: ServerIdentityRepository) {}
getIdentity(): Promise<ServerIdentityResponse> {
this.identityFlight ??= this.load().catch((error: unknown) => {
this.identityFlight = undefined;
throw error;
});
return this.identityFlight;
}
private async load(): Promise<ServerIdentityResponse> {
const instanceId = await this.repository.getOrCreate();
if (!instanceId) throw new ServiceUnavailableException('Server identity is unavailable');
return { product: 'bookorbit', protocolVersion: 1, instanceId };
}
}
@@ -0,0 +1,43 @@
import { eq } from 'drizzle-orm';
import { serverIdentity } from '../src/db/schema';
import { ServerIdentityRepository } from '../src/modules/server-identity/server-identity.repository';
import { ServerIdentityService } from '../src/modules/server-identity/server-identity.service';
import { closeE2EContext, createE2EContext, type E2EContext } from './e2e/app-harness';
describe('Server connection identity', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext({ isolateAppData: true });
}, 60_000);
afterAll(async () => {
if (ctx) await closeE2EContext(ctx);
});
it('exposes a small public no-store response and the same identity through app-info', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/server-identity' });
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
const identity = response.json();
expect(identity).toEqual({ product: 'bookorbit', protocolVersion: 1, instanceId: expect.any(String) });
expect(identity.instanceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
const info = await ctx.app.inject({ method: 'GET', url: '/api/v1/app-info', headers: { authorization: `Bearer ${ctx.adminToken}` } });
expect(info.statusCode).toBe(200);
expect(info.json().instanceId).toBe(identity.instanceId);
expect(info.json().features).toContain('server-connection-identity');
});
it('concurrent replicas converge on the persisted singleton and restart keeps it', async () => {
const repositories = Array.from({ length: 12 }, () => new ServerIdentityRepository(ctx.db));
const identities = await Promise.all(repositories.map((repository) => repository.getOrCreate()));
expect(new Set(identities).size).toBe(1);
expect(identities[0]).toBeTruthy();
const rows = await ctx.db.select().from(serverIdentity).where(eq(serverIdentity.id, 1));
expect(rows).toHaveLength(1);
expect(rows[0].instanceId).toBe(identities[0]);
const restarted = new ServerIdentityService(new ServerIdentityRepository(ctx.db));
expect((await restarted.getIdentity()).instanceId).toBe(identities[0]);
});
});