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,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