From 914cef794a0c61cd95a52a343416cf92b7ce8c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Dohnal?= Date: Wed, 15 Apr 2026 18:07:13 +0200 Subject: [PATCH 1/2] feat: add server listen address configuration --- docker/docker-compose.community.yml | 16 +++++++ packages/services/broker-worker/.env.template | 4 ++ packages/services/broker-worker/package.json | 1 + packages/services/broker-worker/src/dev.ts | 16 ++++++- packages/services/cdn-worker/.env.template | 5 +- packages/services/cdn-worker/package.json | 1 + packages/services/cdn-worker/src/dev.ts | 16 ++++++- packages/services/commerce/.env.template | 2 + packages/services/commerce/src/environment.ts | 8 +++- packages/services/commerce/src/index.ts | 9 +++- .../federation-2/.env.template | 5 +- .../federation-2/package.json | 1 + .../federation-2/src/environment.spec.ts | 43 +++++++++++++++++ .../federation-2/src/environment.ts | 7 +++ .../federation-2/src/index.ts | 17 +++++-- packages/services/policy/.env.template | 2 + packages/services/policy/src/environment.ts | 8 +++- packages/services/policy/src/index.ts | 9 +++- packages/services/schema/.env.template | 2 + packages/services/schema/src/environment.ts | 8 +++- packages/services/schema/src/index.ts | 9 +++- packages/services/server/.env.template | 2 + packages/services/server/src/environment.ts | 8 +++- packages/services/server/src/index.ts | 9 +++- packages/services/service-common/package.json | 3 +- packages/services/service-common/src/index.ts | 1 + .../service-common/src/listen-options.spec.ts | 46 +++++++++++++++++++ .../service-common/src/listen-options.ts | 17 +++++++ .../services/service-common/src/metrics.ts | 13 +++++- packages/services/tokens/.env.template | 4 +- packages/services/tokens/src/environment.ts | 8 +++- packages/services/tokens/src/index.ts | 9 +++- .../services/usage-ingestor/.env.template | 4 +- .../usage-ingestor/src/environment.ts | 7 +++ packages/services/usage-ingestor/src/index.ts | 9 +++- packages/services/usage/.env.template | 2 + packages/services/usage/src/environment.ts | 8 +++- packages/services/usage/src/index.ts | 9 +++- packages/services/workflows/.env.template | 4 +- .../services/workflows/src/environment.ts | 8 +++- packages/services/workflows/src/index.ts | 9 +++- packages/web/app/.env.template | 2 + packages/web/app/package.json | 1 + packages/web/app/src/env/backend.ts | 7 +++ .../web/app/src/env/listen-options.spec.ts | 46 +++++++++++++++++++ packages/web/app/src/server/index.ts | 6 ++- 46 files changed, 394 insertions(+), 37 deletions(-) create mode 100644 packages/services/broker-worker/.env.template create mode 100644 packages/services/external-composition/federation-2/src/environment.spec.ts create mode 100644 packages/services/service-common/src/listen-options.spec.ts create mode 100644 packages/services/service-common/src/listen-options.ts create mode 100644 packages/web/app/src/env/listen-options.spec.ts diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index 7472d4ce337..f1c7677c28c 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -203,6 +203,8 @@ services: ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}' WEB_APP_URL: '${HIVE_APP_BASE_URL}' PORT: 3001 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' S3_ENDPOINT: 'http://s3:9000' S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER} S3_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD} @@ -234,6 +236,8 @@ services: environment: NODE_ENV: production PORT: 3012 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' LOG_LEVEL: '${LOG_LEVEL:-debug}' OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}' SENTRY: '${SENTRY:-0}' @@ -250,6 +254,8 @@ services: environment: NODE_ENV: production PORT: 3002 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: '${REDIS_PASSWORD}' @@ -278,6 +284,8 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: '${REDIS_PASSWORD}' PORT: 3003 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' LOG_LEVEL: '${LOG_LEVEL:-debug}' OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}' SENTRY: '${SENTRY:-0}' @@ -294,6 +302,8 @@ services: environment: NODE_ENV: production PORT: 3014 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' POSTGRES_HOST: db POSTGRES_PORT: 5432 POSTGRES_DB: '${POSTGRES_DB}' @@ -340,6 +350,8 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: '${REDIS_PASSWORD}' PORT: 3006 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' LOG_LEVEL: '${LOG_LEVEL:-debug}' SENTRY: '${SENTRY:-0}' SENTRY_DSN: '${SENTRY_DSN:-}' @@ -367,6 +379,8 @@ services: CLICKHOUSE_USERNAME: '${CLICKHOUSE_USER}' CLICKHOUSE_PASSWORD: '${CLICKHOUSE_PASSWORD}' PORT: 3007 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' LOG_LEVEL: '${LOG_LEVEL:-debug}' SENTRY: '${SENTRY:-0}' SENTRY_DSN: '${SENTRY_DSN:-}' @@ -380,6 +394,8 @@ services: - 'stack' environment: PORT: 3000 + SERVER_HOST: '${SERVER_HOST:-::}' + SERVER_HOST_IPV6_ONLY: '${SERVER_HOST_IPV6_ONLY:-0}' NODE_ENV: production APP_BASE_URL: '${HIVE_APP_BASE_URL}' GRAPHQL_PUBLIC_ENDPOINT: http://localhost:8082/graphql diff --git a/packages/services/broker-worker/.env.template b/packages/services/broker-worker/.env.template new file mode 100644 index 00000000000..564cb94aa1e --- /dev/null +++ b/packages/services/broker-worker/.env.template @@ -0,0 +1,4 @@ +CF_BROKER_SIGNATURE=dev-secret +PORT=4010 +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 diff --git a/packages/services/broker-worker/package.json b/packages/services/broker-worker/package.json index 23a1b00a61f..324f35a0f97 100644 --- a/packages/services/broker-worker/package.json +++ b/packages/services/broker-worker/package.json @@ -9,6 +9,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "4.20250913.0", + "@hive/service-common": "workspace:*", "@types/service-worker-mock": "2.0.4", "@whatwg-node/server": "0.10.17", "esbuild": "0.25.9", diff --git a/packages/services/broker-worker/src/dev.ts b/packages/services/broker-worker/src/dev.ts index e47c4ecd0f9..e8478947b85 100644 --- a/packages/services/broker-worker/src/dev.ts +++ b/packages/services/broker-worker/src/dev.ts @@ -1,5 +1,6 @@ import { createServer } from 'http'; import { Router } from 'itty-router'; +import { resolveServerListenOptions } from '@hive/service-common/listen-options'; import { createServerAdapter } from '@whatwg-node/server'; import { createSignatureValidator } from './auth'; import { env } from './dev-polyfill'; @@ -7,6 +8,12 @@ import { handleRequest } from './handler'; // eslint-disable-next-line no-process-env const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010; +const listenOptions = resolveServerListenOptions({ + // eslint-disable-next-line no-process-env + serverHost: process.env.SERVER_HOST, + // eslint-disable-next-line no-process-env + serverHostIpv6Only: process.env.SERVER_HOST_IPV6_ONLY === '1' ? '1' : '0', +}); const isSignatureValid = createSignatureValidator(env.SIGNATURE); function main() { @@ -32,7 +39,14 @@ function main() { const server = createServer(app); return new Promise(resolve => { - server.listen(PORT, '::', resolve); + server.listen( + { + port: PORT, + host: listenOptions.host, + ipv6Only: listenOptions.ipv6Only, + }, + resolve, + ); }); } diff --git a/packages/services/cdn-worker/.env.template b/packages/services/cdn-worker/.env.template index af808ad0a18..9ca0db46f87 100644 --- a/packages/services/cdn-worker/.env.template +++ b/packages/services/cdn-worker/.env.template @@ -1,4 +1,7 @@ S3_ENDPOINT=http://localhost:9000 S3_ACCESS_KEY_ID="minioadmin" S3_SECRET_ACCESS_KEY="minioadmin" -S3_BUCKET_NAME="artifacts" \ No newline at end of file +S3_BUCKET_NAME="artifacts" +PORT=4010 +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 diff --git a/packages/services/cdn-worker/package.json b/packages/services/cdn-worker/package.json index b6bb8a09b45..319cf55f68e 100644 --- a/packages/services/cdn-worker/package.json +++ b/packages/services/cdn-worker/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "4.20250913.0", + "@hive/service-common": "workspace:*", "@types/service-worker-mock": "2.0.4", "@whatwg-node/server": "0.10.17", "bcryptjs": "2.4.3", diff --git a/packages/services/cdn-worker/src/dev.ts b/packages/services/cdn-worker/src/dev.ts index 18d86569c80..810c8c67287 100644 --- a/packages/services/cdn-worker/src/dev.ts +++ b/packages/services/cdn-worker/src/dev.ts @@ -1,5 +1,6 @@ import { createServer } from 'http'; import * as itty from 'itty-router'; +import { resolveServerListenOptions } from '@hive/service-common/listen-options'; import { createServerAdapter } from '@whatwg-node/server'; import { createArtifactRequestHandler } from './artifact-handler'; import { ArtifactStorageReader } from './artifact-storage-reader'; @@ -22,6 +23,12 @@ const s3 = { // eslint-disable-next-line no-process-env const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010; +const listenOptions = resolveServerListenOptions({ + // eslint-disable-next-line no-process-env + serverHost: process.env.SERVER_HOST, + // eslint-disable-next-line no-process-env + serverHostIpv6Only: process.env.SERVER_HOST_IPV6_ONLY === '1' ? '1' : '0', +}); const artifactStorageReader = new ArtifactStorageReader(s3, null, null, null); @@ -93,7 +100,14 @@ function main() { const server = createServer(app); return new Promise(resolve => { - server.listen(PORT, '::', resolve); + server.listen( + { + port: PORT, + host: listenOptions.host, + ipv6Only: listenOptions.ipv6Only, + }, + resolve, + ); }); } diff --git a/packages/services/commerce/.env.template b/packages/services/commerce/.env.template index f2adee44d72..7cfdf50b05a 100644 --- a/packages/services/commerce/.env.template +++ b/packages/services/commerce/.env.template @@ -1,4 +1,6 @@ PORT=4013 +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 OPENTELEMETRY_COLLECTOR_ENDPOINT="" CLICKHOUSE_PROTOCOL="http" CLICKHOUSE_HOST="localhost" diff --git a/packages/services/commerce/src/environment.ts b/packages/services/commerce/src/environment.ts index 98f8ee24170..c3226287d0e 100644 --- a/packages/services/commerce/src/environment.ts +++ b/packages/services/commerce/src/environment.ts @@ -1,5 +1,5 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -22,6 +22,8 @@ export const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), }); @@ -141,6 +143,10 @@ export const env = { release: base.RELEASE ?? 'local', http: { port: base.PORT ?? 4012, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, tracing: { enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, diff --git a/packages/services/commerce/src/index.ts b/packages/services/commerce/src/index.ts index e6073f1bd74..0b868f56431 100644 --- a/packages/services/commerce/src/index.ts +++ b/packages/services/commerce/src/index.ts @@ -137,11 +137,16 @@ async function main() { }); if (env.prometheus) { - await startMetrics(env.prometheus.labels.instance, env.prometheus.port); + await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }); } await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); await Promise.all([usageEstimator.start(), rateLimiter.start(), stripeBilling.start()]); } catch (error) { diff --git a/packages/services/external-composition/federation-2/.env.template b/packages/services/external-composition/federation-2/.env.template index e243763e8b4..b7fa55124a8 100644 --- a/packages/services/external-composition/federation-2/.env.template +++ b/packages/services/external-composition/federation-2/.env.template @@ -1 +1,4 @@ -SECRET=secretsecret \ No newline at end of file +SECRET=secretsecret +PORT=3069 +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 diff --git a/packages/services/external-composition/federation-2/package.json b/packages/services/external-composition/federation-2/package.json index 370ae71bf96..9d1428bc55d 100644 --- a/packages/services/external-composition/federation-2/package.json +++ b/packages/services/external-composition/federation-2/package.json @@ -11,6 +11,7 @@ "@apollo/composition": "2.13.2", "@apollo/federation-internals": "2.13.2", "@graphql-hive/external-composition": "workspace:*", + "@hive/service-common": "workspace:*", "@whatwg-node/server": "0.10.17", "dotenv": "16.4.7", "graphql": "16.9.0", diff --git a/packages/services/external-composition/federation-2/src/environment.spec.ts b/packages/services/external-composition/federation-2/src/environment.spec.ts new file mode 100644 index 00000000000..92cb00db388 --- /dev/null +++ b/packages/services/external-composition/federation-2/src/environment.spec.ts @@ -0,0 +1,43 @@ +import { resolveEnv } from './environment'; + +describe('resolveEnv', () => { + // eslint-disable-next-line no-restricted-syntax -- explicit IPv4 literal required for validation coverage + const ipv4Host = '0.0.0.0'; + + test('uses host and ipv6 defaults when not provided', () => { + const env = resolveEnv({ + SECRET: 'secretsecret', + }); + + expect(env.http).toEqual({ + port: 3069, + host: '::', + ipv6Only: false, + }); + }); + + test('honors explicit host and ipv6-only values', () => { + const env = resolveEnv({ + SECRET: 'secretsecret', + PORT: '4000', + SERVER_HOST: '::1', + SERVER_HOST_IPV6_ONLY: '1', + }); + + expect(env.http).toEqual({ + port: 4000, + host: '::1', + ipv6Only: true, + }); + }); + + test('rejects ipv6-only combined with an IPv4 literal host', () => { + expect(() => + resolveEnv({ + SECRET: 'secretsecret', + SERVER_HOST: ipv4Host, + SERVER_HOST_IPV6_ONLY: '1', + }), + ).toThrow(/SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "0\.0\.0\.0"/); + }); +}); diff --git a/packages/services/external-composition/federation-2/src/environment.ts b/packages/services/external-composition/federation-2/src/environment.ts index ffda796dfef..7136460d57c 100644 --- a/packages/services/external-composition/federation-2/src/environment.ts +++ b/packages/services/external-composition/federation-2/src/environment.ts @@ -1,4 +1,5 @@ import zod from 'zod'; +import { resolveServerListenOptions } from '@hive/service-common'; function extractConfig(config: zod.SafeParseReturnType): Output { if (!config.success) { @@ -15,6 +16,8 @@ const BaseSchema = zod.object({ .number() .transform(port => port || 3069) .default(3069), + SERVER_HOST: zod.string().default('::'), + SERVER_HOST_IPV6_ONLY: zod.union([zod.literal('1'), zod.literal('0')]).default('0'), SECRET: zod.string(), }); @@ -44,6 +47,10 @@ export function resolveEnv(env: Record) { release: base.RELEASE ?? 'local', http: { port: base.PORT, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, secret: base.SECRET, }; diff --git a/packages/services/external-composition/federation-2/src/index.ts b/packages/services/external-composition/federation-2/src/index.ts index 50294fbfbdb..9dc1701200c 100644 --- a/packages/services/external-composition/federation-2/src/index.ts +++ b/packages/services/external-composition/federation-2/src/index.ts @@ -7,9 +7,20 @@ import { createRequestListener } from './server'; const env = resolveEnv(process.env); const server = createServer(createRequestListener(env)); -server.listen(env.http.port, '::', () => { - console.log(`Listening on http://localhost:${env.http.port}`); -}); +function formatListenAddress(host: string, port: number) { + return host.includes(':') ? `[${host}]:${port}` : `${host}:${port}`; +} + +server.listen( + { + port: env.http.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }, + () => { + console.log(`Listening on ${formatListenAddress(env.http.host, env.http.port)}`); + }, +); process.on('SIGINT', () => { server.close(err => { diff --git a/packages/services/policy/.env.template b/packages/services/policy/.env.template index b8998d784ca..1fd3c3c340f 100644 --- a/packages/services/policy/.env.template +++ b/packages/services/policy/.env.template @@ -1,3 +1,5 @@ ENVIRONMENT=development LOG_LEVEL=debug +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 OPENTELEMETRY_COLLECTOR_ENDPOINT="" diff --git a/packages/services/policy/src/environment.ts b/packages/services/policy/src/environment.ts index 82d82143f5e..efaa1eb91f1 100644 --- a/packages/services/policy/src/environment.ts +++ b/packages/services/policy/src/environment.ts @@ -1,5 +1,5 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -20,6 +20,8 @@ const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), }); @@ -107,6 +109,10 @@ export const env = { }, http: { port: base.PORT ?? 6600, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, log: { diff --git a/packages/services/policy/src/index.ts b/packages/services/policy/src/index.ts index 2648d391a1e..265352ccfdc 100644 --- a/packages/services/policy/src/index.ts +++ b/packages/services/policy/src/index.ts @@ -83,11 +83,16 @@ async function main() { await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); if (env.prometheus) { - await startMetrics(env.prometheus.labels.instance, env.prometheus.port); + await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }); } } catch (error) { server.log.fatal(error); diff --git a/packages/services/schema/.env.template b/packages/services/schema/.env.template index 22cadc68254..76c5907b1f9 100644 --- a/packages/services/schema/.env.template +++ b/packages/services/schema/.env.template @@ -1,6 +1,8 @@ REDIS_HOST="localhost" REDIS_PORT="6379" REDIS_PASSWORD="" +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 ENCRYPTION_SECRET="97e4094d2463e71a981913cca4e56788" SCHEMA_CACHE_TTL_MS=5000 SCHEMA_CACHE_SUCCESS_TTL_MS=5000 diff --git a/packages/services/schema/src/environment.ts b/packages/services/schema/src/environment.ts index dd55a9109be..e723577a703 100644 --- a/packages/services/schema/src/environment.ts +++ b/packages/services/schema/src/environment.ts @@ -1,5 +1,5 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -21,6 +21,8 @@ const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString().optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), BODY_LIMIT: NumberFromString().optional().default(/* 15mb in bytes */ 15e6), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), @@ -144,6 +146,10 @@ export const env = { encryptionSecret: base.ENCRYPTION_SECRET, http: { port: base.PORT ?? 6500, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), bodyLimit: base.BODY_LIMIT, }, tracing: { diff --git a/packages/services/schema/src/index.ts b/packages/services/schema/src/index.ts index e9432b21e5a..2e1447177ca 100644 --- a/packages/services/schema/src/index.ts +++ b/packages/services/schema/src/index.ts @@ -146,11 +146,16 @@ async function main() { await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); if (env.prometheus) { - await startMetrics(env.prometheus.labels.instance, env.prometheus.port); + await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }); } } catch (error) { server.log.fatal(error); diff --git a/packages/services/server/.env.template b/packages/services/server/.env.template index 217263539d4..b0b2e73a3d2 100644 --- a/packages/services/server/.env.template +++ b/packages/services/server/.env.template @@ -1,5 +1,7 @@ ENVIRONMENT=development LOG_LEVEL=debug +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_HOST=localhost diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 34db280e825..81ad5b34e1e 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -1,5 +1,5 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -20,6 +20,8 @@ const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), ENCRYPTION_SECRET: emptyString(zod.string()), @@ -417,6 +419,10 @@ export const env = { }, http: { port: base.PORT ?? 3001, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, postgres: { host: postgres.POSTGRES_HOST, diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index c1d430915a3..24b2f82aa96 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -610,12 +610,17 @@ export async function main() { } if (env.prometheus) { - await startMetrics(env.prometheus.labels.instance, env.prometheus.port); + await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }); } await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); } catch (error) { server.log.fatal(error); diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index 734decab356..ee9d027b726 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -4,7 +4,8 @@ "license": "MIT", "private": true, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./listen-options": "./src/listen-options.ts" }, "peerDependencies": { "@sentry/node": "^7.0.0", diff --git a/packages/services/service-common/src/index.ts b/packages/services/service-common/src/index.ts index 30062d1cff7..b9f1753e9e5 100644 --- a/packages/services/service-common/src/index.ts +++ b/packages/services/service-common/src/index.ts @@ -5,6 +5,7 @@ export * from './metrics'; export * from './heartbeats'; export * from './trpc'; export * from './tracing'; +export { resolveServerListenOptions } from './listen-options'; export { registerShutdown } from './graceful-shutdown'; export { cleanRequestId, maskToken } from './helpers'; export { sentryInit } from './sentry'; diff --git a/packages/services/service-common/src/listen-options.spec.ts b/packages/services/service-common/src/listen-options.spec.ts new file mode 100644 index 00000000000..9fbc2408d1a --- /dev/null +++ b/packages/services/service-common/src/listen-options.spec.ts @@ -0,0 +1,46 @@ +import { resolveServerListenOptions } from './listen-options'; + +describe('resolveServerListenOptions', () => { + // eslint-disable-next-line no-restricted-syntax -- explicit IPv4 literal required for validation coverage + const ipv4Host = '0.0.0.0'; + + test('defaults to dual-stack host', () => { + expect(resolveServerListenOptions({})).toEqual({ + host: '::', + ipv6Only: false, + }); + }); + + test('supports ipv4-only host', () => { + expect( + resolveServerListenOptions({ + serverHost: ipv4Host, + serverHostIpv6Only: '0', + }), + ).toEqual({ + host: ipv4Host, + ipv6Only: false, + }); + }); + + test('supports ipv6-only wildcard host', () => { + expect( + resolveServerListenOptions({ + serverHost: '::', + serverHostIpv6Only: '1', + }), + ).toEqual({ + host: '::', + ipv6Only: true, + }); + }); + + test('rejects ipv6-only combined with an IPv4 literal host', () => { + expect(() => + resolveServerListenOptions({ + serverHost: ipv4Host, + serverHostIpv6Only: '1', + }), + ).toThrow(/SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "0\.0\.0\.0"/); + }); +}); diff --git a/packages/services/service-common/src/listen-options.ts b/packages/services/service-common/src/listen-options.ts new file mode 100644 index 00000000000..6cbcf35f836 --- /dev/null +++ b/packages/services/service-common/src/listen-options.ts @@ -0,0 +1,17 @@ +const IPV4_LITERAL = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +export function resolveServerListenOptions(input: { + serverHost?: string; + serverHostIpv6Only?: '0' | '1'; +}) { + const host = input.serverHost ?? '::'; + const ipv6Only = input.serverHostIpv6Only === '1'; + + if (ipv6Only && IPV4_LITERAL.test(host)) { + throw new Error( + `Invalid listen options: SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "${host}".`, + ); + } + + return { host, ipv6Only } as const; +} diff --git a/packages/services/service-common/src/metrics.ts b/packages/services/service-common/src/metrics.ts index 745a181c030..35ac979a6a0 100644 --- a/packages/services/service-common/src/metrics.ts +++ b/packages/services/service-common/src/metrics.ts @@ -13,10 +13,18 @@ export function reportReadiness(isReady: boolean) { readiness.set(isReady ? 1 : 0); } +type MetricsListenOptions = { + port?: number; + host?: string; + ipv6Only?: boolean; +}; + export async function startMetrics( instanceLabel: string | undefined, - port = 10_254, + options: MetricsListenOptions = {}, ): Promise<() => Promise> { + const { port = 10_254, host = '::', ipv6Only = false } = options; + promClient.collectDefaultMetrics({ labels: { instance: instanceLabel }, }); @@ -45,7 +53,8 @@ export async function startMetrics( await server.listen({ port, - host: '::', + host, + ipv6Only, }); return () => server.close(); diff --git a/packages/services/tokens/.env.template b/packages/services/tokens/.env.template index 0a900b60fce..2336b826f44 100644 --- a/packages/services/tokens/.env.template +++ b/packages/services/tokens/.env.template @@ -7,6 +7,8 @@ REDIS_HOST="localhost" REDIS_PORT="6379" REDIS_PASSWORD="" PORT=6001 +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 OPENTELEMETRY_COLLECTOR_ENDPOINT="" LOG_LEVEL="debug" -OPENTELEMETRY_TRACE_USAGE_REQUESTS=1 \ No newline at end of file +OPENTELEMETRY_TRACE_USAGE_REQUESTS=1 diff --git a/packages/services/tokens/src/environment.ts b/packages/services/tokens/src/environment.ts index 53d61b479a7..147ce2a1eee 100644 --- a/packages/services/tokens/src/environment.ts +++ b/packages/services/tokens/src/environment.ts @@ -1,5 +1,5 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -20,6 +20,8 @@ const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), @@ -131,6 +133,10 @@ export const env = { release: base.RELEASE ?? 'local', http: { port: base.PORT ?? 6001, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, tracing: { enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, diff --git a/packages/services/tokens/src/index.ts b/packages/services/tokens/src/index.ts index d052127717b..53234b875bb 100644 --- a/packages/services/tokens/src/index.ts +++ b/packages/services/tokens/src/index.ts @@ -169,12 +169,17 @@ export async function main() { }); if (env.prometheus) { - await startMetrics(env.prometheus.labels.instance, env.prometheus.port); + await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }); } await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); } catch (error) { server.log.fatal(error); diff --git a/packages/services/usage-ingestor/.env.template b/packages/services/usage-ingestor/.env.template index 8b659292e36..1586ebe6de3 100644 --- a/packages/services/usage-ingestor/.env.template +++ b/packages/services/usage-ingestor/.env.template @@ -10,4 +10,6 @@ CLICKHOUSE_USERNAME="test" CLICKHOUSE_PASSWORD="test" CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS="500" CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE="1000" -PORT=4002 \ No newline at end of file +PORT=4002 +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 diff --git a/packages/services/usage-ingestor/src/environment.ts b/packages/services/usage-ingestor/src/environment.ts index 633c1349763..16c131a68fa 100644 --- a/packages/services/usage-ingestor/src/environment.ts +++ b/packages/services/usage-ingestor/src/environment.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import zod from 'zod'; +import { resolveServerListenOptions } from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -20,6 +21,8 @@ const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), @@ -162,6 +165,10 @@ export const env = { release: base.RELEASE ?? 'local', http: { port: base.PORT ?? 5000, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, kafka: { concurrency: kafka.KAFKA_CONCURRENCY, diff --git a/packages/services/usage-ingestor/src/index.ts b/packages/services/usage-ingestor/src/index.ts index 1289ac215b0..1b16f0e3bfc 100644 --- a/packages/services/usage-ingestor/src/index.ts +++ b/packages/services/usage-ingestor/src/index.ts @@ -80,11 +80,16 @@ async function main() { }); if (env.prometheus) { - await startMetrics(env.prometheus.labels.instance, env.prometheus.port); + await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }); } await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); await start(); } catch (error) { diff --git a/packages/services/usage/.env.template b/packages/services/usage/.env.template index 5fe4f4398be..69502fba217 100644 --- a/packages/services/usage/.env.template +++ b/packages/services/usage/.env.template @@ -17,5 +17,7 @@ REDIS_PORT="6379" REDIS_PASSWORD="" PORT=4001 +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 COMMERCE_ENDPOINT="http://localhost:4013" OPENTELEMETRY_COLLECTOR_ENDPOINT="" diff --git a/packages/services/usage/src/environment.ts b/packages/services/usage/src/environment.ts index b71514b708e..4bb23b07053 100644 --- a/packages/services/usage/src/environment.ts +++ b/packages/services/usage/src/environment.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import zod from 'zod'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -21,6 +21,8 @@ const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), TOKENS_ENDPOINT: zod.string().url(), COMMERCE_ENDPOINT: emptyString(zod.string().url().optional()), RATE_LIMIT_TTL: emptyString(NumberFromString.optional()).default(30_000), @@ -152,6 +154,10 @@ export const env = { release: base.RELEASE ?? 'local', http: { port: base.PORT ?? 5000, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, tracing: { enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, diff --git a/packages/services/usage/src/index.ts b/packages/services/usage/src/index.ts index 713ea428d85..6dc5d21f865 100644 --- a/packages/services/usage/src/index.ts +++ b/packages/services/usage/src/index.ts @@ -193,12 +193,17 @@ async function main() { }); if (env.prometheus) { - await startMetrics(env.prometheus.labels.instance, env.prometheus.port); + await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }); } await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); await usage.start(); } catch (error) { diff --git a/packages/services/workflows/.env.template b/packages/services/workflows/.env.template index 4ab7cd696d0..c0dce899c77 100644 --- a/packages/services/workflows/.env.template +++ b/packages/services/workflows/.env.template @@ -8,4 +8,6 @@ EMAIL_PROVIDER=mock SCHEMA_ENDPOINT=http://localhost:6500 REDIS_HOST=localhost REDIS_PORT=6379 -REDIS_PASSWORD= \ No newline at end of file +REDIS_PASSWORD= +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 32ee8ee8be9..ee7aa174d7f 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -1,6 +1,6 @@ import zod from 'zod'; import { PostgresConnectionParamaters } from '@hive/postgres'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel, resolveServerListenOptions } from '@hive/service-common'; import { RequestBroker } from './lib/webhooks/send-webhook.js'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -22,6 +22,8 @@ const emptyString = (input: T) => { const EnvironmentModel = zod.object({ PORT: emptyString(NumberFromString.optional()).default(3014), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), @@ -205,6 +207,10 @@ export const env = { release: base.RELEASE ?? 'local', http: { port: base.PORT ?? 6260, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), }, tracing: { enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index d14be17d574..1e22f26b3c6 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -134,11 +134,16 @@ if (context.email.id === 'mock') { await server.listen({ port: env.http.port, - host: '::', + host: env.http.host, + ipv6Only: env.http.ipv6Only, }); const shutdownMetrics = env.prometheus - ? await startMetrics(env.prometheus.labels.instance, env.prometheus.port) + ? await startMetrics(env.prometheus.labels.instance, { + port: env.prometheus.port, + host: env.http.host, + ipv6Only: env.http.ipv6Only, + }) : null; const runner = await run({ diff --git a/packages/web/app/.env.template b/packages/web/app/.env.template index 7e56e568f4a..e2ebcd8748b 100644 --- a/packages/web/app/.env.template +++ b/packages/web/app/.env.template @@ -1,5 +1,7 @@ APP_BASE_URL="http://localhost:3000" ENVIRONMENT="development" +SERVER_HOST=:: +SERVER_HOST_IPV6_ONLY=0 # Public GraphQL endpoint GRAPHQL_PUBLIC_ENDPOINT="http://localhost:3001/graphql" diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 7090cd09efb..bd479d80e0d 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -30,6 +30,7 @@ "@graphql-tools/mock": "9.0.25", "@graphql-typed-document-node/core": "3.2.0", "@headlessui/react": "2.2.0", + "@hive/service-common": "workspace:*", "@hookform/resolvers": "3.10.0", "@ladle/react": "5.1.1", "@monaco-editor/react": "4.8.0-rc.2", diff --git a/packages/web/app/src/env/backend.ts b/packages/web/app/src/env/backend.ts index 41d9e75b990..1c2dcdf93ca 100644 --- a/packages/web/app/src/env/backend.ts +++ b/packages/web/app/src/env/backend.ts @@ -1,4 +1,5 @@ import zod from 'zod'; +import { resolveServerListenOptions } from '@hive/service-common/listen-options'; import * as Sentry from '@sentry/node'; import { ALLOWED_ENVIRONMENT_VARIABLES } from './frontend-public-variables'; @@ -41,6 +42,8 @@ const BaseSchema = zod.object({ NODE_ENV: zod.string().default('development'), ENVIRONMENT: zod.string(), PORT: emptyString(NumberFromString().optional()), + SERVER_HOST: emptyString(zod.string().optional()), + SERVER_HOST_IPV6_ONLY: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), APP_BASE_URL: zod.string().url(), GRAPHQL_PUBLIC_ENDPOINT: zod.string().url(), GRAPHQL_PUBLIC_SUBSCRIPTION_ENDPOINT: zod.string().url(), @@ -172,6 +175,10 @@ function buildConfig() { const config = { port: base.PORT ?? 3000, + ...resolveServerListenOptions({ + serverHost: base.SERVER_HOST, + serverHostIpv6Only: base.SERVER_HOST_IPV6_ONLY, + }), release: base.RELEASE ?? 'local', nodeEnv: base.NODE_ENV, environment: base.ENVIRONMENT, diff --git a/packages/web/app/src/env/listen-options.spec.ts b/packages/web/app/src/env/listen-options.spec.ts new file mode 100644 index 00000000000..44e57c0558e --- /dev/null +++ b/packages/web/app/src/env/listen-options.spec.ts @@ -0,0 +1,46 @@ +import { resolveServerListenOptions } from '@hive/service-common/listen-options'; + +describe('resolveServerListenOptions', () => { + // eslint-disable-next-line no-restricted-syntax -- explicit IPv4 literal required for validation coverage + const ipv4Host = '0.0.0.0'; + + test('defaults to dual-stack host and ipv4 fallback enabled', () => { + expect(resolveServerListenOptions({})).toEqual({ + host: '::', + ipv6Only: false, + }); + }); + + test('supports ipv6-only wildcard mode', () => { + expect( + resolveServerListenOptions({ + serverHost: '::', + serverHostIpv6Only: '1', + }), + ).toEqual({ + host: '::', + ipv6Only: true, + }); + }); + + test('supports ipv4-only host values', () => { + expect( + resolveServerListenOptions({ + serverHost: ipv4Host, + serverHostIpv6Only: '0', + }), + ).toEqual({ + host: ipv4Host, + ipv6Only: false, + }); + }); + + test('rejects ipv6-only combined with an IPv4 literal host', () => { + expect(() => + resolveServerListenOptions({ + serverHost: ipv4Host, + serverHostIpv6Only: '1', + }), + ).toThrow(/SERVER_HOST_IPV6_ONLY=1 is incompatible with IPv4 host "0\.0\.0\.0"/); + }); +}); diff --git a/packages/web/app/src/server/index.ts b/packages/web/app/src/server/index.ts index efc7498f498..962b40a54f7 100644 --- a/packages/web/app/src/server/index.ts +++ b/packages/web/app/src/server/index.ts @@ -126,7 +126,11 @@ async function main() { }); }); - await server.listen({ port: env.port, host: '::' }); + await server.listen({ + port: env.port, + host: env.host, + ipv6Only: env.ipv6Only, + }); } main().catch(err => { From 19327d6fca2931da8e2c1ddc2c8e5d91bfe46954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Dohnal?= Date: Thu, 16 Apr 2026 13:50:58 +0200 Subject: [PATCH 2/2] update pnpm lockfile --- pnpm-lock.yaml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee292725c34..07398f0d6df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1284,6 +1284,9 @@ importers: '@cloudflare/workers-types': specifier: 4.20250913.0 version: 4.20250913.0 + '@hive/service-common': + specifier: workspace:* + version: link:../service-common '@types/service-worker-mock': specifier: 2.0.4 version: 2.0.4 @@ -1317,6 +1320,9 @@ importers: '@cloudflare/workers-types': specifier: 4.20250913.0 version: 4.20250913.0 + '@hive/service-common': + specifier: workspace:* + version: link:../service-common '@types/service-worker-mock': specifier: 2.0.4 version: 2.0.4 @@ -1432,6 +1438,9 @@ importers: '@graphql-hive/external-composition': specifier: workspace:* version: link:../../../libraries/external-composition/dist + '@hive/service-common': + specifier: workspace:* + version: link:../../service-common '@whatwg-node/server': specifier: 0.10.17 version: 0.10.17 @@ -2126,6 +2135,9 @@ importers: '@headlessui/react': specifier: 2.2.0 version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@hive/service-common': + specifier: workspace:* + version: link:../../services/service-common '@hookform/resolvers': specifier: 3.10.0 version: 3.10.0(react-hook-form@7.54.2(react@18.3.1)) @@ -30994,7 +31006,7 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-config-prettier: 9.1.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsonc: 2.19.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) @@ -34037,7 +34049,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@8.1.1) @@ -34076,14 +34088,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) transitivePeerDependencies: - supports-color @@ -34126,7 +34138,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3