feat(config): group app and worker URLs #23
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@goto/web': minor
|
||||
'@goto/worker': minor
|
||||
'@goto/cli': minor
|
||||
---
|
||||
|
||||
Group application addresses under `url.app` and `url.worker`, replacing `site.url` directly. Default the private worker base URL to `http://goto-worker:9000`. Keep site branding separate and preserve existing application-origin behavior.
|
||||
@@ -9,8 +9,8 @@ const env = {
|
||||
DUMMY_SECRET: 'dummy-session-secret-with-at-least-thirty-two-characters-do-not-print',
|
||||
DUMMY_DESTINATION: 'https://destination.example.test/private-do-not-print'
|
||||
};
|
||||
const validYaml = `site:
|
||||
url: https://admin.example.test
|
||||
const validYaml = `url:
|
||||
app: https://admin.example.test
|
||||
auth:
|
||||
secret: \${DUMMY_SECRET}
|
||||
credentials:
|
||||
|
||||
@@ -139,7 +139,7 @@ async function start() {
|
||||
await writeFile(
|
||||
join(directory, 'config/goto.yaml'),
|
||||
JSON.stringify({
|
||||
site: { url: origin },
|
||||
url: { app: origin },
|
||||
auth: { credentials: { username: 'hmr-test', password: 'hmr-test-only-password' } },
|
||||
domains: {
|
||||
[`go.localhost:${address.port}`]: {
|
||||
|
||||
@@ -24,7 +24,7 @@ await new Promise((resolve, reject) =>
|
||||
);
|
||||
process.env.PORT = String(address.port);
|
||||
const configuration = {
|
||||
site: { url: `http://localhost:${address.port}` },
|
||||
url: { app: `http://localhost:${address.port}` },
|
||||
auth: { credentials: { username: 'example', password: 'e2e-only-password' } },
|
||||
analytics: { retention_months: Number(process.env.GOTO_E2E_RETENTION_MONTHS ?? '3') },
|
||||
domains: {
|
||||
|
||||
@@ -30,7 +30,7 @@ function event(
|
||||
function application(): Application {
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
site: { url: 'https://admin.example.org' },
|
||||
url: { app: 'https://admin.example.org' },
|
||||
auth: { credentials: { username: 'example', password: 'test-password' } },
|
||||
domains: { 'go.example.org': { links: {} } }
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ export const applicationContext: Handle = ({ event, resolve }) => {
|
||||
export const adminOriginGate: Handle = ({ event, resolve }) => {
|
||||
if (
|
||||
event.route.id?.startsWith('/(admin)') &&
|
||||
event.url.origin !== event.locals.snapshot?.configuration.site.url
|
||||
event.url.origin !== event.locals.snapshot?.configuration.url.app
|
||||
) {
|
||||
return new Response('Not found', { status: 404, headers: { 'cache-control': 'no-store' } });
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function prepared(
|
||||
) {
|
||||
return validateConfig(
|
||||
{
|
||||
site: { url: 'https://admin.example.test' },
|
||||
url: { app: 'https://admin.example.test' },
|
||||
auth: { credentials: { username: 'example', password: 'test-only-password' } },
|
||||
domains: { 'go.example.test': { links: { '/docs': { url: destination } } } },
|
||||
...changes
|
||||
@@ -96,7 +96,7 @@ test('publishes valid changes, ignores equivalent data, and retains last good da
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ site: { url: 'https://different-admin.example.test' } },
|
||||
{ url: { app: 'https://different-admin.example.test' } },
|
||||
{ auth: { credentials: { username: 'changed', password: 'private-changed-password' } } },
|
||||
{ database: { path: './data/different.sqlite' } }
|
||||
])('rejects the whole candidate when startup settings change: %j', async (changes) => {
|
||||
|
||||
@@ -29,7 +29,7 @@ function reportReloadIssue(message: string): void {
|
||||
|
||||
function startupSettings({ configuration }: PreparedConfiguration) {
|
||||
return {
|
||||
siteUrl: configuration.site.url,
|
||||
appUrl: configuration.url.app,
|
||||
auth: configuration.auth,
|
||||
databasePath: configuration.database.path,
|
||||
collectionEnabled: configuration.analytics.retention_months !== 0
|
||||
|
||||
@@ -12,7 +12,7 @@ const foreignCookies =
|
||||
function authentication(origin: string) {
|
||||
const { configuration } = validateConfig(
|
||||
{
|
||||
site: { url: origin },
|
||||
url: { app: origin },
|
||||
auth: { secret: 'cookie-test-secret-with-at-least-thirty-two-characters', credentials },
|
||||
domains: { 'go.example.test': { links: {} } }
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface GotoSession {
|
||||
}
|
||||
|
||||
export function createAuthentication(cfg: Immutable<GotoConfiguration>) {
|
||||
const origin = cfg.site.url;
|
||||
const origin = cfg.url.app;
|
||||
const secret = resolveAuthSecret(cfg.auth.secret);
|
||||
return betterAuth<BetterAuthOptions & { baseURL: string }>({
|
||||
secret,
|
||||
@@ -58,7 +58,7 @@ export const authHandle: Handle = async ({ event, resolve }) => {
|
||||
const adminRoute = event.route.id?.startsWith('/(admin)');
|
||||
const authPath = event.url.pathname === '/auth' || event.url.pathname.startsWith('/auth/');
|
||||
if (
|
||||
event.url.origin !== event.locals.snapshot?.configuration.site.url ||
|
||||
event.url.origin !== event.locals.snapshot?.configuration.url.app ||
|
||||
(!adminRoute && !authPath)
|
||||
) {
|
||||
return resolve(event);
|
||||
|
||||
@@ -12,7 +12,7 @@ type AuthOptions = Pick<BetterAuthOptions, 'plugins' | 'onAPIError'>;
|
||||
|
||||
export function buildAuthOptions(cfg: Immutable<GotoConfiguration>): AuthOptions {
|
||||
if (cfg.auth.credentials) return credentialsAuth(cfg.auth.credentials, 'admin@goto.invalid');
|
||||
if (cfg.auth.oidc) return oidcAuth(cfg.auth.oidc, cfg.site.url);
|
||||
if (cfg.auth.oidc) return oidcAuth(cfg.auth.oidc, cfg.url.app);
|
||||
throw new Error('an authentication strategy is required');
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { prepareSnapshot } from './snapshot.js';
|
||||
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
site: { url: 'https://admin.example.test' },
|
||||
url: { app: 'https://admin.example.test' },
|
||||
auth: { credentials: { username: 'example', password: 'unit-only-password' } },
|
||||
domains: {
|
||||
'go.example.test': {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function handleIngress(
|
||||
}
|
||||
return redirect.response;
|
||||
}
|
||||
if (ingress.url.origin === snapshot.configuration.site.url) {
|
||||
if (ingress.url.origin === snapshot.configuration.url.app) {
|
||||
bindApplication(request, application, ingress.url.origin, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { prepareSnapshot } from './snapshot.js';
|
||||
function application(): Application {
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
site: { url: 'https://admin.example.org' },
|
||||
url: { app: 'https://admin.example.org' },
|
||||
auth: { credentials: { username: 'example', password: 'test-password' } },
|
||||
domains: {}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import { prepareSnapshot } from './snapshot.js';
|
||||
function application(): Application {
|
||||
const prepared = validateConfig(
|
||||
{
|
||||
site: { url: 'https://admin.example.org' },
|
||||
url: { app: 'https://admin.example.org' },
|
||||
auth: { credentials: { username: 'example', password: 'test-password' } },
|
||||
domains: {}
|
||||
},
|
||||
@@ -97,7 +97,7 @@ test('carries the trusted request origin separately from the configured admin or
|
||||
bindApplication(incoming, captured, 'http://localhost:5173');
|
||||
|
||||
expect(requestOrigin(incoming)).toBe('http://localhost:5173');
|
||||
expect(requestApplication(incoming)?.currentSnapshot().configuration.site.url).toBe(
|
||||
expect(requestApplication(incoming)?.currentSnapshot().configuration.url.app).toBe(
|
||||
'https://admin.example.org'
|
||||
);
|
||||
});
|
||||
@@ -111,7 +111,8 @@ test('an existing request retains its snapshot when the application publishes an
|
||||
current = prepareSnapshot(
|
||||
validateConfig(
|
||||
{
|
||||
site: { url: 'https://admin.example.org', title: 'Updated' },
|
||||
url: { app: 'https://admin.example.org' },
|
||||
site: { title: 'Updated' },
|
||||
auth: { credentials: { username: 'example', password: 'test-password' } },
|
||||
domains: {}
|
||||
},
|
||||
|
||||
@@ -68,7 +68,7 @@ async function checkStartup(mode, port = 0) {
|
||||
await writeFile(
|
||||
join(directory, 'config/goto.yaml'),
|
||||
JSON.stringify({
|
||||
site: { url: 'http://admin.example.test' },
|
||||
url: { app: 'http://admin.example.test' },
|
||||
auth: { credentials: { username: 'smoke', password: 'private-worker-smoke-password' } },
|
||||
analytics: { retention_months: mode === 'retention-change' ? 9 : 0 },
|
||||
domains: {}
|
||||
|
||||
@@ -25,7 +25,7 @@ afterEach(async () => {
|
||||
|
||||
async function writeConfiguration(worker_path = './state/worker.sqlite'): Promise<void> {
|
||||
const configuration = {
|
||||
site: { url: 'http://admin.example.test' },
|
||||
url: { app: 'http://admin.example.test' },
|
||||
auth: { credentials: { username: 'test', password: 'worker-test-only-password' } },
|
||||
database: { path: './data/visits.sqlite', worker_path },
|
||||
analytics: { retention_months: 0 },
|
||||
|
||||
+4
-1
@@ -1,8 +1,11 @@
|
||||
# yaml-language-server: $schema=https://goto.example.net/schema.json
|
||||
version: 1
|
||||
|
||||
url:
|
||||
app: https://goto.example.net
|
||||
worker: http://goto-worker:9000
|
||||
|
||||
site:
|
||||
url: https://goto.example.net
|
||||
title: Goto
|
||||
|
||||
appearance:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"required": ["auth", "domains"],
|
||||
"properties": {
|
||||
"version": { "default": 1, "const": 1, "type": "number" },
|
||||
"url": { "default": {}, "$ref": "#/$defs/Url" },
|
||||
"site": { "default": {}, "$ref": "#/$defs/Site" },
|
||||
"appearance": { "default": {}, "$ref": "#/$defs/Appearance" },
|
||||
"auth": { "$ref": "#/$defs/Auth" },
|
||||
@@ -49,7 +50,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SiteUrl": {
|
||||
"BaseUrl": {
|
||||
"description": "An absolute HTTP or HTTPS base URL without embedded credentials, query or fragment. A base path and trailing slash are allowed.",
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^[Hh][Tt][Tt][Pp][Ss]?://[^\\s/@?#]+(?:/[^\\s?#]*)?(?![\\s\\S])"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AppUrl": {
|
||||
"description": "The canonical admin HTTP(S) origin, without credentials, path, trailing slash, query or fragment.",
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
@@ -135,11 +151,18 @@
|
||||
]
|
||||
},
|
||||
"Fallback": { "anyOf": [{ "const": 404, "type": "number" }, { "$ref": "#/$defs/LinkUrl" }] },
|
||||
"Url": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"app": { "default": "http://localhost:3000", "$ref": "#/$defs/AppUrl" },
|
||||
"worker": { "default": "http://goto-worker:9000", "$ref": "#/$defs/BaseUrl" }
|
||||
}
|
||||
},
|
||||
"Site": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "default": "http://localhost:3000", "$ref": "#/$defs/SiteUrl" },
|
||||
"title": {
|
||||
"default": "Goto",
|
||||
"type": "string",
|
||||
|
||||
@@ -22,14 +22,20 @@ describe('Goto editor schema', () => {
|
||||
it('relaxes defaulted fields while keeping authentication, domain links and destinations required', () => {
|
||||
const editor = createEditorSchema();
|
||||
expect(editor.required).toEqual(['auth', 'domains']);
|
||||
for (const name of ['Site', 'Appearance', 'Database', 'Analytics', 'Defaults']) {
|
||||
for (const name of ['Url', 'Site', 'Appearance', 'Database', 'Analytics', 'Defaults']) {
|
||||
expect(definition(editor, name).required).toBeUndefined();
|
||||
}
|
||||
expect(property(editor, 'site')).toEqual({ $ref: '#/$defs/Site', default: {} });
|
||||
expect(property(definition(editor, 'Site'), 'url')).toEqual({
|
||||
$ref: '#/$defs/SiteUrl',
|
||||
expect(object(definition(editor, 'Site').properties)).not.toHaveProperty('url');
|
||||
expect(property(editor, 'url')).toEqual({ $ref: '#/$defs/Url', default: {} });
|
||||
expect(property(definition(editor, 'Url'), 'app')).toEqual({
|
||||
$ref: '#/$defs/AppUrl',
|
||||
default: 'http://localhost:3000'
|
||||
});
|
||||
expect(property(definition(editor, 'Url'), 'worker')).toEqual({
|
||||
$ref: '#/$defs/BaseUrl',
|
||||
default: 'http://goto-worker:9000'
|
||||
});
|
||||
expect(definition(editor, 'DomainConfiguration').required).toEqual(['links']);
|
||||
expect(definition(editor, 'LinkConfiguration').required).toEqual(['url']);
|
||||
expect(property(definition(editor, 'LinkConfiguration'), 'url')).toEqual({
|
||||
@@ -88,7 +94,7 @@ describe('Goto editor schema', () => {
|
||||
|
||||
it('maps runtime formats to editor formats and keeps literal constraints separate from tokens', () => {
|
||||
const editor = createEditorSchema();
|
||||
for (const name of ['HttpUrl', 'LinkUrl', 'SiteUrl', 'Instant']) {
|
||||
for (const name of ['HttpUrl', 'LinkUrl', 'AppUrl', 'BaseUrl', 'Instant']) {
|
||||
const canonical = configurationReferences.find((schema) => schema.$id === name)!;
|
||||
const adapted = definition(editor, name);
|
||||
expect(branches(adapted)[0]).toEqual({
|
||||
@@ -271,7 +277,7 @@ describe('Goto editor schema', () => {
|
||||
const first = JSON.stringify(createEditorSchema());
|
||||
expect(JSON.stringify(createEditorSchema())).toBe(first);
|
||||
expect(JSON.stringify([GotoConfigurationSchema, configurationReferences])).toBe(before);
|
||||
for (const name of ['HttpUrl', 'LinkUrl', 'SiteUrl', 'Instant', 'AuthSecret']) {
|
||||
for (const name of ['HttpUrl', 'LinkUrl', 'AppUrl', 'BaseUrl', 'Instant', 'AuthSecret']) {
|
||||
const canonical = configurationReferences.find((schema) => schema.$id === name)!;
|
||||
for (const value of ['${VALUE}', false, 1]) {
|
||||
expect(Value.Check(canonical, value)).toBe(false);
|
||||
|
||||
@@ -115,7 +115,8 @@ function adaptSchemaForEditor(
|
||||
if (
|
||||
node.format === 'goto-http-url' ||
|
||||
node.format === 'goto-link-url' ||
|
||||
node.format === 'goto-site-url'
|
||||
node.format === 'goto-app-url' ||
|
||||
node.format === 'goto-base-url'
|
||||
) {
|
||||
node.format = 'uri';
|
||||
} else if (node.format === 'goto-instant') {
|
||||
|
||||
@@ -21,7 +21,8 @@ describe('configuration loading', () => {
|
||||
const { configuration } = validateConfig(input, options);
|
||||
expect(configuration).toMatchObject({
|
||||
version: 1,
|
||||
site: { url: 'http://localhost:3000', title: 'Goto' },
|
||||
url: { app: 'http://localhost:3000', worker: 'http://goto-worker:9000' },
|
||||
site: { title: 'Goto' },
|
||||
database: { path: './data/goto.sqlite', worker_path: './data/worker.sqlite' },
|
||||
analytics: { retention_months: 3 },
|
||||
defaults: { status_code: 302, forward_query: false, fallback: 404 }
|
||||
@@ -64,24 +65,28 @@ describe('configuration loading', () => {
|
||||
[{ PORT: '4321' }, 'http://localhost:4321'],
|
||||
[{ PORT: '80' }, 'http://localhost']
|
||||
])('derives an omitted origin from injected environment %j', (env, url) => {
|
||||
expect(validateConfig(configurationInput(), { env }).configuration.site.url).toBe(url);
|
||||
expect(validateConfig(configurationInput(), { env }).configuration.url.app).toBe(url);
|
||||
});
|
||||
|
||||
it('validates interpolated values and preserves an explicit canonical origin', () => {
|
||||
const input = {
|
||||
...configurationInput({ '/docs': { url: '${DESTINATION}' } }),
|
||||
site: { url: '${ADMIN_ORIGIN}' },
|
||||
url: { app: '${ADMIN_ORIGIN}', worker: '${WORKER_BASE}' },
|
||||
auth: { ...configurationInput().auth, secret: '${SECRET}' }
|
||||
};
|
||||
const env = {
|
||||
ADMIN_ORIGIN: 'https://admin.example.com',
|
||||
WORKER_BASE: 'http://worker.example.com:9100/private/',
|
||||
DESTINATION: 'https://example.com',
|
||||
SECRET: 's'.repeat(32),
|
||||
PORT: 'invalid'
|
||||
};
|
||||
const { configuration } = validateConfig(input, { env });
|
||||
expect(configuration.site.url).toBe(env.ADMIN_ORIGIN);
|
||||
expect(configuration.url).toEqual({ app: env.ADMIN_ORIGIN, worker: env.WORKER_BASE });
|
||||
expect(configuration.auth.secret).toBe(env.SECRET);
|
||||
expect(() =>
|
||||
validateConfig(input, { env: { ...env, WORKER_BASE: 'http://worker.example.com?secret' } })
|
||||
).toThrowError(new ConfigError());
|
||||
for (const destination of [
|
||||
'https://private:secret@example.com',
|
||||
'https://example.com/\r\nsecret'
|
||||
@@ -95,6 +100,54 @@ describe('configuration loading', () => {
|
||||
).toThrowError(new ConfigError());
|
||||
});
|
||||
|
||||
it('defaults omitted URL fields independently', () => {
|
||||
const input = configurationInput();
|
||||
expect(validateConfig({ ...input, url: {} }, options).configuration.url).toEqual({
|
||||
app: 'http://localhost:3000',
|
||||
worker: 'http://goto-worker:9000'
|
||||
});
|
||||
expect(
|
||||
validateConfig({ ...input, url: { app: 'https://admin.example.com' } }, options).configuration
|
||||
.url.worker
|
||||
).toBe('http://goto-worker:9000');
|
||||
expect(
|
||||
validateConfig(
|
||||
{ ...input, url: { worker: 'https://worker.example.com/private/' } },
|
||||
{ env: { NODE_ENV: 'development' } }
|
||||
).configuration.url
|
||||
).toEqual({ app: 'http://localhost:5173', worker: 'https://worker.example.com/private/' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'ftp://worker.example.com',
|
||||
'http://user:password@worker.example.com',
|
||||
'http://@worker.example.com',
|
||||
'http://worker.example.com?token=secret',
|
||||
'http://worker.example.com?',
|
||||
'http://worker.example.com#fragment',
|
||||
'http://worker.example.com#',
|
||||
'http://worker.example.com/private path'
|
||||
])('rejects invalid worker base URLs %j', (worker) => {
|
||||
expect(() => validateConfig({ ...configurationInput(), url: { worker } }, options)).toThrow(
|
||||
ConfigError
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects the old site URL key without accepting it as an app URL', () => {
|
||||
for (const url of [undefined, { app: 'https://admin.example.com' }]) {
|
||||
expect(() =>
|
||||
validateConfig(
|
||||
{
|
||||
...configurationInput(),
|
||||
...(url === undefined ? {} : { url }),
|
||||
site: { url: 'https://old.example.com', title: 'Custom title' }
|
||||
},
|
||||
options
|
||||
)
|
||||
).toThrow(ConfigError);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults the OIDC name after resolving authentication placeholders', () => {
|
||||
const env = {
|
||||
SECRET: 's'.repeat(32),
|
||||
@@ -137,6 +190,8 @@ describe('configuration loading', () => {
|
||||
{ unknown: 'private-value' },
|
||||
{ site: null },
|
||||
{ site: [] },
|
||||
{ url: null },
|
||||
{ url: [] },
|
||||
{ version: Infinity },
|
||||
{ auth: {} },
|
||||
{
|
||||
@@ -146,7 +201,7 @@ describe('configuration loading', () => {
|
||||
}
|
||||
},
|
||||
{ auth: { ...configurationInput().auth, secret: 'too-short-private-secret' } },
|
||||
{ site: { url: 'https://ADMIN.example.com/' } },
|
||||
{ url: { app: 'https://ADMIN.example.com/' } },
|
||||
{ defaults: { forward_query: null } },
|
||||
{ domains: [] },
|
||||
{ domains: { 'go.example.com': { links: [] } } },
|
||||
|
||||
@@ -64,7 +64,7 @@ export function validateConfig(raw: unknown, options: ConfigOptions = {}): Prepa
|
||||
assertSafeInput(raw);
|
||||
const copy = structuredClone(raw);
|
||||
const env = options.env ?? process.env;
|
||||
defaultSiteUrl(copy, env);
|
||||
defaultAppUrl(copy, env);
|
||||
const expanded = interpolate(copy, env);
|
||||
const defaulted = Value.Default(GotoConfigurationSchema, configurationReferences, expanded);
|
||||
assertArrayShapesPreserved(raw, defaulted);
|
||||
@@ -81,11 +81,11 @@ export function validateConfig(raw: unknown, options: ConfigOptions = {}): Prepa
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSiteUrl(raw: Record<string, unknown>, env: ConfigEnvironment): void {
|
||||
if (!Object.hasOwn(raw, 'site')) raw.site = {};
|
||||
const site = raw.site;
|
||||
if (typeof site !== 'object' || site === null || Array.isArray(site)) return;
|
||||
if (Object.hasOwn(site, 'url')) return;
|
||||
function defaultAppUrl(raw: Record<string, unknown>, env: ConfigEnvironment): void {
|
||||
if (!Object.hasOwn(raw, 'url')) raw.url = {};
|
||||
const url = raw.url;
|
||||
if (typeof url !== 'object' || url === null || Array.isArray(url)) return;
|
||||
if (Object.hasOwn(url, 'app')) return;
|
||||
const port = env.PORT ?? (env.NODE_ENV === 'development' ? '5173' : '3000');
|
||||
const number = Number(port);
|
||||
if (
|
||||
@@ -99,6 +99,6 @@ function defaultSiteUrl(raw: Record<string, unknown>, env: ConfigEnvironment): v
|
||||
) {
|
||||
throw new ConfigError();
|
||||
}
|
||||
(site as Record<string, unknown>).url =
|
||||
(url as Record<string, unknown>).app =
|
||||
number === 80 ? 'http://localhost' : `http://localhost:${number}`;
|
||||
}
|
||||
@@ -204,7 +204,7 @@ describe('effective domains', () => {
|
||||
it('rejects the admin host, noncanonical paths and reversed or empty validity windows', () => {
|
||||
const input = configurationInput();
|
||||
expect(() =>
|
||||
validateConfig({ ...input, site: { url: 'https://go.example.com' } }, options)
|
||||
validateConfig({ ...input, url: { app: 'https://go.example.com' } }, options)
|
||||
).toThrow(ConfigError);
|
||||
expect(() =>
|
||||
validateConfig(configurationInput({ '/docs/': { url: 'https://example.com' } }), options)
|
||||
|
||||
@@ -49,7 +49,7 @@ export function prepareDomains(
|
||||
authoredDefaults: object
|
||||
): Record<string, PreparedDomain> {
|
||||
const domains: Record<string, PreparedDomain> = Object.create(null);
|
||||
const adminHost = normalizeHost(new URL(configuration.site.url).host);
|
||||
const adminHost = normalizeHost(new URL(configuration.url.app).host);
|
||||
for (const [key, domain] of Object.entries(configuration.domains)) {
|
||||
const host = normalizeHost(key);
|
||||
if (!host || host === adminHost || Object.hasOwn(domains, host)) {
|
||||
|
||||
@@ -33,7 +33,18 @@ FormatRegistry.Set('goto-link-url', (value) => {
|
||||
return url !== undefined && url.username === '' && url.password === '';
|
||||
});
|
||||
|
||||
FormatRegistry.Set('goto-site-url', (value) => {
|
||||
FormatRegistry.Set('goto-base-url', (value) => {
|
||||
const url = parseHttpUrl(value);
|
||||
return (
|
||||
url !== undefined &&
|
||||
url.username === '' &&
|
||||
url.password === '' &&
|
||||
!value.includes('?') &&
|
||||
!value.includes('#')
|
||||
);
|
||||
});
|
||||
|
||||
FormatRegistry.Set('goto-app-url', (value) => {
|
||||
const url = parseHttpUrl(value);
|
||||
return url !== undefined && url.port !== '0' && value === url.origin;
|
||||
});
|
||||
@@ -44,7 +55,7 @@ const ipv6Pattern = '\\[[0-9a-f:.]+\\]';
|
||||
const hostPattern = `(?:${hostnamePattern}|${ipv6Pattern})`;
|
||||
const httpsPortPattern = `(?::(?!443${end})[1-9][0-9]*)?`;
|
||||
const httpPortPattern = `(?::(?!80${end})[1-9][0-9]*)?`;
|
||||
const siteOriginPattern = `^(?:https://${hostPattern}${httpsPortPattern}|http://${hostPattern}${httpPortPattern})${end}`;
|
||||
const appOriginPattern = `^(?:https://${hostPattern}${httpsPortPattern}|http://${hostPattern}${httpPortPattern})${end}`;
|
||||
const instantPattern = `^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})${end}`;
|
||||
|
||||
FormatRegistry.Set('goto-instant', (value) => {
|
||||
@@ -71,10 +82,18 @@ const LinkUrlSchema = Type.String({
|
||||
description: 'An absolute HTTP or HTTPS destination without embedded credentials.'
|
||||
});
|
||||
|
||||
const SiteUrlSchema = Type.String({
|
||||
$id: 'SiteUrl',
|
||||
format: 'goto-site-url',
|
||||
pattern: siteOriginPattern,
|
||||
const BaseUrlSchema = Type.String({
|
||||
$id: 'BaseUrl',
|
||||
format: 'goto-base-url',
|
||||
pattern: `^[Hh][Tt][Tt][Pp][Ss]?://[^\\s/@?#]+(?:/[^\\s?#]*)?${end}`,
|
||||
description:
|
||||
'An absolute HTTP or HTTPS base URL without embedded credentials, query or fragment. A base path and trailing slash are allowed.'
|
||||
});
|
||||
|
||||
const AppUrlSchema = Type.String({
|
||||
$id: 'AppUrl',
|
||||
format: 'goto-app-url',
|
||||
pattern: appOriginPattern,
|
||||
description:
|
||||
'The canonical admin HTTP(S) origin, without credentials, path, trailing slash, query or fragment.'
|
||||
});
|
||||
@@ -118,9 +137,16 @@ const FallbackSchema = Type.Union([Type.Literal(404), ref(LinkUrlSchema)], {
|
||||
$id: 'Fallback'
|
||||
});
|
||||
|
||||
const UrlSchema = Type.Object(
|
||||
{
|
||||
app: ref(AppUrlSchema, { default: 'http://localhost:3000' }),
|
||||
worker: ref(BaseUrlSchema, { default: 'http://goto-worker:9000' })
|
||||
},
|
||||
{ $id: 'Url', additionalProperties: false }
|
||||
);
|
||||
|
||||
const SiteSchema = Type.Object(
|
||||
{
|
||||
url: ref(SiteUrlSchema, { default: 'http://localhost:3000' }),
|
||||
title: Type.String({ ...nonBlankText, default: 'Goto' }),
|
||||
logo_path: Type.Optional(ref(LocalImagePathSchema)),
|
||||
favicon_path: Type.Optional(ref(LocalImagePathSchema))
|
||||
@@ -255,6 +281,7 @@ const DomainConfigurationSchema = Type.Object(
|
||||
export const GotoConfigurationSchema = Type.Object(
|
||||
{
|
||||
version: Type.Literal(1, { default: 1 }),
|
||||
url: ref(UrlSchema, { default: {} }),
|
||||
site: ref(SiteSchema, { default: {} }),
|
||||
appearance: ref(AppearanceSchema, { default: {} }),
|
||||
auth: ref(AuthSchema),
|
||||
@@ -271,7 +298,8 @@ export const GotoConfigurationSchema = Type.Object(
|
||||
export const configurationReferences: TSchema[] = [
|
||||
HttpUrlSchema,
|
||||
LinkUrlSchema,
|
||||
SiteUrlSchema,
|
||||
BaseUrlSchema,
|
||||
AppUrlSchema,
|
||||
InstantSchema,
|
||||
AuthSecretSchema,
|
||||
LocalImagePathSchema,
|
||||
@@ -279,6 +307,7 @@ export const configurationReferences: TSchema[] = [
|
||||
TagsSchema,
|
||||
RedirectStatusSchema,
|
||||
FallbackSchema,
|
||||
UrlSchema,
|
||||
SiteSchema,
|
||||
AppearanceSchema,
|
||||
OidcAuthSchema,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { generateVisits, seedWindowStart } from './seed-visits-data.js';
|
||||
const script = fileURLToPath(new URL('./seed-visits.js', import.meta.url));
|
||||
const loader = fileURLToPath(new URL('../tools/register.mjs', import.meta.url));
|
||||
const fixture = {
|
||||
site: { url: 'https://admin.example.test' },
|
||||
url: { app: 'https://admin.example.test' },
|
||||
auth: { credentials: { username: 'test', password: 'seed-test-only-password' } },
|
||||
domains: {
|
||||
'one.example.test': { links: { '/docs': { url: 'https://example.org/docs' } } },
|
||||
|
||||
Reference in new issue
Block a user