house worker

This commit is contained in:
2026-10-11 22:05:05 +00:00
parent ecd49fc410
commit b52e9188f4
6 changed files with 247 additions and 24 deletions

View File

@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createWorkerApi } from './worker-api';
const config = {
url: 'https://dev.stanrunge.dev',
token: 'test-worker-token-0000000000000000',
accessClientId: 'test-client.access',
accessClientSecret: 'test-access-secret'
};
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe('worker API', () => {
it('authenticates the health check with both tokens without sending a body', async () => {
const fetch = vi.fn().mockResolvedValue(Response.json({ ok: true }));
vi.stubGlobal('fetch', fetch);
await expect(createWorkerApi(config)('health')).resolves.toEqual({ ok: true });
expect(fetch).toHaveBeenCalledExactlyOnceWith(
'https://dev.stanrunge.dev/projects/house-tracker/api/worker/health',
expect.objectContaining({
method: 'GET',
body: undefined,
redirect: 'manual',
headers: expect.objectContaining({
authorization: `Bearer ${config.token}`,
'CF-Access-Client-Id': config.accessClientId,
'CF-Access-Client-Secret': config.accessClientSecret
})
})
);
});
it('can POST to a server without Cloudflare Access', async () => {
const fetch = vi.fn().mockResolvedValue(Response.json({ newIds: [] }));
vi.stubGlobal('fetch', fetch);
await createWorkerApi({ url: config.url, token: config.token })('sync', { cards: [] });
expect(fetch.mock.calls[0][1]).toMatchObject({ method: 'POST', body: '{"cards":[]}' });
expect(fetch.mock.calls[0][1].headers).not.toHaveProperty('CF-Access-Client-Secret');
});
it.each([302, 401, 403, 429])(
'fails immediately on HTTP %i without exposing response text',
async (status) => {
const fetch = vi.fn().mockResolvedValue(
new Response('private login response', {
status,
headers: { location: 'https://login.example.com/' }
})
);
vi.stubGlobal('fetch', fetch);
const error = await createWorkerApi(config)('health').catch((error: Error) => error);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(`API health ${status}`);
expect((error as Error).message).not.toContain('private login response');
expect((error as Error).message).not.toContain(config.accessClientSecret);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch.mock.calls[0][1].redirect).toBe('manual');
}
);
it('rejects a login page returned with HTTP 200', async () => {
const fetch = vi.fn().mockResolvedValue(
new Response('<html>login</html>', {
headers: { 'content-type': 'text/html' }
})
);
vi.stubGlobal('fetch', fetch);
await expect(createWorkerApi(config)('health')).rejects.toThrow('expected JSON');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('retries transient server failures and preserves sync data', async () => {
vi.useFakeTimers();
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 502 }))
.mockResolvedValueOnce(Response.json({ newIds: [] }));
vi.stubGlobal('fetch', fetch);
const result = createWorkerApi(config)('sync', { cards: [] });
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toEqual({ newIds: [] });
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch.mock.calls[1][1].body).toBe('{"cards":[]}');
});
it('does not retry a one-attempt connection check', async () => {
const fetch = vi.fn().mockRejectedValue(new Error('connection failed'));
vi.stubGlobal('fetch', fetch);
await expect(createWorkerApi(config, 1)('health')).rejects.toThrow('could not reach');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('requires the complete Access credential pair', () => {
expect(() => createWorkerApi({ ...config, accessClientSecret: undefined })).toThrow('Set both');
expect(() => createWorkerApi({ ...config, accessClientId: undefined })).toThrow('Set both');
});
it('rejects insecure remote URLs and permits local development', () => {
expect(() => createWorkerApi({ ...config, url: 'http://dev.stanrunge.dev' })).toThrow('HTTPS');
expect(() => createWorkerApi({ ...config, url: 'http://127.0.0.1:5173' })).not.toThrow();
});
});

View File

@@ -0,0 +1,78 @@
// Only requests to our website use these credentials. Listing fetches stay separate.
type WorkerApiConfig = {
url: string;
token: string;
accessClientId?: string;
accessClientSecret?: string;
};
type WorkerApiPath = 'health' | 'sync' | 'details' | 'report';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export function createWorkerApi(config: WorkerApiConfig, attempts = 4) {
if (!config.url || config.token.length < 32)
throw new Error('HOUSE_API_URL and HOUSE_WORKER_TOKEN (32+ chars) are required');
if (Boolean(config.accessClientId) !== Boolean(config.accessClientSecret))
throw new Error('Set both HOUSE_CF_ACCESS_CLIENT_ID and HOUSE_CF_ACCESS_CLIENT_SECRET');
const base = new URL(config.url);
const local = ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname);
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && local))
throw new Error('HOUSE_API_URL must use HTTPS (HTTP is allowed for localhost)');
if (base.username || base.password || base.search || base.hash || base.pathname !== '/')
throw new Error('HOUSE_API_URL must be just the website origin, without a path or credentials');
const headers: Record<string, string> = {
accept: 'application/json',
'content-type': 'application/json',
authorization: `Bearer ${config.token}`
};
if (config.accessClientId && config.accessClientSecret) {
headers['CF-Access-Client-Id'] = config.accessClientId;
headers['CF-Access-Client-Secret'] = config.accessClientSecret;
}
return async function request<T>(path: WorkerApiPath, body?: unknown): Promise<T> {
for (let attempt = 0; attempt < attempts; attempt++) {
if (attempt) await sleep(5000 * 2 ** (attempt - 1));
let response: Response;
try {
response = await fetch(`${base.origin}/projects/house-tracker/api/worker/${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers,
body: body === undefined ? undefined : JSON.stringify(body),
// Never forward Access credentials to a redirect (including the login page).
redirect: 'manual',
signal: AbortSignal.timeout(30_000)
});
} catch {
if (attempt + 1 < attempts) continue;
throw new Error(`API ${path}: could not reach the website`);
}
if (response.status >= 500 && attempt + 1 < attempts) {
await response.body?.cancel();
continue;
}
if (!response.ok) {
await response.body?.cancel();
const hint =
response.status >= 300 && response.status < 400
? 'redirect refused; check the Cloudflare Access service token and Service Auth policy'
: response.status === 401 || response.status === 403
? 'check the worker token and Cloudflare Access service token/policy'
: 'check the website configuration';
throw new Error(`API ${path} ${response.status}: ${hint}`);
}
if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) {
await response.body?.cancel();
throw new Error(`API ${path}: expected JSON; check Cloudflare Access and HOUSE_API_URL`);
}
try {
return (await response.json()) as T;
} catch {
throw new Error(`API ${path}: invalid JSON response`);
}
}
throw new Error(`API ${path}: no request attempts configured`);
};
}

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { requireWorker } from '$lib/server/house-tracker';
import type { RequestHandler } from './$types';
// Authenticated connectivity check; no database writes or listing-site requests.
export const GET: RequestHandler = async ({ request }) => {
requireWorker(request);
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
};