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
@@ -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]);
});
});