Align trusted-proxy handling with When and Directory #37
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@hledger-reports/web': patch
|
||||
---
|
||||
|
||||
Align trusted-proxy requests with framework URL and client-address handling. Both request URL origins must match normalized YAML `url.app`; unsafe methods retain exact Origin checks and authentication remains mandatory. Remove custom raw Host parsing and duplicated route-local IP restrictions.
|
||||
|
||||
Compiled Compose uses runtime protocol and address headers, which the sole upstream proxy must overwrite while preserving public Host. Remove the build-time public URL; adapter-node 6's runtime `ORIGIN` is not a substitute for protocol headers. Local Vite/mise development and bindings stay unchanged. Compiled browser fixtures use a test-only loopback HTTP boundary with normal browser headers, not an auth bypass or production deployment recipe.
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
COMPOSE_FILE: apps/web/docker-compose.yml
|
||||
COMPOSE_FILE: apps/web/docker-compose.yml:apps/web/docker-compose.e2e.yml
|
||||
AUTH_SECRET: fictional-ci-session-secret-at-least-32-characters
|
||||
AUTH_PASSWORD: fictional-ci-password
|
||||
PLAYWRIGHT_USERNAME: household
|
||||
|
||||
@@ -18,7 +18,7 @@ The hledger library reads the journal and organizes neutral, table/chart-ready `
|
||||
|
||||
**`/queries` remains a separate live report:** each Run executes native balance at recorded cost against the journal current at execution. Exact results persist in SQLite as operational workflow output, but the page displays only its latest response; reload clears it. Neither page offers user-facing run history.
|
||||
|
||||
Sign-in is required, but this remains loopback-only development software. Do not expose it publicly or through a reverse proxy. See [shared access and authentication](docs/authentication.md). Curated report catalogs and search remain deferred.
|
||||
Sign-in is required. Local development stays loopback-bound; an existing trusted HTTPS proxy may front the compiled app under the documented Host, origin and client-address contract. Do not expose its upstream directly. See [shared access and authentication](docs/authentication.md). Curated report catalogs and search remain deferred.
|
||||
|
||||
## Setup and run
|
||||
|
||||
@@ -34,7 +34,7 @@ docker compose -f docker-compose.dev.yml up --build --wait
|
||||
|
||||
Runtime data is bind-mounted from **`apps/web/data/`**, so it is directly inspectable and ignored by Git/build tools. To stop without removing the data folder, run `docker compose -f docker-compose.dev.yml down` from the same directory.
|
||||
|
||||
Open **http://127.0.0.1:5173/**, sign in, then use **Prepare reports** to create the first saved set. Web readiness does not wait for financial calculations, and saved data remains readable if the worker is unavailable. Automated tests use the separate fictional `lots.journal` fixture, never your live journal. See [container development and CI](docs/development.md) for setup, configuration migration, compiled preview and browser tests.
|
||||
Open **http://127.0.0.1:5173/**, sign in, then use **Prepare reports** to create the first saved set. Web readiness does not wait for financial calculations, and saved data remains readable if the worker is unavailable. Automated tests use the separate fictional `lots.journal` fixture, never your live journal. See [container development and CI](docs/development.md) for setup, configuration migration, compiled proxy setup and browser tests.
|
||||
|
||||
The worker runs requested jobs; it does not watch or hash journals, detect staleness, or schedule report preparation. Saved timestamps tell you when reports were prepared, not whether they match current inputs. Explicit query Runs still read current input. Closing the page aborts its HTTP request, not durable work. Without `CONFIG_PATH`, protected requests fail closed; public health and schema endpoints remain available. No personal configuration is discovered.
|
||||
|
||||
@@ -55,7 +55,7 @@ auth:
|
||||
password: '${AUTH_PASSWORD}'
|
||||
```
|
||||
|
||||
`version` defaults to `1`; other versions fail validation. `url.app` and exactly one credentials/OIDC strategy are required. Sessions last a fixed seven days, without renewal or an auth database. Auth secrets remain web-private; worker validates structure without resolving them. Deployment/auth strings support `${NAME}` environment references, `${NAME:-fallback}` for unset variables, and `$$` for a literal dollar. Expansion is single-pass and missing variables fail safely. Relative paths resolve from the selected YAML file, not the working directory. Compose's example instead selects `/journal/main.journal`, the unchanged container journal path. Set the schema URL to your actual listener (`3000` for the default compiled build).
|
||||
`version` defaults to `1`; other versions fail validation. `url.app` and exactly one credentials/OIDC strategy are required. Sessions last a fixed seven days, without renewal or an auth database. Auth secrets remain web-private; worker validates structure without resolving them. Deployment/auth strings support `${NAME}` environment references, `${NAME:-fallback}` for unset variables, and `$$` for a literal dollar. Expansion is single-pass and missing variables fail safely. Relative paths resolve from the selected YAML file, not the working directory. Compose's example instead selects `/journal/main.journal`, the unchanged container journal path. Set the schema URL to your browser-facing origin.
|
||||
|
||||
Configure register membership explicitly; omission means that register is hidden and unavailable, not an all-account fallback:
|
||||
|
||||
@@ -83,7 +83,7 @@ Files are prepared together in a hidden staging set and published as a complete
|
||||
|
||||
In Compose, these paths are visible under **`apps/web/data/`** in the checkout. The previous named volume is left untouched; [copy it safely before the first bind-mounted start](docs/development.md#inspecting-data-and-preserving-the-previous-volume) to retain its queue and publications. Protect and back up the entire data directory; do not log financial data or native diagnostics. [Prepared-data lifetime](packages/snapshots/README.md#prepared-reports) covers growth, last-good recovery and untouched unsupported files.
|
||||
|
||||
Config loading neither opens journals nor creates directories. Each process loads its explicit selection at startup; restart after YAML edits. Blank/invalid selections fail startup. Web initializes one jobs client and reuses it for requests. Report routes check actual loopback client address, URL hostname and raw Host; POSTs additionally require an exact same-origin request and a 2-KiB streamed body limit: form encoding for preparation, JSON for live queries. These transport guards remain in addition to the central authentication gate. Responses are no-store. Vite denies raw journals, SQLite/sidecars and data/input/result folders.
|
||||
Config loading neither opens journals nor creates directories. Each process loads its explicit selection at startup; restart after YAML edits. Blank/invalid selections fail startup. Web initializes one jobs client and reuses it for requests. A shared hook checks both framework request URL origins against normalized YAML `url.app`; POSTs additionally require an exact same-origin request and a 2-KiB streamed body limit: form encoding for preparation, JSON for live queries. These transport guards remain in addition to the central authentication gate. Responses are no-store. Vite denies raw journals, SQLite/sidecars and data/input/result folders.
|
||||
|
||||
The [native library](packages/hledger/README.md) documents read-only journal access, execution safeguards and exact-codec limitations. The worker enables scheduled SQLite query retention; see [scope, scheduling and backup precautions](apps/worker/README.md#query-job-retention).
|
||||
|
||||
@@ -101,7 +101,7 @@ pnpm build
|
||||
pnpm worker:inspect # Read-only fixture preflight; does not prepare reports
|
||||
```
|
||||
|
||||
Use the documented [Compose compiled preview](docs/development.md#compiled-preview) for production-image checks; it uses the same host data folder.
|
||||
The [compiled Compose setup](docs/development.md#compiled-preview) requires a trusted proxy and uses the same host data folder; direct HTTP browsing remains a Vite development workflow.
|
||||
|
||||
Worker health defaults to `http://127.0.0.1:9000/healthz` (`WORKER_PORT` overrides it); web also serves `/healthz`. Health is liveness, not financial freshness. Tests cover guarded routes, exact native results, durable preparation/reload, failure preservation, live-query reload clearing and compiled browser interactions. Native errors are deliberately generic; run hledger directly for diagnostics.
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ WORKDIR /m/apps/web
|
||||
CMD ["pnpm", "dev", "--host", "127.0.0.1", "--port", "5173", "--strictPort"]
|
||||
|
||||
FROM dependencies AS builder
|
||||
ARG ORIGIN
|
||||
RUN pnpm --filter "@hledger-reports/web..." build
|
||||
|
||||
FROM builder AS pruner
|
||||
|
||||
+10
-5
@@ -115,10 +115,13 @@ Do not copy into populated data folders or delete volumes to simulate an empty a
|
||||
|
||||
## Request and resource boundaries
|
||||
|
||||
This app requires shared sign-in and remains local-only. Actual client address,
|
||||
URL hostname and raw Host must be loopback and match the configured app origin. POSTs additionally require exact Origin and bounded
|
||||
fatal-UTF8 request bodies: form encoding for preparation, JSON for live queries.
|
||||
Responses are no-store. No forwarded-header or public-proxy bypass is supported.
|
||||
This app requires shared sign-in. Both `event.url.origin` and the origin of
|
||||
`request.url` must match normalized YAML `url.app`; there is no custom raw Host
|
||||
parser. Local Vite development stays loopback-bound. Compiled instances use the
|
||||
[trusted-proxy contract](../../docs/authentication.md#trusted-proxy-contract):
|
||||
framework-owned scheme, Host and client-address handling, with no build-time public URL.
|
||||
Unsafe methods require exact Origin; preparation/query POSTs also require bounded
|
||||
fatal-UTF8 bodies (form encoding/JSON respectively). Responses are no-store.
|
||||
|
||||
Hooks open one complete private application context per configured process and
|
||||
place it in `locals.application`: resolved configuration, deployment paths, caller-owned authentication and a
|
||||
@@ -133,7 +136,7 @@ pages, data loads, actions and APIs. The protected layout owns financial navigat
|
||||
Sign-in is a progressively enhanced SvelteKit action and sign-out a native POST;
|
||||
both work without JavaScript. Sessions last a fixed seven days with
|
||||
no renewal or authentication database. See [authentication](../../docs/authentication.md)
|
||||
for setup, OIDC admission, secret rotation and the unchanged local-only boundary.
|
||||
for setup, OIDC admission, secret rotation and the trusted-proxy boundary.
|
||||
|
||||
`/schema.json` is a public nonfinancial endpoint serving only the bundled editor
|
||||
artifact. It does not access request locals, live configuration, journals or the
|
||||
@@ -205,6 +208,8 @@ and public projection. Shared validation is tested once at its owner, not throug
|
||||
every possible route input. Routine page markup is not a unit-test contract.
|
||||
|
||||
Playwright starts a disposable copied journal/data deployment and signs in normally.
|
||||
Its test-only HTTP boundary supplies the actual loopback scheme and socket IP to
|
||||
the native compiled handler; browsers send normal headers, with no auth bypass.
|
||||
A small smoke suite covers the main journeys, including explicit native preparation;
|
||||
it does not publish mock financial snapshots or repeat chart edge-case matrices.
|
||||
Run targeted tests during iteration. See the [testing philosophy](../../docs/philosophy.md#tests-while-the-foundations-are-changing)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Test-only HTTP boundary for the compiled image; never use for production traffic.
|
||||
# Base config/journal/data mounts are unchanged: use an isolated fictional fixture.
|
||||
services:
|
||||
web:
|
||||
command: ['node', 'e2e/web.mjs']
|
||||
environment:
|
||||
PROTOCOL_HEADER: x-forwarded-proto
|
||||
ADDRESS_HEADER: x-real-ip
|
||||
HOST_HEADER: ''
|
||||
PORT_HEADER: ''
|
||||
volumes:
|
||||
- ./e2e/web.mjs:/app/e2e/web.mjs:ro
|
||||
@@ -1,7 +1,7 @@
|
||||
name: hledger-web-reports
|
||||
|
||||
# Local-only: host networking preserves actual loopback client addresses.
|
||||
# All listeners bind 127.0.0.1; do not replace this with public port publishing.
|
||||
# Keep the upstream private; the trusted proxy supplies public request metadata.
|
||||
# Local Vite development uses docker-compose.dev.yml instead.
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
@@ -18,6 +18,8 @@ services:
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
HOST: 127.0.0.1
|
||||
PORT: '3000'
|
||||
PROTOCOL_HEADER: x-forwarded-proto
|
||||
ADDRESS_HEADER: x-real-ip
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./config/hledger-reports.yaml
|
||||
|
||||
@@ -83,11 +83,11 @@ try {
|
||||
if (!ready) await delay(100);
|
||||
}
|
||||
if (!ready) throw new Error('Fixture worker did not become ready.');
|
||||
start('../build/index.js', {
|
||||
start('./web.mjs', {
|
||||
HOST: '127.0.0.1',
|
||||
PORT: '3000',
|
||||
ADDRESS_HEADER: '',
|
||||
PROTOCOL_HEADER: '',
|
||||
ADDRESS_HEADER: 'x-real-ip',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto',
|
||||
HOST_HEADER: '',
|
||||
PORT_HEADER: ''
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { server } from '../build/index.js';
|
||||
|
||||
// This owned HTTP fixture supplies trusted headers before the native adapter handler.
|
||||
// Host/port and the adapter's startup/shutdown lifecycle stay unchanged.
|
||||
server.prependListener('request', (req) => {
|
||||
req.headers['x-forwarded-proto'] = 'http';
|
||||
req.headers['x-real-ip'] = req.socket.remoteAddress;
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error as httpError, fail, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import { isAPIError } from 'better-auth/api';
|
||||
import { parseSetCookieHeader, toCookieOptions } from 'better-auth/cookies';
|
||||
import { InvalidBody, readRequestText } from '../local-api.js';
|
||||
import { InvalidBody, readRequestText } from '../http.js';
|
||||
|
||||
async function dispatch(event: RequestEvent, path: string, body: object) {
|
||||
const app = event.locals.application;
|
||||
|
||||
@@ -3,16 +3,16 @@ import { expect, it, vi } from 'vitest';
|
||||
import { privateHeaders, protectOrigin, requireSignIn } from './gate.js';
|
||||
|
||||
const origin = 'http://127.0.0.1:3000';
|
||||
function setup(id: string | null, method = 'GET') {
|
||||
function setup(id: string | null, method = 'GET', appOrigin = origin) {
|
||||
const event = {
|
||||
url: new URL('/resource', origin),
|
||||
request: new Request(`${origin}/resource`, {
|
||||
url: new URL('/resource', appOrigin),
|
||||
request: new Request(`${appOrigin}/resource`, {
|
||||
method,
|
||||
headers: { host: '127.0.0.1:3000', origin }
|
||||
headers: { host: new URL(appOrigin).host, origin: appOrigin }
|
||||
}),
|
||||
route: { id },
|
||||
locals: {
|
||||
application: { config: { url: { app: origin } } },
|
||||
application: { config: { url: { app: appOrigin } } },
|
||||
session: null,
|
||||
auth: vi.fn(async () => null)
|
||||
},
|
||||
@@ -74,19 +74,27 @@ it('rejects anonymous endpoints and actions instead of resolving them', async ()
|
||||
}
|
||||
});
|
||||
|
||||
it('checks actual clients before reading private context and rejects a different canonical Host', async () => {
|
||||
const remote = setup('/(protected)');
|
||||
vi.mocked(remote.event.getClientAddress).mockReturnValue('198.51.100.10');
|
||||
Object.defineProperty(remote.event.locals, 'application', {
|
||||
get() {
|
||||
throw new Error('Private dependency');
|
||||
}
|
||||
it('accepts a canonical proxied request without treating its client IP as authorization', async () => {
|
||||
const input = setup('/(protected)', 'POST', 'https://reports.example.test');
|
||||
vi.mocked(input.event.getClientAddress).mockReturnValue('198.51.100.10');
|
||||
expect((await protectOrigin(input)).status).toBe(200);
|
||||
input.resolve.mockClear();
|
||||
expect((await requireSignIn(input)).status).toBe(401);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
vi.mocked(input.event.locals.auth).mockResolvedValue({
|
||||
user: { name: 'Reader' },
|
||||
expires: '2026-11-07T00:00:00.000Z'
|
||||
});
|
||||
expect((await protectOrigin(remote)).status).toBe(403);
|
||||
const alias = setup('/(protected)');
|
||||
alias.event.request.headers.set('host', 'localhost:3000');
|
||||
expect((await protectOrigin(alias)).status).toBe(403);
|
||||
expect(alias.event.locals.auth).not.toHaveBeenCalled();
|
||||
expect((await requireSignIn(input)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects a different application origin before resolving authentication', async () => {
|
||||
const input = setup('/(protected)', 'GET', 'https://reports.example.test');
|
||||
const url = new URL('https://other.example.test/');
|
||||
input.event = { ...input.event, url, request: new Request(url) };
|
||||
expect((await protectOrigin(input)).status).toBe(403);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
expect(input.event.locals.auth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed without configured resources', async () => {
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { sequence, type Handle } from '@sveltejs/kit/hooks';
|
||||
import { apiError, isLocalRequest } from '../local-api.js';
|
||||
import { apiError, hasConfiguredOrigin } from '../http.js';
|
||||
|
||||
function isPublicRoute(id: string): boolean {
|
||||
return id === '/(public)' || id.startsWith('/(public)/');
|
||||
}
|
||||
|
||||
function needsLocalAccess(id: string | null): boolean {
|
||||
function needsApplicationOrigin(id: string | null): boolean {
|
||||
return (
|
||||
id !== null &&
|
||||
(!isPublicRoute(id) || id === '/(public)/(auth)' || id.startsWith('/(public)/(auth)/'))
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredOrigin(event: RequestEvent, origin: string): boolean {
|
||||
const host = event.request.headers.get('host');
|
||||
return event.url.origin === origin && new URL(`${event.url.protocol}//${host}`).origin === origin;
|
||||
}
|
||||
|
||||
export const privateHeaders: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
if (!needsLocalAccess(event.route.id)) return response;
|
||||
if (!needsApplicationOrigin(event.route.id)) return response;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('cache-control', 'no-store');
|
||||
if (!headers.has('referrer-policy'))
|
||||
@@ -34,8 +29,7 @@ export const privateHeaders: Handle = async ({ event, resolve }) => {
|
||||
};
|
||||
|
||||
export const protectOrigin: Handle = ({ event, resolve }) => {
|
||||
if (!needsLocalAccess(event.route.id)) return resolve(event);
|
||||
if (!isLocalRequest(event)) return apiError(403, 'LOCAL_ONLY', 'Local access is required.');
|
||||
if (!needsApplicationOrigin(event.route.id)) return resolve(event);
|
||||
const application = event.locals.application;
|
||||
if (!application)
|
||||
return apiError(503, 'NOT_CONFIGURED', 'Application configuration is required.');
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { InvalidBody, isLocalRequest, readRequestText } from './local-api.js';
|
||||
import { hasConfiguredOrigin, InvalidBody, readRequestText } from './http.js';
|
||||
|
||||
it('requires both a loopback client and an independently valid raw Host, ignoring forwarded claims', () => {
|
||||
const url = new URL('http://localhost:5173/');
|
||||
const request = new Request(url, {
|
||||
headers: { host: url.host, 'x-forwarded-host': 'localhost' }
|
||||
});
|
||||
const event = { url, request, getClientAddress: () => '127.0.0.1' };
|
||||
expect(isLocalRequest(event)).toBe(true);
|
||||
expect(isLocalRequest({ ...event, getClientAddress: () => '192.168.1.2' })).toBe(false);
|
||||
request.headers.set('host', 'attacker.example@localhost');
|
||||
expect(isLocalRequest(event)).toBe(false);
|
||||
it('requires both framework and request origins to match the configured application', () => {
|
||||
const origin = 'https://reports.example.test';
|
||||
const url = new URL('/', origin);
|
||||
const request = new Request(url);
|
||||
expect(hasConfiguredOrigin({ url, request }, origin)).toBe(true);
|
||||
expect(
|
||||
hasConfiguredOrigin({ url: new URL('https://other.example.test/'), request }, origin)
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasConfiguredOrigin({ url, request: new Request('http://reports.example.test/') }, origin)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts exactly 2048 UTF-8 bytes even when a character spans chunks, and releases the reader', async () => {
|
||||
@@ -1,24 +1,10 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
const loopbackAddresses = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
const loopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
export function isLocalRequest(
|
||||
event: Pick<RequestEvent, 'getClientAddress' | 'url' | 'request'>
|
||||
export function hasConfiguredOrigin(
|
||||
event: Pick<RequestEvent, 'url' | 'request'>,
|
||||
origin: string
|
||||
): boolean {
|
||||
try {
|
||||
// Validate raw Host independently of the framework URL.
|
||||
const host = event.request.headers.get('host');
|
||||
if (!host || !/^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?$/i.test(host)) return false;
|
||||
const rawHostname = new URL(`http://${host}`).hostname;
|
||||
return (
|
||||
loopbackAddresses.has(event.getClientAddress()) &&
|
||||
loopbackHosts.has(event.url.hostname) &&
|
||||
loopbackHosts.has(rawHostname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return event.url.origin === origin && new URL(event.request.url).origin === origin;
|
||||
}
|
||||
|
||||
export function apiError(
|
||||
@@ -202,7 +202,6 @@ it('stops the waiter after native HTTP body EOF and disconnect, leaving the dura
|
||||
const response = await handleQuery({
|
||||
request: fetchRequest,
|
||||
url: new URL(fetchRequest.url),
|
||||
getClientAddress: () => '127.0.0.1',
|
||||
locals: {
|
||||
auth: () => {
|
||||
throw new Error('Direct route handlers must not resolve authentication.');
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type LiveQueryResult
|
||||
} from '@hledger-reports/jobs';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { apiError, InvalidBody, isLocalRequest, readBody } from './local-api.js';
|
||||
import { apiError, InvalidBody, readBody } from './http.js';
|
||||
|
||||
import { waitForReport } from './wait-for-report.js';
|
||||
|
||||
@@ -59,11 +59,8 @@ function safeFailure(error: unknown): Response {
|
||||
return apiError(500, 'INTERNAL_ERROR', 'The query service could not complete this request.');
|
||||
}
|
||||
|
||||
type QueryEvent = Pick<RequestEvent, 'getClientAddress' | 'url' | 'request' | 'locals'>;
|
||||
type QueryEvent = Pick<RequestEvent, 'url' | 'request' | 'locals'>;
|
||||
export async function handleQuery(event: QueryEvent): Promise<Response> {
|
||||
if (!isLocalRequest(event)) {
|
||||
return apiError(403, 'LOCAL_ONLY', 'Query reports are available on loopback only.');
|
||||
}
|
||||
if (event.request.headers.get('origin') !== event.url.origin) {
|
||||
return apiError(403, 'INVALID_ORIGIN', 'A same-origin request is required.');
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { isLocalRequest } from '#lib/server/local-api.js';
|
||||
import { readSavedReports } from '#lib/server/reports.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'The overview is available on loopback only.');
|
||||
const { application } = event.locals;
|
||||
if (!application) {
|
||||
return { configured: false, overview: null, readFailed: false };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SafeHoldingsJobError, validateHoldingsRequest } from '@hledger-reports/
|
||||
import { error, fail, type ActionFailure } from '@sveltejs/kit';
|
||||
import { registerMembers } from '#lib/server/accounts.js';
|
||||
import { runHoldingsReport } from '#lib/server/holdings.js';
|
||||
import { InvalidBody, isLocalRequest, readRequestText } from '#lib/server/local-api.js';
|
||||
import { InvalidBody, readRequestText } from '#lib/server/http.js';
|
||||
import { readSavedReports } from '#lib/server/reports.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
@@ -20,7 +20,6 @@ const accountIdPattern = /^[a-z0-9]{8}(?![\s\S])/;
|
||||
export const load = (async (event): Promise<HoldingsPageData> => {
|
||||
// Ordinary POST failures render this load after the action has set headers.
|
||||
if (event.request.method !== 'POST') event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'Holdings are available on loopback only.');
|
||||
const origin = event.request.headers.get('origin');
|
||||
if (origin !== null && origin !== event.url.origin)
|
||||
error(403, 'A same-origin request is required.');
|
||||
@@ -75,8 +74,8 @@ export const actions = {
|
||||
{ result: HoldingsReport; asOf: string; accountId: string } | ActionFailure<HoldingsFormFailure>
|
||||
> => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event) || event.request.headers.get('origin') !== event.url.origin) {
|
||||
return fail(403, { message: 'A local, same-origin request is required.' });
|
||||
if (event.request.headers.get('origin') !== event.url.origin) {
|
||||
return fail(403, { message: 'A same-origin request is required.' });
|
||||
}
|
||||
if (
|
||||
event.request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !==
|
||||
|
||||
@@ -3,15 +3,14 @@ import {
|
||||
requestReportPreparation,
|
||||
type ReportsPreparationState
|
||||
} from '#lib/server/preparation.js';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { InvalidBody, isLocalRequest, readRequestText } from '#lib/server/local-api.js';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { InvalidBody, readRequestText } from '#lib/server/http.js';
|
||||
import { readSavedReports } from '#lib/server/reports.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async (event) => {
|
||||
// A failed ordinary POST renders this load with headers already set by the action.
|
||||
if (event.request.method !== 'POST') event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'Report preparation is available on loopback only.');
|
||||
const { application } = event.locals;
|
||||
const configured = application !== null;
|
||||
let preparation: ReportsPreparationState | 'unavailable' = 'idle';
|
||||
@@ -35,8 +34,8 @@ export const load = (async (event) => {
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event) || event.request.headers.get('origin') !== event.url.origin) {
|
||||
return fail(403, { message: 'A local, same-origin request is required.' });
|
||||
if (event.request.headers.get('origin') !== event.url.origin) {
|
||||
return fail(403, { message: 'A same-origin request is required.' });
|
||||
}
|
||||
const { application } = event.locals;
|
||||
if (!application) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { isLocalRequest } from '#lib/server/local-api.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'Query reports are available on loopback only.');
|
||||
return { enabled: event.locals.application !== null };
|
||||
};
|
||||
@@ -57,10 +57,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>Cost balance query — hledger reports</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Local-only fixture queries: native hledger balance at recorded cost."
|
||||
/>
|
||||
<meta name="description" content="Native hledger balance at recorded cost." />
|
||||
</svelte:head>
|
||||
|
||||
<main class="page" id="main" tabindex="-1">
|
||||
@@ -106,8 +103,7 @@
|
||||
{#if failureStatus === 400}
|
||||
Invalid query. Use a query of at most 512 characters and a valid as-of date.
|
||||
{:else if failureStatus === 403}
|
||||
Queries require a local, same-origin browser connection. Proxy and public access are not
|
||||
supported.
|
||||
Use the configured application address and submit from the same origin.
|
||||
{:else if failureStatus === 409}
|
||||
The query service could not submit this request. Try Run again.
|
||||
{:else if failureStatus === 502}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { registerMembers, registerDirectory } from '#lib/server/accounts.js';
|
||||
import type { CashAccountsData } from '#lib/registers/cash/data.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { isLocalRequest } from '#lib/server/local-api.js';
|
||||
import { readSavedReports } from '#lib/server/reports.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async (event): Promise<CashAccountsData> => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'Cash registers are available on loopback only.');
|
||||
const { application } = event.locals;
|
||||
const data: CashAccountsData = {
|
||||
configured: false,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { registerMembers } from '#lib/server/accounts.js';
|
||||
import { readAccountTransactions } from '@hledger-reports/snapshots';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { CashRegisterData } from '#lib/registers/cash/data.js';
|
||||
import { isLocalRequest } from '#lib/server/local-api.js';
|
||||
import { readSavedReports } from '#lib/server/reports.js';
|
||||
import { cashRegisterPageSize, mapCashRegisterPage } from '#lib/server/registers/cash.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
@@ -31,7 +30,6 @@ function parseRequest(
|
||||
|
||||
export const load = (async (event): Promise<CashRegisterData> => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'Cash registers are available on loopback only.');
|
||||
const { accountId, order, page } = parseRequest(event.params.accountId, event.url.searchParams);
|
||||
const { application } = event.locals;
|
||||
const data: CashRegisterData = {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { registerMembers, registerDirectory } from '#lib/server/accounts.js';
|
||||
import type { InvestmentAccountsData } from '#lib/registers/investment/data.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { isLocalRequest } from '#lib/server/local-api.js';
|
||||
import { readSavedReports } from '#lib/server/reports.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async (event): Promise<InvestmentAccountsData> => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'Investment registers are available on loopback only.');
|
||||
if (event.url.searchParams.size > 0) error(400, 'Unsupported register parameter.');
|
||||
const { application } = event.locals;
|
||||
const data: InvestmentAccountsData = {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
InvestmentAccountPageData,
|
||||
InvestmentRegisterData
|
||||
} from '#lib/registers/investment/data.js';
|
||||
import { isLocalRequest } from '#lib/server/local-api.js';
|
||||
import { readSavedReports } from '#lib/server/reports.js';
|
||||
import {
|
||||
investmentRegisterPageSize,
|
||||
@@ -37,7 +36,6 @@ function parseRequest(
|
||||
|
||||
export const load = (async (event): Promise<InvestmentAccountPageData> => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
if (!isLocalRequest(event)) error(403, 'Investment registers are available on loopback only.');
|
||||
const { accountId, order, page } = parseRequest(event.params.accountId, event.url.searchParams);
|
||||
const { application } = event.locals;
|
||||
const data: InvestmentAccountPageData = {
|
||||
|
||||
@@ -24,7 +24,7 @@ function poisonedEvent() {
|
||||
}
|
||||
|
||||
describe('GET /schema.json', () => {
|
||||
it('serves the bundled artifact without request dependencies or loopback authentication', async () => {
|
||||
it('serves the bundled artifact without request dependencies or authentication', async () => {
|
||||
const response = await GET(poisonedEvent());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -5,16 +5,11 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
const useWorkspaceSource = command === 'serve' && !process.env.VITEST;
|
||||
let origin: string | undefined;
|
||||
if (command === 'build') {
|
||||
origin = process.env.ORIGIN ?? 'http://127.0.0.1:3000';
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
sveltekit({
|
||||
adapter: adapter(),
|
||||
paths: { origin },
|
||||
preprocess: vitePreprocess(),
|
||||
compilerOptions: {
|
||||
runes: ({ filename }) =>
|
||||
|
||||
+39
-9
@@ -4,10 +4,39 @@ One deployment serves one journal. Every signed-in user can browse saved reports
|
||||
prepare reports and run live queries or Holdings. There are no roles, managed users,
|
||||
signup, invitations, personal journals or application email allowlists.
|
||||
|
||||
Authentication does **not** make this a public deployment. Listeners remain
|
||||
loopback-only, actual client addresses and raw Host are checked, and mutating
|
||||
requests require the exact configured Origin. Proxy headers are not trusted.
|
||||
Public/proxy deployment remains a separate security and packaging change.
|
||||
Local development remains loopback-bound. A compiled web instance can also sit
|
||||
behind a trusted HTTPS proxy; authentication and canonical Host/Origin checks still
|
||||
apply. This is a request-compatibility contract, not a production deployment recipe.
|
||||
|
||||
## Trusted-proxy contract
|
||||
|
||||
The application listener must be reachable only through the trusted proxy. Compiled
|
||||
Compose sets adapter-node's runtime `PROTOCOL_HEADER=x-forwarded-proto` and
|
||||
`ADDRESS_HEADER=x-real-ip`. The proxy must overwrite both headers with the actual
|
||||
public scheme and client address, preserve the public `Host`, and be the only route
|
||||
to the upstream. Do not trust browser-supplied values or append-only header chains.
|
||||
|
||||
The framework constructs request URLs from scheme and Host. Both `event.url.origin`
|
||||
and the origin of `request.url` must equal normalized YAML `url.app`, the authoritative
|
||||
public origin. There is no custom raw Host parser. Forwarded host is ignored by
|
||||
default; only an explicit native `HOST_HEADER` setting changes that policy, and its
|
||||
chosen header must also be overwritten by the trusted proxy.
|
||||
|
||||
There is no per-build public URL (`paths.origin` or Docker `ARG ORIGIN`) and no runtime
|
||||
`ORIGIN` override. Adapter-node 6's `ORIGIN` is not a replacement for protocol headers;
|
||||
without a trusted protocol it assumes HTTPS, so its compiled entry point is not a
|
||||
direct HTTP browser preview.
|
||||
|
||||
Authentication copies `event.getClientAddress()` into its own private client-address
|
||||
header, replacing any incoming value. The framework owns proxy/IP policy; the app
|
||||
does not parse forwarding chains. Remote clients still need a session, and unsafe
|
||||
methods require an exact `Origin` matching `url.app`. Financial responses remain
|
||||
no-store.
|
||||
|
||||
Local Vite/mise development and listener bindings are unchanged; Vite derives its
|
||||
own request origin. The owned compiled browser fixtures instead use a test-only
|
||||
loopback HTTP boundary, not a deployment option or authentication bypass. Broader
|
||||
production work remains deferred.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -19,7 +48,7 @@ with an example. Journal-only configuration is no longer sufficient for web star
|
||||
version: 1
|
||||
journal: /journal/main.journal
|
||||
url:
|
||||
app: http://127.0.0.1:3000
|
||||
app: http://127.0.0.1:5173
|
||||
auth:
|
||||
secret: '${AUTH_SECRET}'
|
||||
credentials:
|
||||
@@ -35,14 +64,15 @@ secret is supported, but the YAML file must remain private. Omitting `auth.secre
|
||||
means `${AUTH_SECRET}`, not unauthenticated access.
|
||||
|
||||
`url.app` is an absolute origin, without credentials, path, query or fragment. It
|
||||
must match the browser address. The compiled build's `ORIGIN` must also match it;
|
||||
runtime `ORIGIN` cannot change an adapter-node build. The default compiled listener
|
||||
is `http://127.0.0.1:3000`; development uses `http://127.0.0.1:5173`.
|
||||
must match the browser address and framework request URLs. The example above is
|
||||
for local Vite development; compiled proxy setups use their public HTTPS origin.
|
||||
|
||||
The tracked Compose example uses `${APP_URL:-http://127.0.0.1:3000}`; development
|
||||
Compose defaults `APP_URL` to `http://127.0.0.1:5173` but honors an explicit override
|
||||
such as `http://localhost:5173`. Recreate containers after changing that environment
|
||||
value and browse using the exact configured origin. Compose passes `AUTH_SECRET`,
|
||||
value and browse using the exact configured origin. The compiled proxy setup must
|
||||
set `APP_URL` to its public origin; its loopback HTTP fallback is for fixtures, not
|
||||
a direct compiled browser preview. Compose passes `AUTH_SECRET`,
|
||||
`AUTH_PASSWORD` and `OIDC_CLIENT_SECRET` to web, not worker. Other environment
|
||||
references require explicit deployment environment wiring. Restart both services
|
||||
when changing the shared file; no financial reports are prepared on restart or login.
|
||||
|
||||
+33
-23
@@ -19,7 +19,7 @@ one auth strategy without changing its journal selection. See
|
||||
[authentication setup](authentication.md). The example uses `APP_URL`; development
|
||||
Compose defaults it to `http://127.0.0.1:5173` and honors an explicit `APP_URL`
|
||||
override, including one supplied by mise. Recreate containers after changing it;
|
||||
`localhost` and `127.0.0.1` are distinct origins. The compiled default is port `3000`.
|
||||
`localhost` and `127.0.0.1` are distinct origins. The compiled upstream listens on port `3000`.
|
||||
Do not use either authenticated configuration to open a public listener.
|
||||
|
||||
Start development:
|
||||
@@ -105,7 +105,7 @@ The new Compose file selects that ignored live file. Explicit native `CONFIG_PAT
|
||||
|
||||
The container journal path `/journal/main.journal`, host journal directory and sibling `data/jobs`/`data/snapshots` locations are unchanged. Renaming only the YAML file within its existing `config/` directory does not move data or change journal identity. Never delete or recreate data to complete this transition.
|
||||
|
||||
From the repository root, `pnpm config:validate --config apps/web/config/hledger-reports.yaml` checks the selected config and environment expansion without opening journals, running hledger or creating data. `pnpm worker:inspect` remains the separate native fixture preflight. `/schema.json` serves the generated schema, not configuration; use the actual listener URL in the YAML language-server comment (`5173` for default development, `3000` for the compiled default).
|
||||
From the repository root, `pnpm config:validate --config apps/web/config/hledger-reports.yaml` checks the selected config and environment expansion without opening journals, running hledger or creating data. `pnpm worker:inspect` remains the separate native fixture preflight. `/schema.json` serves the generated schema, not configuration; use the browser-facing URL in the YAML language-server comment (`http://127.0.0.1:5173` for default Vite development).
|
||||
|
||||
## Inspecting data and preserving the previous volume
|
||||
|
||||
@@ -195,15 +195,23 @@ journal view; edits during a calculation do not trigger another job automaticall
|
||||
|
||||
## Compiled preview
|
||||
|
||||
This compiled setup requires an existing trusted HTTPS proxy under the
|
||||
[trusted-proxy contract](authentication.md#trusted-proxy-contract), with YAML
|
||||
`url.app` (the example's `APP_URL`) set to its public origin. Compose supplies
|
||||
`PROTOCOL_HEADER=x-forwarded-proto` and `ADDRESS_HEADER=x-real-ip`; the proxy must
|
||||
overwrite both and preserve public Host. It must be the only route to the upstream.
|
||||
|
||||
Stop live development before switching; both modes use the same project, data
|
||||
volume, and worker health port.
|
||||
folder, and worker health port.
|
||||
|
||||
```sh
|
||||
docker compose -f apps/web/docker-compose.dev.yml down
|
||||
docker compose -f apps/web/docker-compose.yml up --build --wait
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:3000/** for Overview, `/transactions` for account activity, or `/queries` for live queries.
|
||||
Browse through that proxy, not directly at `http://127.0.0.1:3000/`: adapter-node 6
|
||||
assumes HTTPS without a trusted protocol. For direct local HTTP browsing, keep using
|
||||
Vite development. No proxy service or new listener binding is added here.
|
||||
These are pruned production images running
|
||||
compiled JavaScript, not Vite or the TypeScript development loader. Source edits
|
||||
need another build. Web has no hledger executable; only the worker image does.
|
||||
@@ -245,14 +253,23 @@ This runs type checks, lint, unit/integration tests, builds, and native prefligh
|
||||
with Node **26.5.0**, pnpm **11.9.0**, and checksum-verified hledger **1.99.5**.
|
||||
Tests have their own loopback network and do not mount your source or data volume.
|
||||
|
||||
Test the compiled preview with Chromium:
|
||||
For the compiled **fictional-fixture** deployment, Chromium uses the test-only override:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/web/docker-compose.yml run --build --rm e2e
|
||||
docker compose -f apps/web/docker-compose.yml -f apps/web/docker-compose.e2e.yml run --build --rm e2e
|
||||
```
|
||||
|
||||
The command starts required services if needed. To exercise the live dev server,
|
||||
use `docker-compose.dev.yml` instead. The small smoke suite covers sign-in with and
|
||||
This is the GitHub compiled-fixture path, not a command for personal data. The
|
||||
override reuses the base Compose configuration, journal and data mounts; it does
|
||||
not create or isolate a fictional deployment. Use it only in an already isolated
|
||||
fictional-fixture checkout, as CI does. For ordinary local browser testing, prefer
|
||||
the host-native command below, which creates its own temporary deployment.
|
||||
|
||||
The override runs `e2e/web.mjs`: an owned loopback HTTP boundary that overwrites protocol
|
||||
and address headers with the actual HTTP scheme and socket IP before invoking the
|
||||
exported native server handler. Browsers send normal headers and sign in normally;
|
||||
there is no authentication bypass. Existing Vite Compose E2E remains unchanged:
|
||||
use `docker-compose.dev.yml` alone for that fixture. The small smoke suite covers sign-in with and
|
||||
without JavaScript, explicit preparation and saved Overview, Cash/Investment browsing,
|
||||
Holdings and a live query. It uses real services rather than mocked financial
|
||||
publications or response matrices. Parser, financial, storage and failure details
|
||||
@@ -273,28 +290,21 @@ prepares bind-directory ownership for the unprivileged image user; this does not
|
||||
change normal runtime users or grant access to private host data.
|
||||
|
||||
This mode requires host hledger 1.99.5 and browser OS dependencies. Playwright
|
||||
starts compiled web/worker processes on ports 3000/4193 against a fresh temporary
|
||||
uses the same owned HTTP boundary for compiled web and starts the worker on ports
|
||||
3000/4193 respectively, against a fresh temporary
|
||||
deployment with its own copied journal tree, and removes it on shutdown. Port 3000
|
||||
must be free: Playwright refuses to reuse an existing server. It never reuses the
|
||||
development data volume or mutates an operator's journal. Browser helpers observe
|
||||
reports through the UI rather than publishing fixture snapshots. `PLAYWRIGHT_BASE_URL` instead targets an existing **fictional-fixture**
|
||||
server; it does not change application configuration.
|
||||
|
||||
## Local-only networking and platforms
|
||||
## Networking and platforms
|
||||
|
||||
Unlike When's authenticated app, this reporter still rejects non-loopback client
|
||||
addresses and URL/raw Host names. Publishing a bridge container's port on
|
||||
127.0.0.1 does **not** preserve the client's loopback address inside the container.
|
||||
Rather than weakening those independent guards or trusting forwarded headers,
|
||||
these Compose files use **host networking** and bind listeners to **127.0.0.1**.
|
||||
No reverse proxy or authentication bypass is added. Compiled web builds configure
|
||||
SvelteKit's `paths.origin`, defaulting to `http://127.0.0.1:3000`: adapter-node 6
|
||||
otherwise assumes HTTPS. Set `ORIGIN` when building, or pass it as a Docker build
|
||||
argument, to select another origin; setting it only when starting the compiled
|
||||
server no longer works. The listening port and browser URL must match that build.
|
||||
The owned browser fixture uses the default origin. Vite development derives its
|
||||
own request origin. Raw Host and actual client-address checks remain independent;
|
||||
forwarded headers are not a substitute.
|
||||
The checked-in development/preview Compose files still use **host networking** and
|
||||
bind listeners to **127.0.0.1**. They do not expose the app or configure a proxy.
|
||||
The compiled app supports an existing trusted proxy that preserves the public Host
|
||||
and exclusively controls upstream access. See the [trusted-proxy contract](authentication.md#trusted-proxy-contract)
|
||||
for client-address forwarding and origin requirements. Authentication remains mandatory.
|
||||
|
||||
Use Linux Docker Engine, or Docker Desktop 4.34+ with host networking enabled.
|
||||
Stop existing native servers using ports 3000, 5173, or 9000 before starting the
|
||||
|
||||
@@ -14,7 +14,7 @@ auth:
|
||||
password: '${AUTH_PASSWORD}'
|
||||
```
|
||||
|
||||
Use the actual web listener's schema URL (`3000` for the default compiled build).
|
||||
Use the browser-facing schema URL.
|
||||
The endpoint serves the bundled editor artifact, never live configuration. The
|
||||
tracked `schema.json` package export is also available for offline editors.
|
||||
|
||||
@@ -23,7 +23,10 @@ tracked `schema.json` package export is also available for offline editors.
|
||||
`version` defaults to `1` when omitted; other values are rejected. `journal` is
|
||||
required and nonblank, with no control characters. Unknown fields are rejected:
|
||||
storage directories and reporting currency are not settings.
|
||||
`url.app` is a required canonical HTTP(S) origin. `auth` requires exactly one
|
||||
`url.app` is the required, authoritative canonical HTTP(S) origin. Both framework
|
||||
request URL origins must match its normalized value; compiled proxy setups derive
|
||||
their scheme and Host through adapter-node, not a build-time public URL. Local Vite
|
||||
development is unchanged. `auth` requires exactly one
|
||||
`credentials` or `oidc` branch; its optional `secret` defaults to `${AUTH_SECRET}`
|
||||
and must resolve to at least 32 characters. There is no generated fallback. See
|
||||
[authentication](../../docs/authentication.md) for provider and session semantics.
|
||||
@@ -131,5 +134,5 @@ config directory are not independent deployments. Keep the selected journal path
|
||||
unchanged to retain its saved-report identity. No data migration occurs.
|
||||
|
||||
The repository-local live YAML is ignored; only a safe example is tracked.
|
||||
Explicit `CONFIG_PATH`/`--config` selections may use another filename. Actual-client
|
||||
loopback and same-origin protections remain separate from configuration loading.
|
||||
Explicit `CONFIG_PATH`/`--config` selections may use another filename. Authentication and
|
||||
canonical-origin protections remain separate from configuration loading.
|
||||
Reference in new issue
Block a user