diff --git a/.changeset/service-urls.md b/.changeset/service-urls.md new file mode 100644 index 0000000..8124c7e --- /dev/null +++ b/.changeset/service-urls.md @@ -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. diff --git a/apps/cli/src/cli.test.ts b/apps/cli/src/cli.test.ts index 0d78b50..d76c684 100644 --- a/apps/cli/src/cli.test.ts +++ b/apps/cli/src/cli.test.ts @@ -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: diff --git a/apps/web/e2e/dev-server.mjs b/apps/web/e2e/dev-server.mjs index 7ac4402..bed39be 100644 --- a/apps/web/e2e/dev-server.mjs +++ b/apps/web/e2e/dev-server.mjs @@ -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}`]: { diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.mjs index 22e54ea..d819b2b 100644 --- a/apps/web/e2e/server.mjs +++ b/apps/web/e2e/server.mjs @@ -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: { diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 54f0794..e9a11a9 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -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: {} } } }, diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 854275d..246ab83 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -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' } }); } diff --git a/apps/web/src/lib/server/application.test.ts b/apps/web/src/lib/server/application.test.ts index a080514..ace1fed 100644 --- a/apps/web/src/lib/server/application.test.ts +++ b/apps/web/src/lib/server/application.test.ts @@ -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) => { diff --git a/apps/web/src/lib/server/application.ts b/apps/web/src/lib/server/application.ts index 1290d1c..e837829 100644 --- a/apps/web/src/lib/server/application.ts +++ b/apps/web/src/lib/server/application.ts @@ -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 diff --git a/apps/web/src/lib/server/auth/index.test.ts b/apps/web/src/lib/server/auth/index.test.ts index 022a8b9..fa66df1 100644 --- a/apps/web/src/lib/server/auth/index.test.ts +++ b/apps/web/src/lib/server/auth/index.test.ts @@ -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: {} } } }, diff --git a/apps/web/src/lib/server/auth/index.ts b/apps/web/src/lib/server/auth/index.ts index c467b07..c42fac3 100644 --- a/apps/web/src/lib/server/auth/index.ts +++ b/apps/web/src/lib/server/auth/index.ts @@ -15,7 +15,7 @@ export interface GotoSession { } export function createAuthentication(cfg: Immutable) { - const origin = cfg.site.url; + const origin = cfg.url.app; const secret = resolveAuthSecret(cfg.auth.secret); return betterAuth({ 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); diff --git a/apps/web/src/lib/server/auth/providers.ts b/apps/web/src/lib/server/auth/providers.ts index 856cf35..c2f636d 100644 --- a/apps/web/src/lib/server/auth/providers.ts +++ b/apps/web/src/lib/server/auth/providers.ts @@ -12,7 +12,7 @@ type AuthOptions = Pick; export function buildAuthOptions(cfg: Immutable): 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'); } diff --git a/apps/web/src/lib/server/dispatch.test.ts b/apps/web/src/lib/server/dispatch.test.ts index 886b2b4..f694324 100644 --- a/apps/web/src/lib/server/dispatch.test.ts +++ b/apps/web/src/lib/server/dispatch.test.ts @@ -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': { diff --git a/apps/web/src/lib/server/dispatch.ts b/apps/web/src/lib/server/dispatch.ts index e13beea..b829dea 100644 --- a/apps/web/src/lib/server/dispatch.ts +++ b/apps/web/src/lib/server/dispatch.ts @@ -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; } diff --git a/apps/web/src/lib/server/lifecycle.test.ts b/apps/web/src/lib/server/lifecycle.test.ts index d166a07..fcfa283 100644 --- a/apps/web/src/lib/server/lifecycle.test.ts +++ b/apps/web/src/lib/server/lifecycle.test.ts @@ -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: {} }, diff --git a/apps/web/src/lib/server/request-context.test.ts b/apps/web/src/lib/server/request-context.test.ts index c626bd4..b9098f1 100644 --- a/apps/web/src/lib/server/request-context.test.ts +++ b/apps/web/src/lib/server/request-context.test.ts @@ -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: {} }, diff --git a/apps/worker/e2e/startup.js b/apps/worker/e2e/startup.js index f2348c3..8ba38d2 100644 --- a/apps/worker/e2e/startup.js +++ b/apps/worker/e2e/startup.js @@ -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: {} diff --git a/apps/worker/src/services/startup.test.ts b/apps/worker/src/services/startup.test.ts index 31d75a0..32e69b5 100644 --- a/apps/worker/src/services/startup.test.ts +++ b/apps/worker/src/services/startup.test.ts @@ -25,7 +25,7 @@ afterEach(async () => { async function writeConfiguration(worker_path = './state/worker.sqlite'): Promise { 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 }, diff --git a/examples/goto.yaml b/examples/goto.yaml index 68e9bf9..e862509 100644 --- a/examples/goto.yaml +++ b/examples/goto.yaml @@ -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: diff --git a/packages/config/src/config.schema.json b/packages/config/src/config.schema.json index 70f4b3d..aa61ee7 100644 --- a/packages/config/src/config.schema.json +++ b/packages/config/src/config.schema.json @@ -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", diff --git a/packages/config/src/editor-schema.test.ts b/packages/config/src/editor-schema.test.ts index 88c295c..884b7d3 100644 --- a/packages/config/src/editor-schema.test.ts +++ b/packages/config/src/editor-schema.test.ts @@ -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); diff --git a/packages/config/src/editor-schema.ts b/packages/config/src/editor-schema.ts index 5aee240..0742224 100644 --- a/packages/config/src/editor-schema.ts +++ b/packages/config/src/editor-schema.ts @@ -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') { diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index a08c51c..0e2f49b 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -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: [] } } }, diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index b0b2efb..2a5a0c2 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -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, 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, 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, env: ConfigEnvironment): v ) { throw new ConfigError(); } - (site as Record).url = + (url as Record).app = number === 80 ? 'http://localhost' : `http://localhost:${number}`; } diff --git a/packages/config/src/resolve.test.ts b/packages/config/src/resolve.test.ts index 3d2b6ec..8c697d3 100644 --- a/packages/config/src/resolve.test.ts +++ b/packages/config/src/resolve.test.ts @@ -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) diff --git a/packages/config/src/resolve.ts b/packages/config/src/resolve.ts index e18426f..89cb7d1 100644 --- a/packages/config/src/resolve.ts +++ b/packages/config/src/resolve.ts @@ -49,7 +49,7 @@ export function prepareDomains( authoredDefaults: object ): Record { const domains: Record = 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)) { diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index a2ecbb1..6e68bbc 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -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, diff --git a/scripts/seed-visits.test.js b/scripts/seed-visits.test.js index df10822..facbb8c 100644 --- a/scripts/seed-visits.test.js +++ b/scripts/seed-visits.test.js @@ -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' } } },