mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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`.
|
||||||
@@ -17,11 +17,14 @@ export const SERVER_FEATURES = [
|
|||||||
"metadata-search-cache",
|
"metadata-search-cache",
|
||||||
"read-aloud-delivery",
|
"read-aloud-delivery",
|
||||||
"koreader-delivery-settings",
|
"koreader-delivery-settings",
|
||||||
|
"server-connection-identity",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
||||||
|
|
||||||
export interface AppInfoResponse {
|
export interface AppInfoResponse {
|
||||||
|
/** Stable installation identity, absent on older servers. */
|
||||||
|
instanceId?: string;
|
||||||
version: string;
|
version: string;
|
||||||
updateAvailable: boolean | null;
|
updateAvailable: boolean | null;
|
||||||
latestVersion: string | 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. */
|
/** Absent on servers older than the field itself, which a client treats as an empty list. */
|
||||||
features?: ServerFeature[];
|
features?: ServerFeature[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Public, credential-free identity check for explicitly enrolled connection aliases. */
|
||||||
|
export interface ServerIdentityResponse {
|
||||||
|
product: "bookorbit";
|
||||||
|
protocolVersion: 1;
|
||||||
|
instanceId: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -549,6 +549,24 @@ export const E2E_SUITES = Object.freeze({
|
|||||||
...SHARED_DB_AND_HELPER_PATHS,
|
...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": {
|
"app-settings-oidc-contract": {
|
||||||
id: "app-settings-oidc-contract",
|
id: "app-settings-oidc-contract",
|
||||||
name: "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,
|
"when": 1791853301183,
|
||||||
"tag": "0111_add_koreader_session_tombstones",
|
"tag": "0111_add_koreader_session_tombstones",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 112,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791941743914,
|
||||||
|
"tag": "0112_add_server_connection_identity",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,3 +45,4 @@ export * from './upload-sessions';
|
|||||||
export * from './podcasts';
|
export * from './podcasts';
|
||||||
export * from './metadata-cache';
|
export * from './metadata-cache';
|
||||||
export * from './delivery';
|
export * from './delivery';
|
||||||
|
export * from './server-identity';
|
||||||
|
|||||||
@@ -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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||||
|
import { ServerIdentityModule } from '../server-identity/server-identity.module';
|
||||||
import { AppInfoController } from './app-info.controller';
|
import { AppInfoController } from './app-info.controller';
|
||||||
import { AppInfoService } from './app-info.service';
|
import { AppInfoService } from './app-info.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AppSettingsModule],
|
imports: [AppSettingsModule, ServerIdentityModule],
|
||||||
controllers: [AppInfoController],
|
controllers: [AppInfoController],
|
||||||
providers: [AppInfoService],
|
providers: [AppInfoService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { SERVER_FEATURES } from '@bookorbit/types';
|
|||||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
import { GITHUB_RELEASES_API } from './app-info.constants';
|
import { GITHUB_RELEASES_API } from './app-info.constants';
|
||||||
import { AppInfoService } from './app-info.service';
|
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> {
|
function makeConfig(version = 'v1.2.3'): jest.Mocked<ConfigService> {
|
||||||
return {
|
return {
|
||||||
@@ -45,25 +50,25 @@ describe('AppInfoService', () => {
|
|||||||
|
|
||||||
describe('getAppInfo', () => {
|
describe('getAppInfo', () => {
|
||||||
it('returns version from config', async () => {
|
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');
|
expect((await service.getAppInfo()).version).toBe('v2.0.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to "Local build" when config returns undefined', async () => {
|
it('defaults to "Local build" when config returns undefined', async () => {
|
||||||
const config = { get: vi.fn().mockReturnValue(undefined) } as unknown as ConfigService;
|
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');
|
expect((await service.getAppInfo()).version).toBe('Local build');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for updateAvailable when update check is skipped', async () => {
|
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();
|
const info = await service.getAppInfo();
|
||||||
expect(info.updateAvailable).toBeNull();
|
expect(info.updateAvailable).toBeNull();
|
||||||
expect(info.latestVersion).toBeNull();
|
expect(info.latestVersion).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose the Book Dock filesystem path', async () => {
|
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();
|
const info = await service.getAppInfo();
|
||||||
expect(info).toMatchObject({
|
expect(info).toMatchObject({
|
||||||
version: 'Local build',
|
version: 'Local build',
|
||||||
@@ -74,8 +79,24 @@ describe('AppInfoService', () => {
|
|||||||
expect(info).not.toHaveProperty('bookDockPath');
|
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 () => {
|
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();
|
const info = await service.getAppInfo();
|
||||||
expect(info.features).toEqual([...SERVER_FEATURES]);
|
expect(info.features).toEqual([...SERVER_FEATURES]);
|
||||||
expect(info.features).toEqual(
|
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 () => {
|
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();
|
const first = await service.getAppInfo();
|
||||||
first.features?.push('book-journal');
|
first.features?.push('book-journal');
|
||||||
expect((await service.getAppInfo()).features).toEqual([...SERVER_FEATURES]);
|
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 () => {
|
it('fetches from GitHub if checked for the first time', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
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();
|
const info = await service.getAppInfo();
|
||||||
expect(info.updateAvailable).toBe(true);
|
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 () => {
|
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 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
|
await service.getAppInfo(); // First call triggers fetch
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
@@ -112,7 +133,7 @@ describe('AppInfoService', () => {
|
|||||||
|
|
||||||
it('re-fetches if more than 10 minutes have elapsed', async () => {
|
it('re-fetches if more than 10 minutes have elapsed', async () => {
|
||||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
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
|
await service.getAppInfo(); // First call
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
@@ -131,7 +152,7 @@ describe('AppInfoService', () => {
|
|||||||
it('fetches GitHub API and sets updateAvailable: true when newer version exists', async () => {
|
it('fetches GitHub API and sets updateAvailable: true when newer version exists', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
const info = await service.getAppInfo();
|
const info = await service.getAppInfo();
|
||||||
@@ -143,7 +164,7 @@ describe('AppInfoService', () => {
|
|||||||
it('sets updateAvailable: false when already on latest', async () => {
|
it('sets updateAvailable: false when already on latest', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.3'));
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBe(false);
|
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 () => {
|
it('sets updateAvailable: false when running newer than latest (downgrade scenario)', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.2'));
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBe(false);
|
expect((await service.getAppInfo()).updateAvailable).toBe(false);
|
||||||
@@ -161,7 +182,7 @@ describe('AppInfoService', () => {
|
|||||||
it('correctly compares minor version bumps', async () => {
|
it('correctly compares minor version bumps', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBe(true);
|
expect((await service.getAppInfo()).updateAvailable).toBe(true);
|
||||||
@@ -170,7 +191,7 @@ describe('AppInfoService', () => {
|
|||||||
it('correctly compares major version bumps', async () => {
|
it('correctly compares major version bumps', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v2.0.0'));
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBe(true);
|
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 () => {
|
it('skips fetch and leaves updateAvailable: null when version is "Local build"', async () => {
|
||||||
const fetchSpy = vi.spyOn(global, 'fetch');
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
@@ -189,7 +210,7 @@ describe('AppInfoService', () => {
|
|||||||
it('skips fetch when version is a sha-prefixed string', async () => {
|
it('skips fetch when version is a sha-prefixed string', async () => {
|
||||||
const fetchSpy = vi.spyOn(global, 'fetch');
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
@@ -199,7 +220,7 @@ describe('AppInfoService', () => {
|
|||||||
it('skips fetch when update checks are disabled', async () => {
|
it('skips fetch when update checks are disabled', async () => {
|
||||||
const fetchSpy = vi.spyOn(global, 'fetch');
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
@@ -209,7 +230,7 @@ describe('AppInfoService', () => {
|
|||||||
it('leaves updateAvailable: null on network error (does not throw)', async () => {
|
it('leaves updateAvailable: null on network error (does not throw)', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockRejectedValue(new TypeError('fetch failed'));
|
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();
|
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
||||||
@@ -221,7 +242,7 @@ describe('AppInfoService', () => {
|
|||||||
abortError.name = 'AbortError';
|
abortError.name = 'AbortError';
|
||||||
vi.spyOn(global, 'fetch').mockRejectedValue(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();
|
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
||||||
@@ -236,7 +257,7 @@ describe('AppInfoService', () => {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(badResponse);
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
||||||
@@ -251,7 +272,7 @@ describe('AppInfoService', () => {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
||||||
@@ -266,7 +287,7 @@ describe('AppInfoService', () => {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
||||||
@@ -281,7 +302,7 @@ describe('AppInfoService', () => {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
||||||
@@ -296,7 +317,7 @@ describe('AppInfoService', () => {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
expect((await service.getAppInfo()).updateAvailable).toBeNull();
|
||||||
@@ -306,7 +327,7 @@ describe('AppInfoService', () => {
|
|||||||
it('logs start and end on success', async () => {
|
it('logs start and end on success', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||||
@@ -317,7 +338,7 @@ describe('AppInfoService', () => {
|
|||||||
it('logs warn on failure', async () => {
|
it('logs warn on failure', async () => {
|
||||||
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error'));
|
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();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledOnce();
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SERVER_FEATURES, type AppInfoResponse } from '@bookorbit/types';
|
|||||||
|
|
||||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
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';
|
import { GITHUB_RELEASES_API, SEMVER_RE, UPDATE_CHECK_TIMEOUT_MS } from './app-info.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -17,6 +18,7 @@ export class AppInfoService implements OnApplicationBootstrap {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly appSettingsService: AppSettingsService,
|
private readonly appSettingsService: AppSettingsService,
|
||||||
|
private readonly identity: ServerIdentityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
@@ -43,8 +45,10 @@ export class AppInfoService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxUploadSizeMb = await this.appSettingsService.getMaxUploadSizeMb();
|
const maxUploadSizeMb = await this.appSettingsService.getMaxUploadSizeMb();
|
||||||
|
const instanceId = await this.instanceId();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(instanceId ? { instanceId } : {}),
|
||||||
version,
|
version,
|
||||||
updateAvailable: this.updateAvailable,
|
updateAvailable: this.updateAvailable,
|
||||||
latestVersion: this.latestVersion,
|
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> {
|
private async checkForUpdate(currentVersion: string): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
this.logger.log(`[app-info.update_check] [start] version=${currentVersion} - checking for updates`);
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user