Separate configuration monitoring from application updates #53

Merged
eleith merged 1 commits from refactor/config-monitor into main 2026-10-27 12:09:50 +00:00
42 changed files with 982 additions and 1545 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@directory/web': patch
---
Separate configuration monitoring from application updates, retaining last-good configuration and restart-only authentication settings. Detect same-sized edits that preserve modification time.
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Session } from '@auth/sveltekit';
import type { DirectoryContent } from './lib/display/types.js';
import type { Authentication } from './lib/server/auth/index.js';
import type { PreparedSnapshot } from './lib/server/config/snapshot.js';
import type { PreparedSnapshot } from './lib/server/directory/snapshot.js';
declare global {
namespace NodeJS {
+19 -29
View File
@@ -7,8 +7,7 @@ import { requireSignIn } from './lib/server/auth/access.js';
import { readMountedImage } from './lib/server/images/read-mounted-image.js';
import { GET as mountedImage } from './routes/(protected)/(endpoints)/public/[...file]/+server.js';
import { GET as bookmarkLogo } from './routes/(protected)/(endpoints)/bookmarks/[id]/logo/+server.js';
import type { Application } from './lib/server/lifecycle/application.js';
import type { Directory } from './lib/server/config/load-directory.js';
import type { Application } from './lib/server/application.js';
const adapterEvents: NodeJS.EventEmitter = process;
@@ -20,7 +19,7 @@ const mocks = vi.hoisted(() => ({
report: vi.fn<typeof import('./lib/server/logger.js').reportStartupFailure>(),
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
path: vi.fn<typeof import('@directory/config').resolveConfigPath>(),
prepare: vi.fn<typeof import('./lib/server/config/snapshot.js').prepareSnapshot>()
prepare: vi.fn<typeof import('./lib/server/directory/snapshot.js').prepareSnapshot>()
}));
vi.mock('$env/dynamic/private', () => ({ env: mocks.env }));
@@ -60,10 +59,10 @@ vi.mock('@directory/config', async (importOriginal) => ({
loadConfigFile: mocks.load,
resolveConfigPath: mocks.path
}));
vi.mock('./lib/server/config/snapshot.js', () => ({ prepareSnapshot: mocks.prepare }));
vi.mock('./lib/server/directory/snapshot.js', () => ({ prepareSnapshot: mocks.prepare }));
const realSnapshot = await vi.importActual<typeof import('./lib/server/config/snapshot.js')>(
'./lib/server/config/snapshot.js'
const realSnapshot = await vi.importActual<typeof import('./lib/server/directory/snapshot.js')>(
'./lib/server/directory/snapshot.js'
);
const sentinel = 'PRIVATE-config-password-not-for-browser';
type Resolve = Parameters<Handle>[0]['resolve'];
@@ -98,11 +97,8 @@ function event(): RequestEvent {
function application(title: string, authentication?: Authentication) {
return {
directory: {
currentSnapshot: vi.fn().mockReturnValue(snapshot(title)),
reload: vi.fn<Directory['reload']>(),
stop: vi.fn<Directory['stop']>()
},
currentSnapshot: vi.fn<Application['currentSnapshot']>().mockReturnValue(snapshot(title)),
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
publicDir: '/mounted/public',
authentication,
stop: vi.fn<Application['stop']>()
@@ -169,7 +165,7 @@ describe('server hooks', () => {
expect(unavailable.headers.get('referrer-policy')).toBe('strict-origin-when-cross-origin');
expect(await unavailable.text()).toBe('Service unavailable');
expect(resolve).not.toHaveBeenCalled();
expect(ready.directory.currentSnapshot).not.toHaveBeenCalled();
expect(ready.currentSnapshot).not.toHaveBeenCalled();
pending.resolve(ready);
await startup;
expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env);
@@ -264,9 +260,7 @@ describe('server hooks', () => {
const authentication: Authentication = {
strategy,
handle: vi.fn<Handle>().mockImplementation(async ({ event }) => {
expect(event.locals.snapshot).toBe(
ready.directory.currentSnapshot.mock.results[0]?.value
);
expect(event.locals.snapshot).toBe(ready.currentSnapshot.mock.results[0]?.value);
expect(event.locals.publicDir).toBe(ready.publicDir);
expect(event.locals.authentication).toBe(authentication);
expect(event.locals.session).toBeNull();
@@ -290,7 +284,7 @@ describe('server hooks', () => {
event: expect.objectContaining({ locals: request.locals }),
resolve: expect.any(Function)
});
expect(ready.directory.currentSnapshot).toHaveBeenCalledOnce();
expect(ready.currentSnapshot).toHaveBeenCalledOnce();
expect(resolve).not.toHaveBeenCalled();
expect(authentication.signIn).not.toHaveBeenCalled();
expect(authentication.signOut).not.toHaveBeenCalled();
@@ -409,8 +403,8 @@ describe('server hooks', () => {
};
}
const ready = application('Initial', authentication);
const captured = ready.directory.currentSnapshot();
ready.directory.currentSnapshot.mockClear();
const captured = ready.currentSnapshot();
ready.currentSnapshot.mockClear();
const before = JSON.stringify(captured);
const replacement = snapshot('Replacement', '#abc', '#def');
mocks.boot.mockResolvedValueOnce(ready);
@@ -420,7 +414,7 @@ describe('server hooks', () => {
const resolve = vi.fn<Resolve>().mockImplementation(async (event, options) => {
expect(event.locals).toBe(request.locals);
expect(event.locals.snapshot).toBe(captured);
ready.directory.currentSnapshot.mockReturnValue(replacement);
ready.currentSnapshot.mockReturnValue(replacement);
const transform = options?.transformPageChunk;
if (transform === undefined) throw new Error('Missing theme transform');
if (strategy !== 'none') {
@@ -446,7 +440,7 @@ describe('server hooks', () => {
expect(html).not.toContain('Replacement');
expect(html).not.toContain('#abc');
expect(html).not.toContain('--directory-');
expect(ready.directory.currentSnapshot).toHaveBeenCalledOnce();
expect(ready.currentSnapshot).toHaveBeenCalledOnce();
expect(request.locals.snapshot).toBe(captured);
expect(JSON.stringify(captured)).toBe(before);
expect(mocks.load).not.toHaveBeenCalled();
@@ -465,13 +459,9 @@ describe('server hooks', () => {
const initial = snapshot('Initial');
const replacement = snapshot('Replacement');
let published = initial;
const directory = {
currentSnapshot: vi.fn(() => published),
reload: vi.fn<Directory['reload']>(),
stop: vi.fn()
};
const app = {
directory,
currentSnapshot: vi.fn<Application['currentSnapshot']>(() => published),
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
publicDir: '/mounted/public',
authentication: undefined,
stop: vi.fn<Application['stop']>()
@@ -494,7 +484,7 @@ describe('server hooks', () => {
});
const firstEvent = event();
const first = await hooks.handle({ event: firstEvent, resolve });
expect(directory.currentSnapshot).toHaveBeenCalledOnce();
expect(app.currentSnapshot).toHaveBeenCalledOnce();
expect(firstEvent.locals.snapshot).toBe(initial);
expect(firstEvent.locals.authentication).toBeUndefined();
expect(firstEvent.locals.session).toBeNull();
@@ -509,7 +499,7 @@ describe('server hooks', () => {
expect(capturedPaths).toEqual([app.publicDir, app.publicDir]);
const secondEvent = event();
const second = await hooks.handle({ event: secondEvent, resolve });
expect(directory.currentSnapshot).toHaveBeenCalledTimes(2);
expect(app.currentSnapshot).toHaveBeenCalledTimes(2);
expect(secondEvent.locals.snapshot).toBe(replacement);
expect(await second.json()).toEqual({ first: 'Replacement', second: 'Replacement' });
expect(capturedPaths).toEqual(Array<string>(4).fill(app.publicDir));
@@ -517,6 +507,6 @@ describe('server hooks', () => {
expect(mocks.path).not.toHaveBeenCalled();
expect(mocks.load).not.toHaveBeenCalled();
expect(mocks.prepare).not.toHaveBeenCalled();
expect(directory.reload).not.toHaveBeenCalled();
expect(app.onConfigChange).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private';
import type { Handle, ServerInit } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { themeStyleTag } from './lib/server/appearance.js';
import type { ServerState } from './lib/server/lifecycle/application.js';
import type { ServerState } from './lib/server/application.js';
import { startDevelopment } from './lib/server/lifecycle/development.js';
import { startProduction } from './lib/server/lifecycle/production.js';
import { reportStartupFailure } from './lib/server/logger.js';
@@ -30,7 +30,7 @@ const applicationHandle: Handle = ({ event, resolve }) => {
headers: { 'cache-control': 'no-store' }
});
}
event.locals.snapshot = application.directory.currentSnapshot();
event.locals.snapshot = application.currentSnapshot();
event.locals.publicDir = application.publicDir;
event.locals.authentication = application.authentication;
event.locals.session = null;
+1 -1
View File
@@ -1,7 +1,7 @@
import { validateConfig } from '@directory/config';
import { describe, expect, it } from 'vitest';
import { themeStyleTag } from './appearance.js';
import { prepareSnapshot } from './config/snapshot.js';
import { prepareSnapshot } from './directory/snapshot.js';
function snapshot(light: string, dark: string) {
return prepareSnapshot(
+269
View File
@@ -0,0 +1,269 @@
import { inspect } from 'node:util';
import { validateConfig } from '@directory/config';
import type { DirectoryConfiguration } from '@directory/config/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApplication } from './application.js';
import type { Authentication } from './auth/index.js';
import { DirectoryError } from './directory-error.js';
const mocks = vi.hoisted(() => ({
prepare: vi.fn<typeof import('./directory/snapshot.js').prepareSnapshot>(),
authenticate: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
report: vi.fn<typeof import('./logger.js').reportDirectoryIssue>()
}));
vi.mock('./directory/snapshot.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./directory/snapshot.js')>()),
prepareSnapshot: mocks.prepare
}));
vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.authenticate }));
vi.mock('./logger.js', () => ({ reportDirectoryIssue: mocks.report }));
const realSnapshot =
await vi.importActual<typeof import('./directory/snapshot.js')>('./directory/snapshot.js');
const sentinel = 'PRIVATE-password-env-filename.yml';
const credentials = {
origin: 'https://directory.example.com',
credentials: { username: 'family', password: sentinel }
};
const authentication: Authentication = {
strategy: 'credentials',
handle: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn()
};
function configuration(
title = 'boot',
auth?: DirectoryConfiguration['auth']
): DirectoryConfiguration {
return validateConfig(
{
site: { title, logo_path: `/public/${title}.svg` },
...(auth === undefined ? {} : { auth }),
links: {
photos: {
title: `${title} photos`,
url: 'https://photos.example.com',
logo_path: `/public/${title}-photos.svg`,
tags: [' photos ', ' backup ']
}
}
},
{ env: {} }
);
}
function expectRedacted(value: unknown): void {
for (const text of [String(value), JSON.stringify(value), inspect(value)]) {
expect(text).not.toContain(sentinel);
}
if (value instanceof Error) expect(value.stack).not.toContain(sentinel);
expect(value).not.toHaveProperty('cause');
expect(value).not.toHaveProperty('value');
expect(value).not.toHaveProperty('path');
}
function captureDirectoryError(action: () => unknown): DirectoryError {
try {
action();
} catch (error) {
expect(error).toBeInstanceOf(DirectoryError);
expectRedacted(error);
return error as DirectoryError;
}
throw new Error('Expected a synchronous DirectoryError');
}
beforeEach(() => {
mocks.prepare.mockReset().mockImplementation(realSnapshot.prepareSnapshot);
mocks.authenticate.mockReset().mockReturnValue(authentication);
mocks.report.mockReset();
});
describe('application startup', () => {
it('synchronously prepares private configuration and constructs authentication from that snapshot', () => {
const initial = configuration('boot', credentials);
const app = createApplication(initial);
const snapshot = app.currentSnapshot();
expect(mocks.prepare).toHaveBeenCalledExactlyOnceWith(initial);
expect(snapshot).toBe(mocks.prepare.mock.results[0]!.value);
expect(mocks.authenticate).toHaveBeenCalledExactlyOnceWith(snapshot.configuration.auth);
expect(mocks.authenticate.mock.calls[0]![0]).toBe(snapshot.configuration.auth);
expect(app.authentication).toBe(authentication);
expect(snapshot.configuration.auth?.credentials?.password).toBe(sentinel);
expect(snapshot.configuration.auth).not.toHaveProperty('secret');
expect(snapshot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
expect(snapshot.assets.referencedPaths).toEqual([
'/public/boot.svg',
'/public/boot-photos.svg'
]);
expectRedacted(snapshot.display);
expect(mocks.report).not.toHaveBeenCalled();
});
it('does not construct authentication when it is absent', () => {
expect(createApplication(configuration()).authentication).toBeUndefined();
expect(mocks.authenticate).not.toHaveBeenCalled();
});
it.each(['prepare_failed', 'auth_failed'] as const)('redacts %s at startup', (code) => {
const fail = () => {
throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
};
if (code === 'prepare_failed') mocks.prepare.mockImplementationOnce(fail);
else mocks.authenticate.mockImplementationOnce(fail);
const error = captureDirectoryError(() =>
createApplication(configuration('boot', credentials))
);
expect(error.issue).toEqual({ code });
if (code === 'prepare_failed') expect(mocks.authenticate).not.toHaveBeenCalled();
expect(mocks.report).not.toHaveBeenCalled();
});
});
describe('application config policy', () => {
it('replaces all snapshot views together, preserving old captures and the original authentication', () => {
const initial = configuration('boot', credentials);
const app = createApplication(initial);
const captured = app.currentSnapshot();
const next = configuration('next', credentials);
mocks.prepare.mockImplementationOnce((config) => {
expect(app.currentSnapshot()).toBe(captured);
return realSnapshot.prepareSnapshot(config);
});
expect(app.onConfigChange(next, initial)).toBe(true);
const current = app.currentSnapshot();
expect(current).not.toBe(captured);
expect(current.configuration.site.title).toBe('next');
expect(current.display.site.title).toBe('next');
expect(current.display.groups[0]?.links[0]?.title).toBe('next photos');
expect(current.assets.referencedPaths).toEqual(['/public/next.svg', '/public/next-photos.svg']);
expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos);
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
expect(captured.configuration.site.title).toBe('boot');
expect(captured.display.site.title).toBe('boot');
expect(captured.display.groups[0]?.links[0]?.title).toBe('boot photos');
expect(captured.assets.referencedPaths).toEqual([
'/public/boot.svg',
'/public/boot-photos.svg'
]);
expectRedacted(current.display);
expect(app.authentication).toBe(authentication);
expect(mocks.authenticate).toHaveBeenCalledOnce();
expect(mocks.report).not.toHaveBeenCalled();
});
it('accepts equivalent normalized values and mapping-key order without preparing or publishing', () => {
const initial = configuration('boot', credentials);
const app = createApplication(initial);
const accepted = configuration('accepted', credentials);
expect(app.onConfigChange(accepted, initial)).toBe(true);
const current = app.currentSnapshot();
// Source formatting is gone by this boundary; independently loaded values matter.
const equivalent = configuration('accepted', credentials);
equivalent.site = Object.fromEntries(
Object.entries(equivalent.site).reverse()
) as DirectoryConfiguration['site'];
equivalent.auth = {
credentials: { password: sentinel, username: 'family' },
origin: credentials.origin
};
expect(app.onConfigChange(equivalent, accepted)).toBe(true);
expect(app.onConfigChange(configuration('accepted', credentials), equivalent)).toBe(true);
expect(app.currentSnapshot()).toBe(current);
expect(mocks.prepare).toHaveBeenCalledTimes(2);
expect(mocks.authenticate).toHaveBeenCalledOnce();
expect(mocks.report).not.toHaveBeenCalled();
});
it('publishes bookmark declaration order even when mapping values are equal', () => {
const initial = validateConfig({
links: {
zebra: { title: 'Zebra', url: 'https://zebra.example.com', group: 'Tools' },
alpha: { title: 'Alpha', url: 'https://alpha.example.com', group: 'Family' },
beta: { title: 'Beta', url: 'https://beta.example.com', group: 'Tools' }
}
});
const app = createApplication(initial);
const captured = app.currentSnapshot();
const reordered = {
...initial,
links: { alpha: initial.links.alpha!, beta: initial.links.beta!, zebra: initial.links.zebra! }
};
expect(reordered).toEqual(initial);
expect(app.onConfigChange(reordered, initial)).toBe(true);
const current = app.currentSnapshot();
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
expect(current.display.groups.map((group) => group.label)).toEqual(['Family', 'Tools']);
expect(current.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([
['alpha'],
['beta', 'zebra']
]);
expect(captured.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([
['zebra', 'beta'],
['alpha']
]);
expect(mocks.prepare).toHaveBeenCalledTimes(2);
});
it.each([
{ change: 'enable auth', before: undefined, after: credentials },
{ change: 'disable auth', before: credentials, after: undefined },
{
change: 'origin',
before: credentials,
after: { ...credentials, origin: 'https://other.example.com' }
},
{
change: 'password',
before: credentials,
after: { ...credentials, credentials: { ...credentials.credentials, password: 'changed' } }
},
{
change: 'session secret',
before: { ...credentials, secret: `${sentinel}-session` },
after: { ...credentials, secret: `${sentinel}-replacement` }
}
])('rejects the whole config for $change until auth is restored', ({ before, after }) => {
const initial = configuration('boot', before);
const app = createApplication(initial);
const accepted = configuration('accepted', before);
expect(app.onConfigChange(accepted, initial)).toBe(true);
const captured = app.currentSnapshot();
expect(app.onConfigChange(configuration('rejected', after), accepted)).toBe(false);
expect(app.currentSnapshot()).toBe(captured);
expect(mocks.prepare).toHaveBeenCalledTimes(2);
expect(mocks.report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
expectRedacted(mocks.report.mock.calls);
const restored = configuration('restored', before);
expect(app.onConfigChange(restored, accepted)).toBe(true);
expect(app.currentSnapshot().display.site.title).toBe('restored');
expect(app.currentSnapshot().configuration.auth).toEqual(before);
expect(app.authentication).toBe(before === undefined ? undefined : authentication);
expect(mocks.authenticate).toHaveBeenCalledTimes(before === undefined ? 0 : 1);
});
it('throws a safe preparation failure, retains last good, and recovers using the accepted baseline', () => {
const initial = configuration();
const app = createApplication(initial);
const accepted = configuration('accepted');
expect(app.onConfigChange(accepted, initial)).toBe(true);
const captured = app.currentSnapshot();
mocks.prepare.mockImplementationOnce(() => {
throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
});
const error = captureDirectoryError(() =>
app.onConfigChange(configuration('failed'), accepted)
);
expect(error.issue).toEqual({ code: 'prepare_failed' });
expect(app.currentSnapshot()).toBe(captured);
// The generic monitor, not this callback, reports thrown failures.
expect(mocks.report).not.toHaveBeenCalled();
expect(app.onConfigChange(configuration('accepted'), accepted)).toBe(true);
expect(app.currentSnapshot()).toBe(captured);
expect(mocks.prepare).toHaveBeenCalledTimes(3);
expect(app.onConfigChange(configuration('recovered'), accepted)).toBe(true);
expect(app.currentSnapshot().display.site.title).toBe('recovered');
expect(captured.display.site.title).toBe('accepted');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { isDeepStrictEqual } from 'node:util';
import { isSameConfiguration } from '@directory/config';
import type { DirectoryConfiguration } from '@directory/config/types';
import { createAuthentication, type Authentication } from './auth/index.js';
import { DirectoryError } from './directory-error.js';
import { prepareSnapshot } from './directory/snapshot.js';
import { reportDirectoryIssue } from './logger.js';
export type Application = ReturnType<typeof createApplication> & {
readonly publicDir: string;
stop(): void;
};
export interface ServerState {
application: Application | undefined;
}
export function createApplication(initial: DirectoryConfiguration) {
let snapshot = prepare(initial);
let authentication: Authentication | undefined;
try {
const auth = snapshot.configuration.auth;
if (auth !== undefined) authentication = createAuthentication(auth);
} catch {
throw new DirectoryError({ code: 'auth_failed' });
}
return {
currentSnapshot: () => snapshot,
authentication,
onConfigChange(next: DirectoryConfiguration, previous: DirectoryConfiguration): boolean {
if (!isDeepStrictEqual(next.auth, previous.auth)) {
reportDirectoryIssue({ code: 'restart_required' });
return false;
}
if (isSameConfiguration(next, previous)) return true;
snapshot = prepare(next);
return true;
}
};
}
function prepare(config: DirectoryConfiguration) {
try {
return prepareSnapshot(config);
} catch {
throw new DirectoryError({ code: 'prepare_failed' });
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isHttpError, isRedirect, redirect, type Handle, type RequestEvent } from '@sveltejs/kit';
import type { Session, SvelteKitAuthConfig } from '@auth/sveltekit';
import { validateConfig } from '@directory/config';
import { prepareSnapshot } from '../config/snapshot.js';
import { prepareSnapshot } from '../directory/snapshot.js';
import { GET as mountedImage } from '../../../routes/(protected)/(endpoints)/public/[...file]/+server.js';
import { createAuthentication } from './index.js';
+17 -4
View File
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process';
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
@@ -232,20 +232,33 @@ it('keeps application environment references and fallbacks stable across bootstr
expect(await server.stop()).toEqual({ code: 0, signal: null });
});
it('polls atomic saves, retains last good across invalid/deleted files, and shuts down cleanly', async () => {
it('polls atomic and ctime-only saves, retains last good across invalid/deleted files, and shuts down cleanly', async () => {
const server = await launchServer(source('first'));
const origin = await server.ready();
await expectHealthy(origin);
await expectPage(origin, 'first');
const replacement = `${server.path}.replacement`;
const fixedTime = new Date('2020-01-01T00:00:00Z');
await writeFile(replacement, source('second'));
await utimes(replacement, fixedTime, fixedTime);
await rename(replacement, server.path);
await vi.waitFor(() => expectPage(origin, 'second'), { timeout: 5000, interval: 50 });
// The HTTP response confirms the watcher observed the atomic save's fixed-mtime baseline.
const beforeEdit = await stat(server.path);
await writeFile(server.path, source('edited'));
await utimes(server.path, fixedTime, fixedTime);
const afterEdit = await stat(server.path);
expect(afterEdit.ino).toBe(beforeEdit.ino);
expect(afterEdit.size).toBe(beforeEdit.size);
expect(afterEdit.mtimeMs).toBe(beforeEdit.mtimeMs);
expect(afterEdit.ctimeMs).not.toBe(beforeEdit.ctimeMs);
await vi.waitFor(() => expectPage(origin, 'edited'), { timeout: 5000, interval: 50 });
await writeFile(server.path, `links: [\n${sentinel}`);
await vi.waitFor(() => expect(server.output()).toContain('load_failed'), { timeout: 5000 });
await expectPage(origin, 'second');
await expectPage(origin, 'edited');
await expectHealthy(origin);
const beforeDeletion = server.output().split('load_failed').length;
@@ -254,7 +267,7 @@ it('polls atomic saves, retains last good across invalid/deleted files, and shut
() => expect(server.output().split('load_failed').length).toBeGreaterThan(beforeDeletion),
{ timeout: 5000 }
);
await expectPage(origin, 'second');
await expectPage(origin, 'edited');
await expectHealthy(origin);
await writeFile(server.path, source('third'));
+154 -405
View File
@@ -1,459 +1,208 @@
import { inspect } from 'node:util';
import { resolve } from 'node:path';
import { ConfigError, validateConfig } from '@directory/config';
import type { DirectoryConfiguration } from '@directory/config/types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { reportConfigError } from './logger.js';
import type { Authentication } from './auth/index.js';
import { bootApp } from './boot.js';
import type { Directory } from './config/load-directory.js';
import { DirectoryError } from './directory-error.js';
import { prepareSnapshot } from './config/snapshot.js';
import { logger, reportDirectoryIssue, reportStartupFailure } from './logger.js';
const mocks = vi.hoisted(() => ({
auth: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
path: vi.fn<typeof import('@directory/config').resolveConfigPath>(),
publicDir: vi.fn<typeof import('@directory/config').resolvePublicDir>(),
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
directory: vi.fn<typeof import('./config/load-directory.js').loadDirectory>(),
watch: vi.fn<typeof import('./config/watch.js').watchConfig>(),
stopWatching: vi.fn<() => void>()
monitor: vi.fn<typeof import('@directory/config').startConfigReloading>(),
auth: vi.fn<typeof import('./auth/index.js').createAuthentication>(),
stop: vi.fn<() => void>()
}));
vi.mock('@directory/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@directory/config')>()),
resolveConfigPath: mocks.path,
resolvePublicDir: mocks.publicDir,
loadConfigFile: mocks.load
loadConfigFile: mocks.load,
startConfigReloading: mocks.monitor
}));
vi.mock('./config/load-directory.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config/load-directory.js')>()),
loadDirectory: mocks.directory
}));
vi.mock('./config/watch.js', () => ({ watchConfig: mocks.watch }));
vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.auth }));
const realDirectory = await vi.importActual<typeof import('./config/load-directory.js')>(
'./config/load-directory.js'
);
const realConfig = await vi.importActual<typeof import('@directory/config')>('@directory/config');
const sentinel = 'PRIVATE-environment-password-filename.yaml';
const path = '/config/directory.yaml';
const sentinel = 'PRIVATE-config-credential';
const origin = 'https://directory.example.com';
const frameworkEnv = { ORIGIN: origin, AUTH_URL: origin };
const authCases = [
{
strategy: 'credentials',
auth: { origin, credentials: { username: sentinel, password: sentinel } }
},
{
strategy: 'oidc',
auth: {
origin,
oidc: {
issuer: 'https://identity.example.com',
client_id: sentinel,
client_secret: sentinel
}
}
}
] satisfies { strategy: Authentication['strategy']; auth: DirectoryConfiguration['auth'] }[];
const auth = { origin, credentials: { username: 'family', password: sentinel } };
const authentication: Authentication = {
strategy: 'credentials',
handle: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn()
};
function authentication(strategy: Authentication['strategy']): Authentication {
return {
strategy,
handle: vi.fn<Authentication['handle']>().mockResolvedValue(new Response()),
signIn: vi.fn<Authentication['signIn']>().mockResolvedValue(undefined),
signOut: vi.fn<Authentication['signOut']>().mockResolvedValue(undefined)
};
}
function configuration(auth?: DirectoryConfiguration['auth']): DirectoryConfiguration {
const input: Record<string, unknown> = {
site: { title: 'Family', description: 'Welcome' },
links: { photos: { title: 'Photos', url: 'https://photos.example.com' } }
};
if (auth !== undefined) input.auth = auth;
return validateConfig(input, { env: {} });
}
function loadedDirectory(config = configuration()) {
return {
currentSnapshot: vi.fn().mockReturnValue(prepareSnapshot(config)),
reload: vi.fn<Directory['reload']>().mockResolvedValue(undefined),
stop: vi.fn<Directory['stop']>()
};
}
function expectRedacted(error: unknown): void {
for (const text of [String(error), inspect(error), JSON.stringify(error)]) {
expect(text).not.toContain(sentinel);
}
if (error instanceof Error) expect(error.stack).not.toContain(sentinel);
expect(error).not.toHaveProperty('cause');
function configuration(title = 'Home', enabled = false): DirectoryConfiguration {
return validateConfig({ site: { title }, links: {}, ...(enabled ? { auth } : {}) });
}
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
vi.stubEnv('ORIGIN', undefined);
vi.stubEnv('AUTH_URL', undefined);
mocks.auth.mockReset();
mocks.path.mockReset().mockReturnValue(`/config/${sentinel}`);
mocks.publicDir.mockReset().mockReturnValue('/mounted/public');
mocks.load.mockReset().mockResolvedValue(configuration());
mocks.directory.mockReset().mockResolvedValue(loadedDirectory());
mocks.watch.mockReset().mockReturnValue(mocks.stopWatching);
mocks.path.mockReturnValue(path);
mocks.publicDir.mockReturnValue('/mounted/public');
mocks.load.mockResolvedValue(configuration());
mocks.monitor.mockResolvedValue(mocks.stop);
mocks.auth.mockReturnValue(authentication);
});
afterEach(() => vi.unstubAllEnvs());
describe('bootApp', () => {
it('waits for valid startup, pins one path for loading and watching, then closes the save gap', async () => {
const pending = Promise.withResolvers<Directory>();
const directory = loadedDirectory();
mocks.directory.mockReturnValueOnce(pending.promise);
const env = {
CONFIG_PATH: `/config/${sentinel}`,
DIRECTORY_PUBLIC_DIR: '/mounted/public',
SECRET: sentinel
};
const boot = bootApp(env);
describe('application boot wiring', () => {
it('resolves paths once and connects the loader, application reaction and diagnostics', async () => {
const initial = configuration();
const pending = Promise.withResolvers<DirectoryConfiguration>();
mocks.load.mockReturnValueOnce(pending.promise);
const env = { CONFIG_PATH: path, DIRECTORY_PUBLIC_DIR: '/mounted/public' };
const startup = bootApp(env);
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env });
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { env });
expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
env,
report: reportDirectoryIssue
});
expect(mocks.watch).not.toHaveBeenCalled();
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(path, { env });
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
expect(mocks.monitor).not.toHaveBeenCalled();
env.CONFIG_PATH = '/config/changed.yaml';
env.DIRECTORY_PUBLIC_DIR = '/mounted/changed';
pending.resolve(directory);
const app = await boot;
expect(mocks.path).toHaveBeenCalledTimes(1);
expect(directory.currentSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.watch).toHaveBeenCalledExactlyOnceWith(
`/config/${sentinel}`,
expect.any(Function)
);
expect(mocks.watch.mock.invocationCallOrder[0]).toBeLessThan(
directory.reload.mock.invocationCallOrder[0]!
);
expect(directory.reload).toHaveBeenCalledTimes(1);
expect(app.directory).toBe(directory);
pending.resolve(initial);
const app = await startup;
expect(mocks.monitor).toHaveBeenCalledExactlyOnceWith({
path,
initial,
load: expect.any(Function),
onChange: app.onConfigChange,
onError: reportConfigError
});
expect(app.publicDir).toBe('/mounted/public');
expect(mocks.publicDir).toHaveBeenCalledTimes(1);
expect(app.authentication).toBeUndefined();
expect(mocks.auth).not.toHaveBeenCalled();
expect(app.directory.currentSnapshot()).toBe(directory.currentSnapshot.mock.results[0]?.value);
await mocks.watch.mock.calls[0]![1]();
await app.directory.reload();
expect(directory.reload).toHaveBeenCalledTimes(3);
expect(app.currentSnapshot().display.site.title).toBe('Home');
expect(mocks.path).toHaveBeenCalledOnce();
expect(mocks.publicDir).toHaveBeenCalledOnce();
const options = mocks.monitor.mock.calls[0]![0];
mocks.load.mockResolvedValueOnce(configuration('Updated'));
const next = await options.load(options.path);
expect(mocks.load).toHaveBeenLastCalledWith(path, { env });
expect(options.onChange(next, options.initial)).toBe(true);
expect(app.currentSnapshot().display.site.title).toBe('Updated');
expect(app.stop).toBe(mocks.stop);
app.stop();
expect(mocks.stopWatching).toHaveBeenCalledOnce();
expect(directory.stop).toHaveBeenCalledOnce();
expect(mocks.stop).toHaveBeenCalledOnce();
});
it.each([
[{}, '/deployment/public'],
[{ DIRECTORY_PUBLIC_DIR: 'mounted/images' }, resolve('mounted/images')],
[{ DIRECTORY_PUBLIC_DIR: '/mounted/images' }, '/mounted/images']
])(
'pins the resolved public directory without requiring it to exist: %j',
async (env, expected) => {
mocks.path.mockReturnValue('/deployment/config/directory.yaml');
mocks.publicDir.mockImplementation(realConfig.resolvePublicDir);
const app = await bootApp(env);
expect(app.publicDir).toBe(expected);
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith('/deployment/config/directory.yaml', {
env
});
app.stop();
}
);
it('passes the same default options to both path resolvers', async () => {
const app = await bootApp();
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env });
expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
env: process.env,
report: reportDirectoryIssue
});
expect(mocks.directory.mock.calls[0]![1]!.env).toBe(process.env);
expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
env: process.env
});
app.stop();
});
it('does not resolve boot until the post-watch reload finishes', async () => {
const pending = Promise.withResolvers<void>();
const reloading = Promise.withResolvers<void>();
const directory = loadedDirectory();
directory.reload.mockImplementationOnce(() => {
reloading.resolve();
it('waits for monitor reconciliation before returning the application', async () => {
const entered = Promise.withResolvers<void>();
const pending = Promise.withResolvers<() => void>();
mocks.monitor.mockImplementationOnce(() => {
entered.resolve();
return pending.promise;
});
mocks.directory.mockResolvedValueOnce(directory);
let ready = false;
const boot = bootApp().then((app) => {
ready = true;
return app;
});
await reloading.promise;
expect(directory.reload).toHaveBeenCalledOnce();
expect(ready).toBe(false);
pending.resolve();
const app = await boot;
expect(ready).toBe(true);
app.stop();
const ready = vi.fn();
const startup = bootApp().then(ready);
await entered.promise;
expect(ready).not.toHaveBeenCalled();
pending.resolve(mocks.stop);
await startup;
expect(ready).toHaveBeenCalledOnce();
});
it('propagates redacted invalid startup without starting a watcher', async () => {
const error = new DirectoryError({
code: 'load_failed',
issues: new ConfigError([{ code: 'missing_environment', path: `/links/${sentinel}/url` }])
.issues
});
mocks.directory.mockRejectedValueOnce(error);
await expect(bootApp()).rejects.toBe(error);
expectRedacted(error);
expect(mocks.watch).not.toHaveBeenCalled();
});
it('redacts unknown startup errors', async () => {
mocks.directory.mockRejectedValueOnce(new Error(sentinel));
const error: unknown = await bootApp().catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
expectRedacted(error);
expect(mocks.watch).not.toHaveBeenCalled();
});
it('redacts path resolution errors before loading or watching', async () => {
mocks.path.mockImplementationOnce(() => {
throw new Error(sentinel);
});
const error: unknown = await bootApp().catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expectRedacted(error);
expect(mocks.directory).not.toHaveBeenCalled();
expect(mocks.watch).not.toHaveBeenCalled();
});
it('redacts public directory resolution errors before loading or watching', async () => {
mocks.publicDir.mockImplementationOnce(() => {
throw new Error(sentinel);
});
const error: unknown = await bootApp({}).catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
expectRedacted(error);
expect(mocks.directory).not.toHaveBeenCalled();
expect(mocks.watch).not.toHaveBeenCalled();
});
it.each(authCases)(
'creates $strategy authentication once before watching, retaining it across the post-watch reload',
async ({ strategy, auth }) => {
vi.stubEnv('ORIGIN', origin);
const config = configuration(auth);
const replacement = validateConfig(
{ ...config, site: { ...config.site, title: 'Replacement' } },
{ env: {} }
);
const ready = authentication(strategy);
const env = {
AUTH_SECRET: sentinel.repeat(2),
ORIGIN: 'https://directory.example.com',
AUTH_URL: 'https://directory.example.com'
};
mocks.load.mockResolvedValueOnce(config).mockResolvedValue(replacement);
mocks.directory.mockImplementationOnce(realDirectory.loadDirectory);
mocks.auth.mockImplementationOnce((input) => {
expect(input).toEqual(config.auth);
expect(input).not.toBe(config.auth);
expect(Object.isFrozen(input)).toBe(true);
expect(Object.isFrozen('credentials' in input ? input.credentials : input.oidc)).toBe(true);
expect(mocks.watch).not.toHaveBeenCalled();
return ready;
});
const app = await bootApp(env, frameworkEnv);
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(expect.any(Object));
expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan(
mocks.watch.mock.invocationCallOrder[0]!
);
expect(mocks.load).toHaveBeenCalledTimes(2);
expect(app.directory.currentSnapshot().display.site.title).toBe('Replacement');
expect(app.authentication).toBe(ready);
await app.directory.reload();
expect(app.authentication).toBe(ready);
expect(mocks.auth).toHaveBeenCalledOnce();
app.stop();
expect(mocks.stopWatching).toHaveBeenCalledOnce();
}
);
it.each([
{ name: 'auth enabled after preparation', auth: authCases[0]!.auth, env: {} },
{ name: 'auth disabled after preparation', auth: undefined, env: frameworkEnv },
{
name: 'origin changed after preparation',
auth: authCases[0]!.auth,
env: {
ORIGIN: `https://${sentinel}.example.com`,
AUTH_URL: `https://${sentinel}.example.com`
}
},
{
name: 'Kit private ORIGIN mismatch',
auth: authCases[0]!.auth,
env: { ORIGIN: sentinel, AUTH_URL: origin }
},
{
name: 'Kit private AUTH_URL mismatch',
auth: authCases[0]!.auth,
env: { ORIGIN: origin, AUTH_URL: sentinel }
}
])('rejects $name before authentication, watching or reloading', async ({ auth, env }) => {
vi.stubEnv('ORIGIN', auth?.origin);
const directory = loadedDirectory(configuration(auth));
mocks.directory.mockResolvedValueOnce(directory);
const error: unknown = await bootApp({}, env).catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
expectRedacted(error);
expect(mocks.auth).not.toHaveBeenCalled();
expect(directory.stop).toHaveBeenCalledOnce();
expect(directory.reload).not.toHaveBeenCalled();
expect(mocks.watch).not.toHaveBeenCalled();
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
try {
reportStartupFailure(error);
expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).');
} finally {
output.mockRestore();
}
});
it('accepts empty framework origins when authentication is absent', async () => {
const app = await bootApp({}, { ORIGIN: '', AUTH_URL: '' });
expect(app.authentication).toBeUndefined();
expect(mocks.auth).not.toHaveBeenCalled();
app.stop();
});
it('passes explicit source env to the loader separately from prepared process and Kit private origins', async () => {
const source = { ORIGIN: sentinel, AUTH_URL: sentinel, PASSWORD: sentinel };
it('constructs auth before monitoring and uses Kit private URLs, not interpolation values', async () => {
vi.stubEnv('ORIGIN', origin);
vi.stubEnv('AUTH_URL', 'https://different.example.com');
const directory = loadedDirectory(configuration(authCases[0]!.auth));
mocks.directory.mockResolvedValueOnce(directory);
const ready = authentication('credentials');
mocks.auth.mockReturnValueOnce(ready);
const app = await bootApp(source, frameworkEnv);
expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {
env: source,
report: reportDirectoryIssue
});
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: source });
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(
directory.currentSnapshot().configuration.auth
vi.stubEnv('AUTH_URL', 'https://not-kit.example.com');
mocks.load.mockResolvedValueOnce(configuration('Home', true));
const env = { ORIGIN: sentinel, AUTH_URL: sentinel };
const app = await bootApp(env, frameworkEnv);
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env });
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(auth);
expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan(
mocks.monitor.mock.invocationCallOrder[0]!
);
expect(app.authentication).toBe(ready);
app.stop();
expect(app.authentication).toBe(authentication);
});
it('rejects a process origin mismatch even when Kit private env matches YAML', async () => {
vi.stubEnv('ORIGIN', `https://${sentinel}.example.com`);
const directory = loadedDirectory(configuration(authCases[0]!.auth));
mocks.directory.mockResolvedValueOnce(directory);
const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
expectRedacted(error);
expect(directory.stop).toHaveBeenCalledOnce();
expect(directory.reload).not.toHaveBeenCalled();
expect(mocks.auth).not.toHaveBeenCalled();
expect(mocks.watch).not.toHaveBeenCalled();
});
it('checks process.env by default rather than source interpolation origins', async () => {
// Keep the adapter origin valid so only the default SDK environment can reject this boot.
vi.stubEnv('ORIGIN', origin);
const directory = loadedDirectory(configuration(authCases[0]!.auth));
mocks.directory.mockResolvedValueOnce(directory);
const error: unknown = await bootApp(frameworkEnv).catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
expectRedacted(error);
expect(directory.stop).toHaveBeenCalledOnce();
expect(mocks.auth).not.toHaveBeenCalled();
expect(mocks.watch).not.toHaveBeenCalled();
});
it.each(authCases)(
'redacts unknown $strategy provider initialization failures before watching or reloading',
async ({ auth }) => {
vi.stubEnv('ORIGIN', origin);
const directory = loadedDirectory(configuration(auth));
mocks.directory.mockResolvedValueOnce(directory);
mocks.auth.mockImplementationOnce(() => {
throw new Error(sentinel, { cause: sentinel });
it.each([
{ name: 'auth added', enabled: true, prepared: undefined, privateEnv: {} },
{ name: 'auth removed', enabled: false, prepared: origin, privateEnv: frameworkEnv },
{
name: 'prepared process origin changed',
enabled: true,
prepared: 'https://other.example.com',
privateEnv: frameworkEnv
},
{
name: 'Kit private ORIGIN differs',
enabled: true,
prepared: origin,
privateEnv: { ...frameworkEnv, ORIGIN: sentinel }
},
{
name: 'Kit private AUTH_URL differs',
enabled: true,
prepared: origin,
privateEnv: { ...frameworkEnv, AUTH_URL: sentinel }
}
])(
'fails closed when $name before auth or monitoring',
async ({ enabled, prepared, privateEnv }) => {
vi.stubEnv('ORIGIN', prepared);
mocks.load.mockResolvedValueOnce(configuration('Home', enabled));
await expect(bootApp({}, privateEnv)).rejects.toMatchObject({
issue: { code: 'auth_failed' }
});
const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error);
expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(
directory.currentSnapshot.mock.results[0]?.value.configuration.auth
);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' });
expectRedacted(error);
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
try {
reportStartupFailure(error);
expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).');
} finally {
output.mockRestore();
}
expect(directory.stop).toHaveBeenCalledOnce();
expect(directory.reload).not.toHaveBeenCalled();
expect(mocks.watch).not.toHaveBeenCalled();
expect(mocks.auth).not.toHaveBeenCalled();
expect(mocks.monitor).not.toHaveBeenCalled();
}
);
it('stops the loaded directory and redacts a watcher setup failure', async () => {
const directory = loadedDirectory();
mocks.directory.mockResolvedValueOnce(directory);
mocks.watch.mockImplementationOnce(() => {
throw new Error(sentinel);
});
const error: unknown = await bootApp().catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'watch_failed' });
expectRedacted(error);
expect(directory.stop).toHaveBeenCalledOnce();
expect(directory.reload).not.toHaveBeenCalled();
it('uses the process environment when no environments are injected', async () => {
await bootApp();
expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env });
expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env: process.env });
});
it('stops watching and prevents a pending real directory reload from publishing', async () => {
mocks.directory.mockImplementationOnce(realDirectory.loadDirectory);
const app = await bootApp({});
expect(app.authentication).toBeUndefined();
expect(mocks.auth).not.toHaveBeenCalled();
const original = app.directory.currentSnapshot();
const pending = Promise.withResolvers<DirectoryConfiguration>();
const loading = Promise.withResolvers<void>();
mocks.load.mockImplementationOnce(() => {
loading.resolve();
return pending.promise;
it('retains safe loader diagnostics without starting monitoring', async () => {
const failure = new ConfigError([
{ code: 'missing_environment', path: `/links/${sentinel}/url` }
]);
mocks.load.mockRejectedValueOnce(failure);
const error: unknown = await bootApp().catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect(error).toMatchObject({ issue: { code: 'load_failed', issues: failure.issues } });
expect(JSON.stringify(error)).not.toContain(sentinel);
expect(mocks.monitor).not.toHaveBeenCalled();
});
it.each(['path', 'publicDir', 'load'] as const)(
'redacts unexpected %s failures',
async (stage) => {
mocks[stage].mockImplementationOnce(() => {
throw new Error(sentinel);
});
const error: unknown = await bootApp().catch((error: unknown) => error);
expect(error).toMatchObject({ issue: { code: 'load_failed' } });
expect(String(error)).not.toContain(sentinel);
expect(error).not.toHaveProperty('cause');
expect(mocks.monitor).not.toHaveBeenCalled();
}
);
it('does not start monitoring when authentication initialization fails', async () => {
vi.stubEnv('ORIGIN', origin);
mocks.load.mockResolvedValueOnce(configuration('Home', true));
mocks.auth.mockImplementationOnce(() => {
throw new Error(sentinel);
});
const reload = mocks.watch.mock.calls[0]![1]();
await loading.promise;
app.stop();
pending.resolve(validateConfig({ site: { title: 'Replacement' }, links: {} }, { env: {} }));
await reload;
expect(mocks.stopWatching).toHaveBeenCalledOnce();
expect(app.directory.currentSnapshot()).toBe(original);
expect(original.display.site.title).toBe('Family');
await app.directory.reload();
expect(mocks.load).toHaveBeenCalledTimes(3);
await expect(bootApp({}, frameworkEnv)).rejects.toMatchObject({
issue: { code: 'auth_failed' }
});
expect(mocks.monitor).not.toHaveBeenCalled();
});
it('redacts monitor setup errors at the application boundary', async () => {
mocks.monitor.mockRejectedValueOnce(new Error(sentinel));
const error: unknown = await bootApp().catch((error: unknown) => error);
expect(error).toMatchObject({ issue: { code: 'watch_failed' } });
expect(String(error)).not.toContain(sentinel);
expect(error).not.toHaveProperty('cause');
});
});
+32 -40
View File
@@ -1,11 +1,14 @@
import { resolveConfigPath, resolvePublicDir } from '@directory/config';
import type { ConfigEnvironment } from '@directory/config/types';
import { createAuthentication, type Authentication } from './auth/index.js';
import { loadDirectory, type Directory } from './config/load-directory.js';
import {
ConfigError,
loadConfigFile,
resolveConfigPath,
resolvePublicDir,
startConfigReloading
} from '@directory/config';
import type { ConfigEnvironment, DirectoryConfiguration } from '@directory/config/types';
import { createApplication, type Application } from './application.js';
import { DirectoryError } from './directory-error.js';
import type { Application } from './lifecycle/application.js';
import { watchConfig } from './config/watch.js';
import { reportDirectoryIssue } from './logger.js';
import { reportConfigError } from './logger.js';
export async function bootApp(
env: ConfigEnvironment = process.env,
@@ -14,50 +17,39 @@ export async function bootApp(
const options = { env };
let path: string;
let publicDir: string;
let directory: Directory;
let config: DirectoryConfiguration;
try {
path = resolveConfigPath(options);
publicDir = resolvePublicDir(path, options);
directory = await loadDirectory(path, { ...options, report: reportDirectoryIssue });
config = await loadConfigFile(path, options);
} catch (error) {
if (error instanceof DirectoryError) throw error;
throw new DirectoryError({ code: 'load_failed' });
throw new DirectoryError({
code: 'load_failed',
...(error instanceof ConfigError ? { issues: error.issues } : {})
});
}
let authentication: Authentication | undefined;
try {
const auth = directory.currentSnapshot().configuration.auth;
// Reject edits between URL preparation and Kit startup, including enabling/disabling auth.
if (
auth?.origin !== (process.env.ORIGIN || undefined) ||
auth?.origin !== (frameworkEnv.ORIGIN || undefined) ||
auth?.origin !== (frameworkEnv.AUTH_URL || undefined)
) {
throw new DirectoryError({ code: 'auth_failed' });
}
if (auth !== undefined) authentication = createAuthentication(auth);
} catch {
directory.stop();
// Reject changes between framework URL preparation and the initial application load.
if (
config.auth?.origin !== (process.env.ORIGIN || undefined) ||
config.auth?.origin !== (frameworkEnv.ORIGIN || undefined) ||
config.auth?.origin !== (frameworkEnv.AUTH_URL || undefined)
) {
throw new DirectoryError({ code: 'auth_failed' });
}
let stopWatching: () => void;
const application = createApplication(config);
let stop: () => void;
try {
stopWatching = watchConfig(path, () => directory.reload());
stop = await startConfigReloading({
path,
initial: config,
load: (path) => loadConfigFile(path, options),
onChange: application.onConfigChange,
onError: reportConfigError
});
} catch {
directory.stop();
throw new DirectoryError({ code: 'watch_failed' });
}
// Catch a save between the initial read and installation of the path watcher.
await directory.reload();
return {
directory,
publicDir,
authentication,
stop() {
stopWatching();
directory.stop();
}
};
return { ...application, publicDir, stop };
}
@@ -1,718 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { inspect } from 'node:util';
import { ConfigError, validateConfig } from '@directory/config';
import type { DirectoryConfiguration } from '@directory/config/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DirectoryError, type DirectoryIssue } from '../directory-error.js';
import { loadDirectory } from './load-directory.js';
import type { PreparedSnapshot } from './snapshot.js';
const mocks = vi.hoisted(() => ({
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
prepare: vi.fn<(config: DirectoryConfiguration) => PreparedSnapshot | Promise<PreparedSnapshot>>()
}));
vi.mock('@directory/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@directory/config')>()),
loadConfigFile: mocks.load
}));
vi.mock('./snapshot.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./snapshot.js')>()),
prepareSnapshot: mocks.prepare
}));
const realSnapshot = await vi.importActual<typeof import('./snapshot.js')>('./snapshot.js');
const sentinel = 'PRIVATE-password-env-filename.yml';
const credentials = {
origin: 'https://directory.example.com',
credentials: { username: 'family', password: sentinel }
};
const oidc = {
origin: 'https://directory.example.com',
oidc: {
issuer: 'https://identity.example.com',
client_id: 'directory',
client_secret: sentinel
}
};
function configuration(
title = 'boot',
auth?: DirectoryConfiguration['auth']
): DirectoryConfiguration {
return validateConfig(
{
site: { title, logo_path: `/public/${title}.svg` },
...(auth === undefined ? {} : { auth }),
links: {
photos: {
title: `${title} photos`,
url: 'https://photos.example.com',
logo_path: `/public/${title}-photos.svg`,
tags: [' photos ', ' backup ']
}
}
},
{ env: {} }
);
}
function expectRedacted(value: unknown): void {
for (const text of [String(value), JSON.stringify(value), inspect(value)]) {
expect(text).not.toContain(sentinel);
}
if (value instanceof Error) expect(value.stack).not.toContain(sentinel);
expect(value).not.toHaveProperty('cause');
expect(value).not.toHaveProperty('value');
expect(value).not.toHaveProperty('path');
}
beforeEach(() => {
mocks.load.mockReset().mockResolvedValue(configuration());
mocks.prepare.mockReset().mockImplementation(realSnapshot.prepareSnapshot);
});
describe('directory startup', () => {
it('returns a directory only after loading and preparation have completed', async () => {
const loaded = Promise.withResolvers<DirectoryConfiguration>();
const prepared = Promise.withResolvers<PreparedSnapshot>();
const preparing = Promise.withResolvers<void>();
mocks.load.mockReturnValueOnce(loaded.promise);
mocks.prepare.mockImplementationOnce(() => {
preparing.resolve();
return prepared.promise;
});
let returned = false;
const startup = loadDirectory().then((directory) => {
returned = true;
return directory;
});
expect(returned).toBe(false);
expect(mocks.prepare).not.toHaveBeenCalled();
const config = configuration();
loaded.resolve(config);
await preparing.promise;
expect(returned).toBe(false);
const snapshot = realSnapshot.prepareSnapshot(config);
prepared.resolve(snapshot);
const directory = await startup;
const current: PreparedSnapshot = directory.currentSnapshot();
expect(current).toBe(snapshot);
expect(current.display.site.title).toBe('boot');
expect(directory).not.toHaveProperty('start');
});
it('rejects invalid startup load with only safe, frozen loader diagnostics', async () => {
const report = vi.fn();
const failure = new ConfigError([
{ code: 'missing_environment', path: `/links/${sentinel}/url` }
]);
mocks.load.mockRejectedValueOnce(failure);
const error: unknown = await loadDirectory(undefined, { report }).catch(
(error: unknown) => error
);
expect(error).toBeInstanceOf(DirectoryError);
const issue = (error as DirectoryError).issue;
expect(issue).toEqual({ code: 'load_failed', issues: failure.issues });
expect(Object.isFrozen(issue)).toBe(true);
expect(Object.isFrozen(issue.issues)).toBe(true);
expect(Object.isFrozen(issue.issues?.[0])).toBe(true);
expectRedacted(error);
expect(report).not.toHaveBeenCalled();
expect(mocks.prepare).not.toHaveBeenCalled();
});
it('redacts an unknown read failure at startup', async () => {
const failure = Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
mocks.load.mockRejectedValueOnce(failure);
const error: unknown = await loadDirectory(sentinel).catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
expectRedacted(error);
});
it('redacts a preparation failure at startup', async () => {
mocks.prepare.mockImplementationOnce(() => {
throw Object.assign(new Error(sentinel), { cause: sentinel, value: sentinel });
});
const error: unknown = await loadDirectory().catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'prepare_failed' });
expectRedacted(error);
});
it('redacts path resolution failure before attempting a read', async () => {
const cwd = vi.spyOn(process, 'cwd').mockImplementationOnce(() => {
throw new Error(sentinel);
});
const startup = loadDirectory();
cwd.mockRestore();
const error: unknown = await startup.catch((error: unknown) => error);
expect(error).toBeInstanceOf(DirectoryError);
expect((error as DirectoryError).issue).toEqual({ code: 'load_failed' });
expectRedacted(error);
expect(mocks.load).not.toHaveBeenCalled();
});
it('resolves explicit and default paths once and forwards the selected environment', async () => {
const report = vi.fn();
const env = { CONFIG_PATH: 'config/first.yaml', PASSWORD: sentinel };
const directory = await loadDirectory(undefined, { env, report });
const pinned = resolve('config/first.yaml');
expect(mocks.load).toHaveBeenLastCalledWith(pinned, { env });
env.CONFIG_PATH = 'config/second.yaml';
await directory.reload();
expect(mocks.load).toHaveBeenLastCalledWith(pinned, { env });
expect(mocks.load.mock.calls[1]?.[1]?.env).toBe(env);
const explicit = await loadDirectory('custom/directory.yaml', { env, report });
await explicit.reload();
expect(mocks.load).toHaveBeenLastCalledWith(resolve('custom/directory.yaml'), { env });
expect(report).not.toHaveBeenCalled();
});
});
describe('filesystem integration', () => {
it('loads disposable YAML with env and defaults, retains last good after an invalid edit, and recovers', async ({
onTestFinished
}) => {
const temporary = await mkdtemp(join(tmpdir(), 'directory-directory-'));
onTestFinished(() => rm(temporary, { recursive: true, force: true }));
const configPath = join(temporary, sentinel);
const source = `
site:
title: \${DIRECTORY_TITLE}
logo_path: /public/boot.svg
auth:
origin: https://directory.example.com
credentials:
username: family
password: \${DIRECTORY_PASSWORD}
links:
photos:
title: Photos
url: https://photos.example.com
tags: [photos, backup]
`;
await writeFile(configPath, source);
const realConfig =
await vi.importActual<typeof import('@directory/config')>('@directory/config');
mocks.load.mockImplementation(realConfig.loadConfigFile);
const report = vi.fn<(issue: DirectoryIssue) => void>();
const env = {
CONFIG_PATH: configPath,
DIRECTORY_TITLE: 'Filesystem boot',
DIRECTORY_PASSWORD: sentinel
};
const directory = await loadDirectory(undefined, { env, report });
onTestFinished(() => directory.stop());
const boot = directory.currentSnapshot();
expect(boot.configuration.site.title).toBe('Filesystem boot');
expect(boot.configuration.auth?.credentials?.password).toBe(sentinel);
expect(boot.configuration.auth).not.toHaveProperty('secret');
expect(boot.display.site).toEqual({
title: 'Filesystem boot',
description: '',
footer: '',
logo_path: '/public/boot.svg',
favicon_path: '/favicon.svg',
open_links_in_new_tab: true
});
expect(boot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
expect(boot.assets.brandingPaths).toEqual(['/public/boot.svg']);
expect(JSON.stringify(boot.display)).not.toContain(sentinel);
await writeFile(
configPath,
'# Same values, different formatting.\n' +
source.replace('tags: [photos, backup]', 'tags:\n - photos\n - backup')
);
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(mocks.prepare).toHaveBeenCalledOnce();
expect(report).not.toHaveBeenCalled();
await writeFile(configPath, `site: [\n${sentinel}`);
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(report).toHaveBeenCalledExactlyOnceWith({
code: 'load_failed',
issues: [{ code: 'invalid_yaml', path: '/', message: 'Invalid YAML document.' }]
});
expectRedacted(report.mock.calls[0]?.[0]);
env.CONFIG_PATH = join(temporary, 'does-not-exist.yaml');
env.DIRECTORY_TITLE = 'Filesystem recovered';
await writeFile(configPath, source.replace('boot.svg', 'recovered.svg'));
await directory.reload();
const recovered = directory.currentSnapshot();
expect(recovered).not.toBe(boot);
expect(recovered.configuration.site.title).toBe('Filesystem recovered');
expect(recovered.configuration.auth).toEqual(boot.configuration.auth);
expect(recovered.configuration.auth).not.toHaveProperty('secret');
expect(recovered.display.site.title).toBe('Filesystem recovered');
expect(recovered.assets.referencedPaths).toEqual(['/public/recovered.svg']);
expect(recovered.display.snapshotId).not.toBe(boot.display.snapshotId);
expect(boot.display.site.title).toBe('Filesystem boot');
expect(boot.assets.referencedPaths).toEqual(['/public/boot.svg']);
expect(report).toHaveBeenCalledTimes(1);
const explicit = await loadDirectory(configPath, { env, report });
onTestFinished(() => explicit.stop());
expect(explicit.currentSnapshot().display.site.title).toBe('Filesystem recovered');
expect(report).toHaveBeenCalledTimes(1);
});
});
describe('owned snapshot reloads', () => {
it('keeps the snapshot and revision when independently loaded values are unchanged', async () => {
const report = vi.fn();
const directory = await loadDirectory(undefined, { report });
const current = directory.currentSnapshot();
mocks.load.mockResolvedValueOnce(configuration());
await directory.reload();
expect(mocks.load).toHaveBeenCalledTimes(2);
expect(mocks.prepare).toHaveBeenCalledOnce();
expect(directory.currentSnapshot()).toBe(current);
expect(directory.currentSnapshot().display.snapshotId).toBe(current.display.snapshotId);
expect(report).not.toHaveBeenCalled();
});
it('compares unchanged values with the latest accepted snapshot, not just boot', async () => {
const directory = await loadDirectory();
mocks.load.mockResolvedValueOnce(configuration('next'));
await directory.reload();
const current = directory.currentSnapshot();
mocks.load.mockResolvedValueOnce(configuration('next'));
await directory.reload();
expect(directory.currentSnapshot()).toBe(current);
expect(mocks.prepare).toHaveBeenCalledTimes(2);
});
it('ignores site mapping-key order when values are unchanged', async () => {
const directory = await loadDirectory();
const current = directory.currentSnapshot();
const reordered = configuration();
reordered.site = Object.fromEntries(
Object.entries(reordered.site).reverse()
) as DirectoryConfiguration['site'];
mocks.load.mockResolvedValueOnce(reordered);
await directory.reload();
expect(directory.currentSnapshot()).toBe(current);
expect(mocks.prepare).toHaveBeenCalledOnce();
});
it('publishes a flat bookmark reorder even when all field values are equal', async () => {
const initial = validateConfig(
{
links: {
zebra: { title: 'Zebra', url: 'https://zebra.example.com' },
alpha: { title: 'Alpha', url: 'https://alpha.example.com' }
}
},
{ env: {} }
);
mocks.load.mockResolvedValueOnce(initial);
const directory = await loadDirectory();
const current = directory.currentSnapshot();
const reordered = {
...initial,
links: { alpha: initial.links.alpha!, zebra: initial.links.zebra! }
};
expect(reordered).toEqual(initial);
mocks.load.mockResolvedValueOnce(reordered);
await directory.reload();
const next = directory.currentSnapshot();
expect(next).not.toBe(current);
expect(next.display.snapshotId).not.toBe(current.display.snapshotId);
expect(next.display.groups[0]!.links.map((link) => link.id)).toEqual(['alpha', 'zebra']);
expect(current.display.groups[0]!.links.map((link) => link.id)).toEqual(['zebra', 'alpha']);
expect(mocks.prepare).toHaveBeenCalledTimes(2);
});
it('publishes group and within-group order changes derived from bookmark declarations', async () => {
const initial = validateConfig(
{
links: {
zebra: { title: 'Zebra', url: 'https://zebra.example.com', group: 'Tools' },
alpha: { title: 'Alpha', url: 'https://alpha.example.com', group: 'Family' },
beta: { title: 'Beta', url: 'https://beta.example.com', group: 'Tools' }
}
},
{ env: {} }
);
mocks.load.mockResolvedValueOnce(initial);
const directory = await loadDirectory();
const current = directory.currentSnapshot();
const reordered = {
...initial,
links: {
alpha: initial.links.alpha!,
beta: initial.links.beta!,
zebra: initial.links.zebra!
}
};
expect(reordered).toEqual(initial);
mocks.load.mockResolvedValueOnce(reordered);
await directory.reload();
const next = directory.currentSnapshot();
expect(next).not.toBe(current);
expect(next.display.groups.map((group) => group.label)).toEqual(['Family', 'Tools']);
expect(next.display.groups.map((group) => group.links.map((link) => link.id))).toEqual([
['alpha'],
['beta', 'zebra']
]);
expect(mocks.prepare).toHaveBeenCalledTimes(2);
});
it('retains the last-good revision when a rejected edit is restored to the same values', async () => {
const report = vi.fn();
const directory = await loadDirectory(undefined, { report });
const current = directory.currentSnapshot();
mocks.load.mockRejectedValueOnce(new ConfigError([{ code: 'invalid_yaml', path: '/' }]));
await directory.reload();
mocks.load.mockResolvedValueOnce(configuration());
await directory.reload();
expect(directory.currentSnapshot()).toBe(current);
expect(mocks.prepare).toHaveBeenCalledOnce();
expect(report).toHaveBeenCalledTimes(1);
});
it('serializes load and preparation, coalescing bursts into one pending follow-up', async () => {
const directory = await loadDirectory();
const boot = directory.currentSnapshot();
const firstLoad = Promise.withResolvers<DirectoryConfiguration>();
const firstPrepare = Promise.withResolvers<void>();
const preparing = Promise.withResolvers<void>();
const secondLoad = Promise.withResolvers<DirectoryConfiguration>();
const loadingAgain = Promise.withResolvers<void>();
mocks.load.mockReturnValueOnce(firstLoad.promise).mockImplementationOnce(() => {
loadingAgain.resolve();
return secondLoad.promise;
});
mocks.prepare.mockImplementationOnce((config) => {
preparing.resolve();
return firstPrepare.promise.then(() => realSnapshot.prepareSnapshot(config));
});
const run = directory.reload();
await Promise.resolve();
for (let i = 0; i < 10; i++) expect(directory.reload()).toBe(run);
expect(mocks.load).toHaveBeenCalledTimes(2);
expect(mocks.prepare).toHaveBeenCalledTimes(1);
firstLoad.resolve(configuration('one'));
await preparing.promise;
for (let i = 0; i < 10; i++) expect(directory.reload()).toBe(run);
expect(mocks.load).toHaveBeenCalledTimes(2);
expect(directory.currentSnapshot()).toBe(boot);
firstPrepare.resolve();
await loadingAgain.promise;
expect(directory.currentSnapshot().display.site.title).toBe('one');
expect(mocks.prepare).toHaveBeenCalledTimes(2);
secondLoad.resolve(configuration('two'));
await run;
expect(mocks.load).toHaveBeenCalledTimes(3);
expect(mocks.prepare).toHaveBeenCalledTimes(3);
expect(directory.currentSnapshot().display.site.title).toBe('two');
});
it('processes a queued unchanged follow-up without preparing or publishing again', async () => {
const directory = await loadDirectory();
const firstLoad = Promise.withResolvers<DirectoryConfiguration>();
const secondLoad = Promise.withResolvers<DirectoryConfiguration>();
const loadingAgain = Promise.withResolvers<void>();
mocks.load.mockReturnValueOnce(firstLoad.promise).mockImplementationOnce(() => {
loadingAgain.resolve();
return secondLoad.promise;
});
const run = directory.reload();
await Promise.resolve();
expect(directory.reload()).toBe(run);
firstLoad.resolve(configuration('next'));
await loadingAgain.promise;
const current = directory.currentSnapshot();
expect(current.display.site.title).toBe('next');
secondLoad.resolve(configuration('next'));
await run;
expect(directory.currentSnapshot()).toBe(current);
expect(mocks.load).toHaveBeenCalledTimes(3);
expect(mocks.prepare).toHaveBeenCalledTimes(2);
});
it('keeps complete old captures while replacing private configuration, display and assets together', async () => {
mocks.load.mockResolvedValueOnce(configuration('boot', credentials));
const directory = await loadDirectory();
const captured = directory.currentSnapshot();
mocks.load.mockResolvedValueOnce(configuration('next', credentials));
await directory.reload();
const current = directory.currentSnapshot();
expect(current).not.toBe(captured);
expect(current.configuration.site.title).toBe('next');
expect(current.display.site.title).toBe('next');
expect(current.display.groups[0]?.links[0]?.title).toBe('next photos');
expect(current.assets).toEqual({
brandingPaths: ['/public/next.svg'],
referencedPaths: ['/public/next.svg', '/public/next-photos.svg'],
bookmarkLogos: realSnapshot.prepareSnapshot(configuration('next', credentials)).assets
.bookmarkLogos
});
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
expect(captured.configuration.site.title).toBe('boot');
expect(captured.display.site.title).toBe('boot');
expect(captured.assets.bookmarkLogos.photos).toBe(
realSnapshot.prepareSnapshot(configuration('boot', credentials)).assets.bookmarkLogos.photos
);
expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos);
expect(captured.assets.referencedPaths).toEqual([
'/public/boot.svg',
'/public/boot-photos.svg'
]);
expect(JSON.stringify(current.display)).not.toContain(sentinel);
expect(JSON.stringify(current.configuration)).toContain(sentinel);
expect(Object.isFrozen(current)).toBe(true);
});
it('retains last good on load and preparation failures, then recovers', async () => {
const report = vi.fn<(issue: DirectoryIssue) => void>();
const directory = await loadDirectory(undefined, { report });
const captured = directory.currentSnapshot();
const invalid = new ConfigError([{ code: 'invalid_yaml', path: '/' }]);
mocks.load.mockRejectedValueOnce(invalid);
await directory.reload();
expect(directory.currentSnapshot()).toBe(captured);
expect(report).toHaveBeenLastCalledWith({ code: 'load_failed', issues: invalid.issues });
mocks.load.mockResolvedValueOnce(configuration('preparation-fails'));
mocks.prepare.mockImplementationOnce(() => {
throw Object.assign(new Error(sentinel), { filename: sentinel, value: sentinel });
});
await directory.reload();
expect(directory.currentSnapshot()).toBe(captured);
expect(report).toHaveBeenLastCalledWith({ code: 'prepare_failed' });
for (const [issue] of report.mock.calls) {
expect(Object.isFrozen(issue)).toBe(true);
expectRedacted(issue);
}
mocks.load.mockResolvedValueOnce(configuration('recovered'));
await directory.reload();
expect(directory.currentSnapshot().display.site.title).toBe('recovered');
expect(directory.currentSnapshot()).not.toBe(captured);
});
it('runs a queued follow-up after failure even when synchronous reporting throws', async () => {
const report = vi.fn(() => {
throw new Error(sentinel);
});
const directory = await loadDirectory(undefined, { report });
const pending = Promise.withResolvers<DirectoryConfiguration>();
mocks.load.mockReturnValueOnce(pending.promise).mockResolvedValue(configuration('recovered'));
const run = directory.reload();
await Promise.resolve();
expect(directory.reload()).toBe(run);
pending.reject(new Error(sentinel));
await expect(run).resolves.toBeUndefined();
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'load_failed' });
expect(directory.currentSnapshot().display.site.title).toBe('recovered');
await directory.reload();
expect(mocks.load).toHaveBeenCalledTimes(4);
});
});
describe('boot authentication', () => {
it.each([
{
change: 'origin change',
bootAuth: credentials,
nextAuth: { ...credentials, origin: 'https://other.example.com' }
},
{
change: 'secret rotation',
bootAuth: { ...oidc, secret: `${sentinel}-session` },
nextAuth: { ...oidc, secret: `${sentinel}-replacement` }
}
])(
'requires restart for $change and retains the entire last-good snapshot',
async ({ bootAuth, nextAuth }) => {
const report = vi.fn();
mocks.load.mockResolvedValueOnce(configuration('boot', bootAuth));
const directory = await loadDirectory(undefined, { report });
mocks.load.mockResolvedValueOnce(configuration('accepted', bootAuth));
await directory.reload();
const accepted = directory.currentSnapshot();
const captured = structuredClone(accepted);
mocks.load.mockResolvedValueOnce(configuration('rejected', nextAuth));
await directory.reload();
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
expectRedacted(report.mock.calls);
expect(directory.currentSnapshot()).toBe(accepted);
expect(directory.currentSnapshot()).toEqual(captured);
mocks.load.mockResolvedValueOnce(configuration('restored', bootAuth));
await directory.reload();
const restored = directory.currentSnapshot();
expect(restored).not.toBe(accepted);
expect(restored.configuration.auth).toEqual(bootAuth);
expect(restored.display.site.title).toBe('restored');
expect(restored.display.groups[0]?.links[0]?.title).toBe('restored photos');
expect(restored.assets.referencedPaths).toEqual([
'/public/restored.svg',
'/public/restored-photos.svg'
]);
expect(report).toHaveBeenCalledTimes(1);
}
);
it('rejects enabling auth on an unauthenticated boot, then accepts restored absent auth', async () => {
const report = vi.fn();
const directory = await loadDirectory(undefined, { report });
const boot = directory.currentSnapshot();
mocks.load.mockResolvedValueOnce(configuration('rejected', credentials));
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(mocks.prepare).toHaveBeenCalledTimes(1);
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
mocks.load.mockResolvedValueOnce(configuration('restored'));
await directory.reload();
expect(directory.currentSnapshot().display.site.title).toBe('restored');
});
it('rejects changed or disabled boot auth as a whole and accepts structural restoration in a different key order', async () => {
const report = vi.fn();
mocks.load.mockResolvedValueOnce(configuration('boot', oidc));
const directory = await loadDirectory(undefined, { report });
const boot = directory.currentSnapshot();
mocks.load.mockResolvedValueOnce(
configuration('changed', { ...oidc, oidc: { ...oidc.oidc, client_secret: 'changed' } })
);
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
mocks.load.mockResolvedValueOnce(configuration('disabled'));
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(mocks.prepare).toHaveBeenCalledTimes(1);
expect(report.mock.calls).toEqual([
[{ code: 'restart_required' }],
[{ code: 'restart_required' }]
]);
expectRedacted(report.mock.calls);
const restored = configuration('restored', oidc);
restored.auth = {
oidc: {
client_secret: sentinel,
client_id: 'directory',
issuer: 'https://identity.example.com'
},
origin: oidc.origin
};
mocks.load.mockResolvedValueOnce(restored);
await directory.reload();
expect(directory.currentSnapshot().display.site.title).toBe('restored');
expect(directory.currentSnapshot().assets.brandingPaths).toEqual(['/public/restored.svg']);
});
it('owns frozen boot auth and prepared data independently of mutable loader results', async () => {
const input = configuration('boot', credentials);
mocks.load.mockResolvedValue(input);
const report = vi.fn();
const directory = await loadDirectory(undefined, { report });
const boot = directory.currentSnapshot();
input.site.title = 'mutated';
input.site.logo_path = '/public/mutated.svg';
input.links.photos!.tags.push('mutated');
input.auth!.credentials!.password = 'mutated';
expect(Object.isFrozen(input)).toBe(false);
expect(Object.isFrozen(boot.configuration.auth?.credentials)).toBe(true);
expect(Object.isFrozen(boot.configuration.links.photos!.tags)).toBe(true);
expect(boot.configuration.auth?.credentials?.password).toBe(sentinel);
expect(boot.configuration.links.photos!.tags).toEqual([' photos ', ' backup ']);
expect(boot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(boot.display.site.title).toBe('boot');
expect(boot.assets.brandingPaths).toEqual(['/public/boot.svg']);
expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' });
mocks.load.mockResolvedValueOnce(configuration('restored', credentials));
await directory.reload();
expect(directory.currentSnapshot().display.site.title).toBe('restored');
});
});
describe('terminal stop', () => {
it('discards a completed load, suppressing preparation and queued work after stop', async () => {
const report = vi.fn();
const directory = await loadDirectory(undefined, { report });
const boot = directory.currentSnapshot();
const pending = Promise.withResolvers<DirectoryConfiguration>();
mocks.load.mockReturnValueOnce(pending.promise);
const run = directory.reload();
await Promise.resolve();
expect(directory.reload()).toBe(run);
directory.stop();
directory.stop();
pending.resolve(configuration('discarded', credentials));
await run;
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(mocks.load).toHaveBeenCalledTimes(2);
expect(mocks.prepare).toHaveBeenCalledTimes(1);
expect(report).not.toHaveBeenCalled();
});
it('discards completed preparation and queued work after stop', async () => {
const report = vi.fn();
const directory = await loadDirectory(undefined, { report });
const boot = directory.currentSnapshot();
const pending = Promise.withResolvers<PreparedSnapshot>();
const entered = Promise.withResolvers<void>();
mocks.load.mockResolvedValueOnce(configuration('pending'));
mocks.prepare.mockImplementationOnce(() => {
entered.resolve();
return pending.promise;
});
const run = directory.reload();
await entered.promise;
expect(directory.reload()).toBe(run);
directory.stop();
pending.resolve(realSnapshot.prepareSnapshot(configuration('discarded')));
await run;
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(mocks.load).toHaveBeenCalledTimes(2);
expect(report).not.toHaveBeenCalled();
});
it.each(['load', 'prepare'] as const)(
'suppresses late %s rejection reports after stop',
async (stage) => {
const report = vi.fn();
const directory = await loadDirectory(undefined, { report });
const boot = directory.currentSnapshot();
const pending = Promise.withResolvers<never>();
const entered = Promise.withResolvers<void>();
if (stage === 'prepare') mocks.load.mockResolvedValueOnce(configuration('pending'));
mocks[stage].mockImplementationOnce(() => {
entered.resolve();
return pending.promise;
});
const run = directory.reload();
await entered.promise;
expect(directory.reload()).toBe(run);
directory.stop();
pending.reject(new Error(sentinel));
await expect(run).resolves.toBeUndefined();
await directory.reload();
expect(directory.currentSnapshot()).toBe(boot);
expect(mocks.load).toHaveBeenCalledTimes(2);
expect(report).not.toHaveBeenCalled();
}
);
it('does not enter a scheduled reload stopped before work begins', async () => {
const directory = await loadDirectory();
const run = directory.reload();
directory.stop();
await run;
await directory.reload();
expect(mocks.load).toHaveBeenCalledTimes(1);
});
});
@@ -1,128 +0,0 @@
import { resolve } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { ConfigError, loadConfigFile, resolveConfigPath } from '@directory/config';
import type { ConfigLoadOptions } from '@directory/config/types';
import { DirectoryError, type DirectoryIssue } from '../directory-error.js';
import { prepareSnapshot, type PreparedSnapshot } from './snapshot.js';
export interface LoadDirectoryOptions extends ConfigLoadOptions {
/** Called synchronously with safe diagnostics for rejected reloads. */
report?: (issue: DirectoryIssue) => void;
}
export interface Directory {
currentSnapshot(): PreparedSnapshot;
reload(): Promise<void>;
/** Stops future work and publication without cancelling in-flight I/O. */
stop(): void;
}
interface DirectoryState {
currentSnapshot: PreparedSnapshot;
readonly bootAuth: PreparedSnapshot['configuration']['auth'];
readonly path: string;
readonly loadOptions: ConfigLoadOptions;
readonly report: LoadDirectoryOptions['report'];
hasPendingReload: boolean;
activeRunPromise: Promise<void> | undefined;
stopped: boolean;
}
export async function loadDirectory(
path?: string,
options: LoadDirectoryOptions = {}
): Promise<Directory> {
const loadOptions: ConfigLoadOptions = options.env === undefined ? {} : { env: options.env };
let configPath: string;
let initial: PreparedSnapshot;
let code: DirectoryIssue['code'] = 'load_failed';
try {
configPath = path === undefined ? resolveConfigPath(loadOptions) : resolve(path);
const config = await loadConfigFile(configPath, loadOptions);
code = 'prepare_failed';
initial = await prepareSnapshot(config);
} catch (error) {
throw new DirectoryError(safeIssue(code, error));
}
const state: DirectoryState = {
currentSnapshot: initial,
// The initial prepared configuration is already an owned, deeply frozen copy.
bootAuth: initial.configuration.auth,
path: configPath,
loadOptions,
report: options.report,
hasPendingReload: false,
activeRunPromise: undefined,
stopped: false
};
return {
currentSnapshot: () => state.currentSnapshot,
reload() {
if (state.stopped) return Promise.resolve();
state.hasPendingReload = true;
// Defer the run until its promise has been recorded.
state.activeRunPromise ??= Promise.resolve().then(() => runPendingReloads(state));
return state.activeRunPromise;
},
stop() {
state.stopped = true;
state.hasPendingReload = false;
}
};
}
async function runPendingReloads(state: DirectoryState): Promise<void> {
try {
while (!state.stopped && state.hasPendingReload) {
state.hasPendingReload = false;
await reloadSnapshot(state);
}
} finally {
state.activeRunPromise = undefined;
}
}
async function reloadSnapshot(state: DirectoryState): Promise<void> {
let code: DirectoryIssue['code'] = 'load_failed';
try {
const config = await loadConfigFile(state.path, state.loadOptions);
if (state.stopped) return;
if (!isDeepStrictEqual(config.auth, state.bootAuth)) {
reportIssue(state, Object.freeze({ code: 'restart_required' }));
return;
}
if (isSameConfiguration(config, state.currentSnapshot.configuration)) return;
code = 'prepare_failed';
const snapshot = await prepareSnapshot(config);
if (state.stopped) return;
state.currentSnapshot = snapshot;
} catch (error) {
if (!state.stopped) reportIssue(state, safeIssue(code, error));
}
}
function isSameConfiguration(
next: PreparedSnapshot['configuration'],
current: PreparedSnapshot['configuration']
): boolean {
const sameValues = isDeepStrictEqual(next, current);
const sameLinkOrder = isDeepStrictEqual(Object.keys(next.links), Object.keys(current.links));
return sameValues && sameLinkOrder;
}
function safeIssue(code: DirectoryIssue['code'], error: unknown): DirectoryIssue {
if (code === 'load_failed' && error instanceof ConfigError) {
return Object.freeze({ code, issues: error.issues });
}
return Object.freeze({ code });
}
function reportIssue(state: DirectoryState, issue: DirectoryIssue): void {
if (state.stopped) return;
try {
state.report?.(issue);
} catch {
// A synchronous diagnostic failure must not poison subsequent reloads.
}
}
@@ -1,135 +0,0 @@
import type { Stats } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { logger } from '../logger.js';
import { watchConfig } from './watch.js';
type Listener = (current: Stats, previous: Stats) => Promise<void>;
const mocks = vi.hoisted(() => ({
watchFile: vi.fn<(path: string, options: object, listener: Listener) => void>(),
unwatchFile: vi.fn()
}));
vi.mock('node:fs', () => mocks);
function metadata(values: Partial<Stats> = {}): Stats {
return { ino: 10, mtimeMs: 100, size: 50, ...values } as Stats;
}
function listener(index = 0): Listener {
return mocks.watchFile.mock.calls[index]![2];
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('watchConfig', () => {
it('registers one non-persistent one-second path poll without an initial reload', () => {
const onChange = vi.fn<() => Promise<void>>();
watchConfig('/config/directory.yaml', onChange);
expect(mocks.watchFile).toHaveBeenCalledExactlyOnceWith(
'/config/directory.yaml',
{ interval: 1000, persistent: false },
expect.any(Function)
);
expect(onChange).not.toHaveBeenCalled();
});
it('ignores equal metadata and differences in unrelated stat fields', async () => {
const onChange = vi.fn<() => Promise<void>>();
watchConfig('/config/directory.yaml', onChange);
await listener()(metadata(), metadata());
await listener()(metadata({ atimeMs: 200, ctimeMs: 300 }), metadata());
expect(onChange).not.toHaveBeenCalled();
});
it.each([
['inode (atomic replacement)', { ino: 11 }],
['modification time', { mtimeMs: 101 }],
['size', { size: 51 }]
])('reloads when only %s changes', async (_name, changed) => {
const onChange = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
watchConfig('/config/directory.yaml', onChange);
await listener()(metadata(changed), metadata());
expect(onChange).toHaveBeenCalledExactlyOnceWith();
});
it('reloads on deletion and recreation, even with the original metadata', async () => {
const onChange = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
watchConfig('/config/directory.yaml', onChange);
const missing = metadata({ ino: 0, mtimeMs: 0, size: 0 });
await listener()(missing, metadata());
await listener()(metadata(), missing);
expect(onChange).toHaveBeenCalledTimes(2);
});
it('stops once, removes only its exact listener, and ignores stale events', async () => {
const onChange = vi.fn<() => Promise<void>>();
const stop = watchConfig('/config/directory.yaml', onChange);
const registered = listener();
stop();
stop();
expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith('/config/directory.yaml', registered);
await registered(metadata({ ino: 11 }), metadata());
expect(onChange).not.toHaveBeenCalled();
});
it('keeps registrations independent, including for the same path', async () => {
const first = vi.fn<() => Promise<void>>();
const second = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const stopFirst = watchConfig('/config/directory.yaml', first);
const stopSecond = watchConfig('/config/directory.yaml', second);
expect(listener(0)).not.toBe(listener(1));
stopFirst();
await listener(0)(metadata({ ino: 11 }), metadata());
await listener(1)(metadata({ ino: 11 }), metadata());
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledOnce();
stopSecond();
expect(mocks.unwatchFile.mock.calls).toEqual([
['/config/directory.yaml', listener(0)],
['/config/directory.yaml', listener(1)]
]);
});
it.each(['reject', 'throw'])(
'contains an unexpected callback %s with a fixed diagnostic',
async (mode) => {
const error = vi.spyOn(logger, 'error').mockReturnValue(undefined);
const sentinel = 'PRIVATE-password-file.yaml';
const onChange = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
if (mode === 'reject') onChange.mockRejectedValueOnce(new Error(sentinel));
else
onChange.mockImplementationOnce(() => {
throw new Error(sentinel);
});
watchConfig('/config/directory.yaml', onChange);
await expect(listener()(metadata({ ino: 11 }), metadata())).resolves.toBeUndefined();
expect(error).toHaveBeenCalledExactlyOnceWith('Configuration reload callback failed.');
await listener()(metadata({ ino: 12 }), metadata({ ino: 11 }));
expect(onChange).toHaveBeenCalledTimes(2);
expect(error).toHaveBeenCalledOnce();
}
);
it.each(['resolve', 'reject'])(
'suppresses stale in-flight %s completion after stop',
async (mode) => {
const error = vi.spyOn(logger, 'error').mockReturnValue(undefined);
const pending = Promise.withResolvers<void>();
const onChange = vi.fn<() => Promise<void>>().mockReturnValue(pending.promise);
const stop = watchConfig('/config/directory.yaml', onChange);
const active = listener()(metadata({ ino: 11 }), metadata());
expect(onChange).toHaveBeenCalledOnce();
stop();
if (mode === 'resolve') pending.resolve();
else pending.reject(new Error('PRIVATE-password-file.yaml'));
await expect(active).resolves.toBeUndefined();
await listener()(metadata({ ino: 12 }), metadata({ ino: 11 }));
expect(onChange).toHaveBeenCalledOnce();
expect(error).not.toHaveBeenCalled();
}
);
});
-29
View File
@@ -1,29 +0,0 @@
import { watchFile, unwatchFile, type Stats } from 'node:fs';
import { logger } from '../logger.js';
export function watchConfig(path: string, onChange: () => Promise<void>): () => void {
let stopped = false;
async function listener(current: Stats, previous: Stats): Promise<void> {
if (
stopped ||
(current.ino === previous.ino &&
current.mtimeMs === previous.mtimeMs &&
current.size === previous.size)
) {
return;
}
try {
await onChange();
} catch {
if (!stopped) logger.error('Configuration reload callback failed.');
}
}
// Poll the path so atomic saves and deletion/recreation do not detach the watcher.
watchFile(path, { interval: 1000, persistent: false }, listener);
return () => {
if (stopped) return;
stopped = true;
unwatchFile(path, listener);
};
}
@@ -2,7 +2,7 @@ import { validateConfig } from '@directory/config';
import type { DirectoryConfiguration } from '@directory/config/types';
import { describe, expect, expectTypeOf, it } from 'vitest';
import type { DisplaySite } from '../../display/types.js';
import { getSite, prepareDirectoryContent } from './directory-content.js';
import { getSite, prepareDirectoryContent } from './content.js';
const destination = { title: 'Photos', url: 'https://photos.example.com' };
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import type { DirectoryConfiguration } from '@directory/config/types';
import type { DirectoryContent } from '../../display/types.js';
import { freezeData, type Immutable } from './immutable.js';
import { prepareDirectoryContent } from './directory-content.js';
import { prepareDirectoryContent } from './content.js';
import { renderBookmarkLogos } from '../images/render-bookmark-logos.js';
export interface PreparedSnapshot {
@@ -1,13 +0,0 @@
import type { Authentication } from '../auth/index.js';
import type { Directory } from '../config/load-directory.js';
export interface Application {
readonly directory: Directory;
readonly publicDir: string;
readonly authentication: Authentication | undefined;
stop(): void;
}
export interface ServerState {
application: Application | undefined;
}
@@ -1,8 +1,7 @@
import { validateConfig } from '@directory/config';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import type { Directory } from '../config/load-directory.js';
import { prepareSnapshot } from '../config/snapshot.js';
import type { Application, ServerState } from './application.js';
import { prepareSnapshot } from '../directory/snapshot.js';
import type { Application, ServerState } from '../application.js';
const mocks = vi.hoisted(() => ({
boot: vi.fn<typeof import('../boot.js').bootApp>()
@@ -11,15 +10,12 @@ vi.mock('../boot.js', () => ({ bootApp: mocks.boot }));
function application(title: string) {
return {
directory: {
currentSnapshot: vi
.fn()
.mockReturnValue(
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
),
reload: vi.fn<Directory['reload']>(),
stop: vi.fn<Directory['stop']>()
},
currentSnapshot: vi
.fn<Application['currentSnapshot']>()
.mockReturnValue(
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
),
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
publicDir: '/mounted/public',
authentication: undefined,
stop: vi.fn<Application['stop']>()
@@ -134,7 +130,7 @@ describe('development lifecycle', () => {
await startup;
shutdown();
expect(stopped.stop).toHaveBeenCalledOnce();
expect(stopped.directory.currentSnapshot).not.toHaveBeenCalled();
expect(stopped.currentSnapshot).not.toHaveBeenCalled();
expect(server.application).toBeUndefined();
});
@@ -154,7 +150,7 @@ describe('development lifecycle', () => {
expect(first.stop).toHaveBeenCalledOnce();
expect(second.stop).not.toHaveBeenCalled();
expect(server.application).toBe(second);
expect(server.application?.directory.currentSnapshot().display.site.title).toBe('Current');
expect(server.application?.currentSnapshot().display.site.title).toBe('Current');
process.emit('directory:dev-stop');
expect(second.stop).toHaveBeenCalledOnce();
});
@@ -1,6 +1,6 @@
import type { ConfigEnvironment } from '@directory/config/types';
import { bootApp } from '../boot.js';
import type { Application, ServerState } from './application.js';
import type { Application, ServerState } from '../application.js';
interface DevelopmentAttempt {
readonly server: ServerState;
@@ -1,8 +1,7 @@
import { validateConfig } from '@directory/config';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import type { Directory } from '../config/load-directory.js';
import { prepareSnapshot } from '../config/snapshot.js';
import type { Application, ServerState } from './application.js';
import { prepareSnapshot } from '../directory/snapshot.js';
import type { Application, ServerState } from '../application.js';
import { startProduction } from './production.js';
const adapterEvents: NodeJS.EventEmitter = process;
@@ -14,15 +13,12 @@ vi.mock('../boot.js', () => ({ bootApp: mocks.boot }));
function application(title: string) {
return {
directory: {
currentSnapshot: vi
.fn()
.mockReturnValue(
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
),
reload: vi.fn<Directory['reload']>(),
stop: vi.fn<Directory['stop']>()
},
currentSnapshot: vi
.fn<Application['currentSnapshot']>()
.mockReturnValue(
prepareSnapshot(validateConfig({ site: { title }, links: {} }, { env: {} }))
),
onConfigChange: vi.fn<Application['onConfigChange']>().mockReturnValue(true),
publicDir: '/mounted/public',
authentication: undefined,
stop: vi.fn<Application['stop']>()
@@ -72,7 +68,7 @@ describe('production lifecycle', () => {
await startup;
expect(server.application).toBe(ready);
expect(ready.stop).not.toHaveBeenCalled();
expect(ready.directory.currentSnapshot).not.toHaveBeenCalled();
expect(ready.currentSnapshot).not.toHaveBeenCalled();
adapterEvents.emit('sveltekit:shutdown');
expect(ready.stop).toHaveBeenCalledOnce();
});
@@ -1,6 +1,6 @@
import type { ConfigEnvironment } from '@directory/config/types';
import { bootApp } from '../boot.js';
import type { Application, ServerState } from './application.js';
import type { Application, ServerState } from '../application.js';
export async function startProduction(server: ServerState, env?: ConfigEnvironment): Promise<void> {
const application = await bootApp(undefined, env);
+31 -1
View File
@@ -1,12 +1,42 @@
import { ConfigError } from '@directory/config';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DirectoryError } from './directory-error.js';
import { logger, reportDirectoryIssue, reportStartupFailure } from './logger.js';
import { logger, reportConfigError, reportDirectoryIssue, reportStartupFailure } from './logger.js';
const sentinel = 'PRIVATE-secret-environment-name-filename.yaml';
afterEach(() => vi.restoreAllMocks());
describe('safe server reporting', () => {
it('reports application reload failures without relabeling them as load failures', () => {
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
reportConfigError(new DirectoryError({ code: 'prepare_failed' }));
expect(output).toHaveBeenCalledExactlyOnceWith(
'Configuration update rejected (prepare_failed).'
);
});
it('reports config load failures without private link identifiers', () => {
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
reportConfigError(
new ConfigError([{ code: 'missing_environment', path: `/links/${sentinel}/url` }])
);
expect(output.mock.calls).toEqual([
['Configuration update rejected (load_failed).'],
[' /links/*/url (missing_environment)']
]);
});
it.each([new Error(sentinel), sentinel, { cause: sentinel, value: sentinel }])(
'never inspects or prints unknown reload errors',
(error) => {
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
reportConfigError(error);
expect(output).toHaveBeenCalledExactlyOnceWith(
'Configuration update rejected (load_failed).'
);
}
);
it('reports only diagnostic codes and redacted issue paths, never whole errors or messages', () => {
const output = vi.spyOn(logger, 'error').mockReturnValue(undefined);
const issue = {
+12
View File
@@ -1,3 +1,4 @@
import { ConfigError } from '@directory/config';
import { Logger } from 'tslog';
import { DirectoryError, type DirectoryIssue } from './directory-error.js';
@@ -15,6 +16,17 @@ export function reportDirectoryIssue(issue: DirectoryIssue): void {
reportIssueDetails(issue);
}
export function reportConfigError(error: unknown): void {
if (error instanceof DirectoryError) {
reportDirectoryIssue(error.issue);
} else {
reportDirectoryIssue({
code: 'load_failed',
...(error instanceof ConfigError ? { issues: error.issues } : {})
});
}
}
export function reportStartupFailure(error: unknown): void {
if (error instanceof DirectoryError) {
logger.error(`Application startup failed (${error.issue.code}).`);
@@ -1,6 +1,6 @@
import { validateConfig } from '@directory/config';
import { describe, expect, it } from 'vitest';
import { prepareSnapshot } from '../../../../../lib/server/config/snapshot.js';
import { prepareSnapshot } from '../../../../../lib/server/directory/snapshot.js';
import { GET } from './+server.js';
const sentinel = 'PRIVATE-version-endpoint-config';
+1 -1
View File
@@ -1,7 +1,7 @@
import { validateConfig } from '@directory/config';
import { describe, expect, it, vi } from 'vitest';
import type { Authentication } from '../../lib/server/auth/index.js';
import { prepareSnapshot } from '../../lib/server/config/snapshot.js';
import { prepareSnapshot } from '../../lib/server/directory/snapshot.js';
import { actions, load } from './+page.server.js';
function eventFor(query = '', withAuth = false) {
@@ -1,7 +1,7 @@
import { validateConfig } from '@directory/config';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { readMountedImage } from '../../../lib/server/images/read-mounted-image.js';
import { prepareSnapshot } from '../../../lib/server/config/snapshot.js';
import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js';
import { GET, HEAD } from './+server.js';
vi.mock('../../../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() }));
@@ -1,7 +1,7 @@
import { validateConfig } from '@directory/config';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { readMountedImage } from '../../../lib/server/images/read-mounted-image.js';
import { prepareSnapshot } from '../../../lib/server/config/snapshot.js';
import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js';
import { GET, HEAD } from './+server.js';
vi.mock('../../../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() }));
@@ -1,7 +1,7 @@
import { validateConfig } from '@directory/config';
import { describe, expect, it, vi } from 'vitest';
import type { Authentication } from '../../../lib/server/auth/index.js';
import { prepareSnapshot } from '../../../lib/server/config/snapshot.js';
import { prepareSnapshot } from '../../../lib/server/directory/snapshot.js';
import { logger } from '../../../lib/server/logger.js';
import { actions, load } from './+page.server.js';
+1 -1
View File
@@ -1,6 +1,6 @@
import { validateConfig } from '@directory/config';
import { describe, expect, it } from 'vitest';
import { prepareSnapshot } from '../lib/server/config/snapshot.js';
import { prepareSnapshot } from '../lib/server/directory/snapshot.js';
import { load } from './+layout.server.js';
function event(site: Record<string, string> = {}) {
+2 -1
View File
@@ -1,6 +1,7 @@
# @directory/config
Shared server-side YAML loading, validation and path resolution for Directory.
Shared server-side YAML loading, validation, path resolution and change monitoring
for Directory.
- [Configuration](../../docs/config.md): fields, defaults and safe errors.
- [Schema](src/config.schema.json).
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { isSameConfiguration, validateConfig } from './index.js';
function configuration() {
return validateConfig({
site: { title: 'Home', description: 'Household apps' },
links: {
photos: { title: 'Photos', url: 'https://photos.example.com' },
music: { title: 'Music', url: 'https://music.example.com' }
}
});
}
describe('isSameConfiguration', () => {
it('compares independently loaded values without treating ordinary mapping order as a change', () => {
const previous = configuration();
const next = validateConfig({
...previous,
site: { description: previous.site.description, title: previous.site.title }
});
expect(Object.keys(next.site)).not.toEqual(Object.keys(previous.site));
expect(isSameConfiguration(next, previous)).toBe(true);
next.site.title = 'Changed';
expect(isSameConfiguration(next, previous)).toBe(false);
});
it('treats bookmark declaration order as a change even when values match', () => {
const previous = configuration();
const next = configuration();
next.links = { music: next.links.music!, photos: next.links.photos! };
expect(next).toEqual(previous);
expect(isSameConfiguration(next, previous)).toBe(false);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { isDeepStrictEqual } from 'node:util';
import type { DirectoryConfiguration } from './types.js';
export function isSameConfiguration(
next: DirectoryConfiguration,
previous: DirectoryConfiguration
): boolean {
const sameValues = isDeepStrictEqual(next, previous);
// Bookmark declaration order determines display order, unlike other mapping keys.
const sameLinkOrder = isDeepStrictEqual(Object.keys(next.links), Object.keys(previous.links));
return sameValues && sameLinkOrder;
}
+2
View File
@@ -1,6 +1,8 @@
export { configurationVersion } from './version.js';
export { DirectoryConfigurationSchema, configurationReferences } from './schema.js';
export { loadConfigFile, validateConfig } from './load.js';
export { startConfigReloading } from './monitor.js';
export { isSameConfiguration } from './compare.js';
export { ConfigError } from './errors.js';
export type { ConfigIssue, ConfigIssueCode } from './errors.js';
export { resolveConfigPath, resolveDeploymentRoot, resolvePublicDir } from './paths.js';
+231
View File
@@ -0,0 +1,231 @@
import type { Stats } from 'node:fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { startConfigReloading } from './index.js';
type Listener = (current: Stats, previous: Stats) => Promise<void>;
const mocks = vi.hoisted(() => ({
watchFile: vi.fn<(path: string, options: object, listener: Listener) => void>(),
unwatchFile: vi.fn()
}));
vi.mock('node:fs', () => mocks);
const path = '/config/settings.json';
function options() {
return {
path,
initial: 'initial',
load: vi.fn<(path: string) => Promise<string>>().mockResolvedValue('reconciled'),
onChange: vi.fn<(next: string, previous: string) => boolean>().mockReturnValue(true),
onError: vi.fn<(error: unknown) => void>()
};
}
function metadata(values: Partial<Stats> = {}): Stats {
return { ino: 10, mtimeMs: 100, ctimeMs: 100, size: 50, ...values } as Stats;
}
function listener(index = 0): Listener {
return mocks.watchFile.mock.calls[index]![2];
}
function change(index = 0): Promise<void> {
return listener(index)(metadata({ ctimeMs: 101 }), metadata());
}
beforeEach(() => vi.resetAllMocks());
describe('startConfigReloading', () => {
it('subscribes before reconciliation and waits for it, using initial as the baseline', async () => {
const input = options();
const read = Promise.withResolvers<string>();
input.load.mockReturnValueOnce(read.promise);
const ready = vi.fn();
const startup = startConfigReloading(input).then((stop) => {
ready();
return stop;
});
expect(mocks.watchFile).toHaveBeenCalledExactlyOnceWith(
path,
{ interval: 1000, persistent: false },
expect.any(Function)
);
// Work starts in a microtask, after the active promise has been assigned.
expect(input.load).not.toHaveBeenCalled();
await Promise.resolve();
expect(input.load).toHaveBeenCalledExactlyOnceWith(path);
expect(ready).not.toHaveBeenCalled();
expect(input.onChange).not.toHaveBeenCalled();
read.resolve('saved during startup');
const stop = await startup;
expect(input.onChange).toHaveBeenCalledExactlyOnceWith('saved during startup', 'initial');
stop();
});
it('ignores unchanged metadata and access-time-only changes', async () => {
const input = options();
const stop = await startConfigReloading(input);
await listener()(metadata(), metadata());
await listener()(metadata({ atimeMs: 200 }), metadata());
expect(input.load).toHaveBeenCalledOnce();
stop();
});
it.each([
['inode', { ino: 11 }],
['mtime', { mtimeMs: 101 }],
['ctime with unchanged size and mtime', { ctimeMs: 101 }],
['size', { size: 51 }]
])('reloads for changed %s', async (_name, changed) => {
const input = options();
const stop = await startConfigReloading(input);
await listener()(metadata(changed), metadata());
expect(input.load).toHaveBeenCalledTimes(2);
stop();
});
it('retains the last accepted value after rejection, callback throws and read failure, then recovers', async () => {
const input = options();
const stop = await startConfigReloading(input);
input.load.mockResolvedValueOnce('rejected');
input.onChange.mockReturnValueOnce(false);
await change();
expect(input.onError).not.toHaveBeenCalled();
const callbackFailure = new Error('callback failed');
input.load.mockResolvedValueOnce('throwing');
input.onChange.mockImplementationOnce(() => {
throw callbackFailure;
});
await change();
const readFailure = new Error('read failed');
input.load.mockRejectedValueOnce(readFailure);
await change();
input.load.mockResolvedValueOnce('recovered').mockResolvedValueOnce('latest');
await change();
await change();
expect(input.onChange.mock.calls).toEqual([
['reconciled', 'initial'],
['rejected', 'reconciled'],
['throwing', 'reconciled'],
['recovered', 'reconciled'],
['latest', 'recovered']
]);
expect(input.onError.mock.calls).toEqual([[callbackFailure], [readFailure]]);
stop();
});
it('reports failed reconciliation without losing the initial baseline or future monitoring', async () => {
const input = options();
const failure = new Error('initial reconciliation failed');
input.load.mockRejectedValueOnce(failure);
const stop = await startConfigReloading(input);
expect(input.onError).toHaveBeenCalledExactlyOnceWith(failure);
expect(input.onChange).not.toHaveBeenCalled();
await change();
expect(input.onChange).toHaveBeenCalledExactlyOnceWith('reconciled', 'initial');
stop();
});
it('serializes slow reads and coalesces events during reconciliation into one follow-up', async () => {
const input = options();
const first = Promise.withResolvers<string>();
const second = Promise.withResolvers<string>();
const readingAgain = Promise.withResolvers<void>();
input.load.mockReturnValueOnce(first.promise).mockImplementationOnce(() => {
readingAgain.resolve();
return second.promise;
});
const ready = vi.fn();
const startup = startConfigReloading(input).then((stop) => {
ready();
return stop;
});
await Promise.resolve();
const active = change();
for (let i = 0; i < 10; i++) void change();
expect(input.load).toHaveBeenCalledOnce();
first.resolve('first');
await readingAgain.promise;
expect(input.onChange).toHaveBeenCalledExactlyOnceWith('first', 'initial');
expect(ready).not.toHaveBeenCalled();
second.resolve('second');
const stop = await startup;
await active;
expect(input.load).toHaveBeenCalledTimes(2);
expect(input.onChange).toHaveBeenLastCalledWith('second', 'first');
stop();
});
it('keeps monitoring after a throwing reporter', async () => {
const input = options();
const stop = await startConfigReloading(input);
const failure = new Error('read failed');
input.load.mockRejectedValueOnce(failure).mockResolvedValue('recovered');
input.onError.mockImplementation(() => {
throw new Error('diagnostic failure');
});
await change();
await change();
expect(input.load).toHaveBeenCalledTimes(3);
expect(input.onError).toHaveBeenCalledExactlyOnceWith(failure);
expect(input.onChange).toHaveBeenLastCalledWith('recovered', 'reconciled');
stop();
});
it.each(['resolve', 'reject'])(
'stops a pending read before its late %s and clears queued work',
async (mode) => {
const input = options();
const stop = await startConfigReloading(input);
const read = Promise.withResolvers<string>();
input.load.mockReturnValueOnce(read.promise);
const active = change();
await Promise.resolve();
expect(input.load).toHaveBeenCalledTimes(2);
void change();
stop();
stop();
expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith(path, listener());
if (mode === 'resolve') read.resolve('too late');
else read.reject(new Error('too late'));
await active;
await change();
expect(input.load).toHaveBeenCalledTimes(2);
expect(input.onChange).toHaveBeenCalledOnce();
expect(input.onError).not.toHaveBeenCalled();
}
);
it('stops scheduled work and removes only its own listener on a shared path', async () => {
const first = options();
const second = options();
const stopFirst = await startConfigReloading(first);
const stopSecond = await startConfigReloading(second);
const scheduled = change(0);
stopFirst();
await scheduled;
await change(0);
await change(1);
expect(first.load).toHaveBeenCalledOnce();
expect(second.load).toHaveBeenCalledTimes(2);
expect(listener(0)).not.toBe(listener(1));
expect(mocks.unwatchFile).toHaveBeenCalledExactlyOnceWith(path, listener(0));
stopSecond();
expect(mocks.unwatchFile.mock.calls).toEqual([
[path, listener(0)],
[path, listener(1)]
]);
});
it('propagates registration failure without starting a load', async () => {
const input = options();
const failure = new Error('watch registration failed');
mocks.watchFile.mockImplementationOnce(() => {
throw failure;
});
await expect(startConfigReloading(input)).rejects.toBe(failure);
expect(input.load).not.toHaveBeenCalled();
expect(input.onChange).not.toHaveBeenCalled();
expect(input.onError).not.toHaveBeenCalled();
expect(mocks.unwatchFile).not.toHaveBeenCalled();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { watchFile, unwatchFile, type Stats } from 'node:fs';
/** Watches a path and reconciles it against the caller's last accepted configuration. */
export async function startConfigReloading<T>({
path,
initial,
load,
onChange,
onError
}: {
path: string;
initial: T;
load: (path: string) => Promise<T>;
/** Synchronously accept or reject the loaded value. */
onChange: (next: T, previous: T) => boolean;
/** Receives raw failures; the caller is responsible for safe diagnostics. */
onError: (error: unknown) => void;
}): Promise<() => void> {
let accepted = initial;
let stopped = false;
let pending = false;
let active: Promise<void> | undefined;
async function run(): Promise<void> {
try {
while (!stopped && pending) {
pending = false;
try {
const next = await load(path);
if (stopped) return;
if (onChange(next, accepted) && !stopped) accepted = next;
} catch (error) {
if (stopped) return;
try {
onError(error);
} catch {
// Diagnostic failures must not poison subsequent reloads.
}
}
}
} finally {
active = undefined;
}
}
function reload(): Promise<void> {
if (stopped) return Promise.resolve();
pending = true;
// Defer the read so same-turn events coalesce and stop can suppress scheduled work.
active ??= Promise.resolve().then(run);
return active;
}
function listener(current: Stats, previous: Stats): Promise<void> {
if (
stopped ||
(current.ino === previous.ino &&
current.mtimeMs === previous.mtimeMs &&
current.ctimeMs === previous.ctimeMs &&
current.size === previous.size)
) {
return Promise.resolve();
}
return reload();
}
function stop(): void {
if (stopped) return;
stopped = true;
pending = false;
unwatchFile(path, listener);
}
// Poll the path so atomic saves and deletion/recreation remain observable.
watchFile(path, { interval: 1000, persistent: false }, listener);
// Close the gap between the caller's initial read and watcher registration.
await reload();
return stop;
}