From 669f7167912b2f1f3e8c9f3874f3f644dbf71bf4 Mon Sep 17 00:00:00 2001 From: NathanFlurry Date: Fri, 2 Jan 2026 10:04:27 +0000 Subject: [PATCH] feat(rivetkit): allow including namespace & token in endpoint (#3732) --- .../packages/rivetkit/src/client/config.ts | 84 ++++++-- .../rivetkit/src/drivers/engine/config.ts | 50 +++-- .../rivetkit/src/drivers/engine/mod.ts | 2 +- .../rivetkit/src/registry/config/index.ts | 63 +++--- .../src/registry/config/legacy-runner.ts | 18 +- .../src/utils/endpoint-parser.test.ts | 189 ++++++++++++++++++ .../rivetkit/src/utils/endpoint-parser.ts | 119 +++++++++++ 7 files changed, 467 insertions(+), 58 deletions(-) create mode 100644 rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.test.ts create mode 100644 rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.ts diff --git a/rivetkit-typescript/packages/rivetkit/src/client/config.ts b/rivetkit-typescript/packages/rivetkit/src/client/config.ts index 68737a536b..54e6438d49 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/config.ts @@ -9,22 +9,24 @@ import { getRivetRunner, } from "@/utils/env-vars"; import { RegistryConfig } from "@/registry/config"; +import { + EndpointSchema, + type ParsedEndpoint, + zodCheckDuplicateCredentials, +} from "@/utils/endpoint-parser"; -export const ClientConfigSchema = z.object({ +/** + * Base client config schema without transforms so it can be merged in to other schemas. + */ +export const ClientConfigSchemaBase = z.object({ /** Endpoint to connect to for Rivet Engine or RivetKit manager API. */ - endpoint: z - .string() - .optional() - .transform((x) => x ?? getRivetEngine() ?? getRivetEndpoint()), + endpoint: EndpointSchema.optional(), /** Token to use to authenticate with the API. */ - token: z - .string() - .optional() - .transform((x) => x ?? getRivetToken()), + token: z.string().optional(), /** Namespace to connect to. */ - namespace: z.string().default(() => getRivetNamespace() ?? "default"), + namespace: z.string().optional(), /** Name of the runner. This is used to group together runners in to different pools. */ runnerName: z.string().default(() => getRivetRunner() ?? "default"), @@ -46,26 +48,84 @@ export const ClientConfigSchema = z.object({ disableMetadataLookup: z.boolean().optional().default(false), }); +export const ClientConfigSchema = ClientConfigSchemaBase.transform( + (config, ctx) => transformClientConfig(config, ctx), +); + export type ClientConfig = z.infer; export type ClientConfigInput = z.input; +export function resolveEndpoint( + parsedEndpoint: ParsedEndpoint | undefined, +): ParsedEndpoint | undefined { + if (parsedEndpoint) { + return parsedEndpoint; + } + + const envEndpoint = getRivetEngine() ?? getRivetEndpoint(); + if (envEndpoint) { + return EndpointSchema.parse(envEndpoint); + } + + return undefined; +} + +export function validateClientConfig( + resolvedEndpoint: ParsedEndpoint | undefined, + config: z.infer, + ctx: z.RefinementCtx, +) { + if (resolvedEndpoint) { + zodCheckDuplicateCredentials(resolvedEndpoint, config, ctx); + } +} + +export function transformClientConfig( + config: z.infer, + ctx?: z.RefinementCtx, +) { + const resolvedEndpoint = resolveEndpoint(config.endpoint); + + // Validate if context is provided (when called from Zod transform) + if (ctx) { + validateClientConfig(resolvedEndpoint, config, ctx); + } + + return { + ...config, + endpoint: resolvedEndpoint?.endpoint, + namespace: + resolvedEndpoint?.namespace ?? + config.namespace ?? + getRivetNamespace() ?? + "default", + token: resolvedEndpoint?.token ?? config.token ?? getRivetToken(), + }; +} + /** * Converts a base config in to a client config. * * The base config does not include all of the properties of the client config, * so this converts the subset of properties in to the client config. + * + * Note: We construct the object directly rather than using ClientConfigSchema.parse() + * because RegistryConfig has already transformed the endpoint, namespace, and token. + * Re-parsing would attempt to extract namespace/token from the endpoint URL again. */ export function convertRegistryConfigToClientConfig( config: RegistryConfig, ): ClientConfig { - return ClientConfigSchema.parse({ + return { endpoint: config.endpoint, token: config.token, namespace: config.namespace, runnerName: config.runner.runnerName, headers: config.headers, + encoding: "bare", + getUpgradeWebSocket: undefined, // We don't need health checks for internal clients disableMetadataLookup: true, - }); + }; } diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/config.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/config.ts index e427068836..2e9f1ad9e8 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/config.ts @@ -1,24 +1,40 @@ import { z } from "zod"; -import { ClientConfigSchema } from "@/client/config"; +import { + ClientConfigSchemaBase, + transformClientConfig, +} from "@/client/config"; import { getRivetRunnerKey } from "@/utils/env-vars"; -const EngineConfigSchemaBase = z - .object({ - /** Unique key for this runner. Runners connecting a given key will replace any other runner connected with the same key. */ - runnerKey: z - .string() - .optional() - .transform((x) => x ?? getRivetRunnerKey()), +/** + * Base engine config schema without transforms so it can be merged in to other schemas. + * + * We include the client config since this includes the common properties like endpoint, namespace, etc. + */ +export const EngineConfigSchemaBase = ClientConfigSchemaBase.extend({ + /** Unique key for this runner. Runners connecting a given key will replace any other runner connected with the same key. */ + runnerKey: z.string().optional(), - /** How many actors this runner can run. */ - totalSlots: z.number().default(100_000), - }) - // We include the client config since this includes the common properties like endpoint, namespace, etc. - .merge(ClientConfigSchema); + /** How many actors this runner can run. */ + totalSlots: z.number().default(100_000), +}); -export const EngingConfigSchema = EngineConfigSchemaBase.default(() => - EngineConfigSchemaBase.parse({}), +const EngineConfigSchemaTransformed = EngineConfigSchemaBase.transform( + (config, ctx) => transformEngineConfig(config, ctx), ); -export type EngineConfig = z.infer; -export type EngineConfigInput = z.input; +export const EngineConfigSchema = EngineConfigSchemaTransformed.default(() => + EngineConfigSchemaTransformed.parse({}), +); + +export type EngineConfig = z.infer; +export type EngineConfigInput = z.input; + +export function transformEngineConfig( + config: z.infer, + ctx?: z.RefinementCtx, +) { + return { + ...transformClientConfig(config, ctx), + runnerKey: config.runnerKey ?? getRivetRunnerKey(), + }; +} diff --git a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts index c6fcd9ac45..8ca0c8ae14 100644 --- a/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/drivers/engine/mod.ts @@ -9,7 +9,7 @@ export { EngineActorDriver } from "./actor-driver"; export { type EngineConfig as Config, type EngineConfigInput as InputConfig, - EngingConfigSchema as ConfigSchema, + EngineConfigSchema as ConfigSchema, } from "./config"; export function createEngineDriver(): DriverConfig { diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts index 32a1d1849d..89646a5e79 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts @@ -12,6 +12,11 @@ import { DriverConfigSchema, type DriverConfig } from "./driver"; import invariant from "invariant"; import { RunnerConfigSchema } from "./runner"; import { ServerlessConfigSchema } from "./serverless"; +import { + EndpointSchema, + zodCheckDuplicateCredentials, +} from "@/utils/endpoint-parser"; +import { resolveEndpoint } from "@/client/config"; export { DriverConfigSchema, type DriverConfig }; @@ -76,15 +81,9 @@ export const RegistryConfigSchema = z // getUpgradeWebSocket: z.custom().optional(), // MARK: Runner Configuration - endpoint: z - .string() - .optional() - .transform((x) => x ?? getRivetEndpoint()), - token: z - .string() - .optional() - .transform((x) => x ?? getRivetToken()), - namespace: z.string().default(() => getRivetNamespace() ?? "default"), + endpoint: EndpointSchema.optional(), + token: z.string().optional(), + namespace: z.string().optional(), headers: z.record(z.string(), z.string()).optional().default({}), // MARK: Client @@ -123,10 +122,16 @@ export const RegistryConfigSchema = z RunnerConfigSchema.parse({}), ), }) - .superRefine((config, ctx) => { + .transform((config, ctx) => { const isDevEnv = isDev(); + const resolvedEndpoint = resolveEndpoint(config.endpoint); + + // Validate duplicate credentials + if (resolvedEndpoint) { + zodCheckDuplicateCredentials(resolvedEndpoint, config, ctx); + } - if (config.endpoint && config.serveManager) { + if (resolvedEndpoint && config.serveManager) { ctx.addIssue({ code: "custom", message: "cannot specify both endpoint and serveManager", @@ -135,7 +140,7 @@ export const RegistryConfigSchema = z if (config.serverless) { // Can't spawn engine AND connect to remote endpoint - if (config.serverless.spawnEngine && config.endpoint) { + if (config.serverless.spawnEngine && resolvedEndpoint) { ctx.addIssue({ code: "custom", message: "cannot specify both spawnEngine and endpoint", @@ -145,7 +150,7 @@ export const RegistryConfigSchema = z // configureRunnerPool requires an engine (via endpoint or spawnEngine) if ( config.serverless.configureRunnerPool && - !config.endpoint && + !resolvedEndpoint && !config.serverless.spawnEngine ) { ctx.addIssue({ @@ -158,7 +163,7 @@ export const RegistryConfigSchema = z // advertiseEndpoint required in production without endpoint if ( !isDevEnv && - !config.endpoint && + !resolvedEndpoint && !config.serverless.advertiseEndpoint ) { ctx.addIssue({ @@ -169,21 +174,28 @@ export const RegistryConfigSchema = z }); } } - }) - .transform((config) => { - const isDevEnv = isDev(); + + // Flatten the endpoint and apply defaults for namespace/token + const endpoint = resolvedEndpoint?.endpoint; + const namespace = + resolvedEndpoint?.namespace ?? + config.namespace ?? + getRivetNamespace() ?? + "default"; + const token = + resolvedEndpoint?.token ?? config.token ?? getRivetToken(); if (config.serverless) { let serveManager: boolean; let advertiseEndpoint: string; - if (config.endpoint) { + if (endpoint) { // Remote endpoint provided: // - Do not start manager server // - Redirect clients to remote endpoint serveManager = config.serveManager ?? false; advertiseEndpoint = - config.serverless.advertiseEndpoint ?? config.endpoint; + config.serverless.advertiseEndpoint ?? endpoint; } else if (isDevEnv) { // Development mode, no endpoint: // - Start manager server @@ -205,8 +217,7 @@ export const RegistryConfigSchema = z } // If endpoint is set or spawning engine, we'll use engine driver - disable manager inspector - const willUseEngine = - !!config.endpoint || config.serverless.spawnEngine; + const willUseEngine = !!endpoint || config.serverless.spawnEngine; const inspector = willUseEngine ? { ...config.inspector, @@ -216,6 +227,9 @@ export const RegistryConfigSchema = z return { ...config, + endpoint, + namespace, + token, serveManager, advertiseEndpoint, inspector, @@ -226,7 +240,7 @@ export const RegistryConfigSchema = z // - If dev mode without endpoint: start manager server // - If prod mode without endpoint: do not start manager server let serveManager: boolean; - if (config.endpoint) { + if (endpoint) { serveManager = config.serveManager ?? false; } else if (isDevEnv) { serveManager = config.serveManager ?? true; @@ -235,7 +249,7 @@ export const RegistryConfigSchema = z } // If endpoint is set, we'll use engine driver - disable manager inspector - const willUseEngine = !!config.endpoint; + const willUseEngine = !!endpoint; const inspector = willUseEngine ? { ...config.inspector, @@ -245,6 +259,9 @@ export const RegistryConfigSchema = z return { ...config, + endpoint, + namespace, + token, serveManager, inspector, }; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/config/legacy-runner.ts b/rivetkit-typescript/packages/rivetkit/src/registry/config/legacy-runner.ts index 9a2360ddf2..fb361c7af4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/config/legacy-runner.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/config/legacy-runner.ts @@ -2,7 +2,10 @@ import type { Logger } from "pino"; import { z } from "zod"; import type { ActorDriverBuilder } from "@/actor/driver"; import { LogLevelSchema } from "@/common/log"; -import { EngingConfigSchema as EngineConfigSchema } from "@/drivers/engine/config"; +import { + EngineConfigSchemaBase, + transformEngineConfig, +} from "@/drivers/engine/config"; import { InspectorConfigSchema } from "@/inspector/config"; import type { ManagerDriverBuilder } from "@/manager/driver"; import type { GetUpgradeWebSocket } from "@/utils"; @@ -134,11 +137,16 @@ const LegacyRunnerConfigSchemaUnmerged = z // created or must be imported async using `await import(...)` getUpgradeWebSocket: z.custom().optional(), }) - .merge(EngineConfigSchema.removeDefault()); + .merge(EngineConfigSchemaBase); + +const LegacyRunnerConfigSchemaTransformed = + LegacyRunnerConfigSchemaUnmerged.transform((config, ctx) => ({ + ...config, + ...transformEngineConfig(config, ctx), + })); -const LegacyRunnerConfigSchemaBase = LegacyRunnerConfigSchemaUnmerged; -export const LegacyRunnerConfigSchema = LegacyRunnerConfigSchemaBase.default(() => - LegacyRunnerConfigSchemaBase.parse({}), +export const LegacyRunnerConfigSchema = LegacyRunnerConfigSchemaTransformed.default( + () => LegacyRunnerConfigSchemaTransformed.parse({}), ); export type LegacyRunnerConfig = z.infer; diff --git a/rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.test.ts b/rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.test.ts new file mode 100644 index 0000000000..7f196318fa --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "vitest"; +import { zodParseEndpoint, EndpointSchema } from "./endpoint-parser"; + +describe("zodParseEndpoint", () => { + describe("full auth syntax", () => { + test("parses namespace and token from endpoint", () => { + const result = zodParseEndpoint("https://foo:bar@api.rivet.dev"); + expect(result.endpoint).toBe("https://api.rivet.dev/"); + expect(result.namespace).toBe("foo"); + expect(result.token).toBe("bar"); + }); + + test("parses with port", () => { + const result = zodParseEndpoint("https://foo:bar@api.rivet.dev:8080"); + expect(result.endpoint).toBe("https://api.rivet.dev:8080/"); + expect(result.namespace).toBe("foo"); + expect(result.token).toBe("bar"); + }); + + test("parses with path", () => { + const result = zodParseEndpoint("https://foo:bar@api.rivet.dev/v1/actors"); + expect(result.endpoint).toBe("https://api.rivet.dev/v1/actors"); + expect(result.namespace).toBe("foo"); + expect(result.token).toBe("bar"); + }); + + test("throws on query string", () => { + expect(() => + zodParseEndpoint("https://foo:bar@api.rivet.dev?region=us-east"), + ).toThrow("endpoint cannot contain a query string"); + }); + + test("throws on fragment", () => { + expect(() => + zodParseEndpoint("https://foo:bar@api.rivet.dev#section"), + ).toThrow("endpoint cannot contain a fragment"); + }); + + test("handles percent-encoded characters in namespace", () => { + const result = zodParseEndpoint("https://foo%40bar:token@api.rivet.dev"); + expect(result.endpoint).toBe("https://api.rivet.dev/"); + expect(result.namespace).toBe("foo@bar"); + expect(result.token).toBe("token"); + }); + + test("handles percent-encoded characters in token", () => { + const result = zodParseEndpoint("https://foo:bar%3Abaz@api.rivet.dev"); + expect(result.endpoint).toBe("https://api.rivet.dev/"); + expect(result.namespace).toBe("foo"); + expect(result.token).toBe("bar:baz"); + }); + }); + + describe("namespace only (no token)", () => { + test("parses namespace without token", () => { + const result = zodParseEndpoint("https://foo@api.rivet.dev"); + expect(result.endpoint).toBe("https://api.rivet.dev/"); + expect(result.namespace).toBe("foo"); + expect(result.token).toBeUndefined(); + }); + + test("parses namespace without token with path", () => { + const result = zodParseEndpoint("https://foo@api.rivet.dev/v1/actors"); + expect(result.endpoint).toBe("https://api.rivet.dev/v1/actors"); + expect(result.namespace).toBe("foo"); + expect(result.token).toBeUndefined(); + }); + }); + + describe("no auth", () => { + test("parses endpoint without auth", () => { + const result = zodParseEndpoint("https://api.rivet.dev"); + expect(result.endpoint).toBe("https://api.rivet.dev/"); + expect(result.namespace).toBeUndefined(); + expect(result.token).toBeUndefined(); + }); + + test("parses endpoint without auth with path", () => { + const result = zodParseEndpoint("https://api.rivet.dev/v1/actors"); + expect(result.endpoint).toBe("https://api.rivet.dev/v1/actors"); + expect(result.namespace).toBeUndefined(); + expect(result.token).toBeUndefined(); + }); + + test("throws on query string without auth", () => { + expect(() => + zodParseEndpoint("https://api.rivet.dev?region=us-east"), + ).toThrow("endpoint cannot contain a query string"); + }); + }); + + describe("http protocol", () => { + test("parses http endpoint with auth", () => { + const result = zodParseEndpoint("http://foo:bar@localhost:6420"); + expect(result.endpoint).toBe("http://localhost:6420/"); + expect(result.namespace).toBe("foo"); + expect(result.token).toBe("bar"); + }); + + test("parses http endpoint without auth", () => { + const result = zodParseEndpoint("http://localhost:6420"); + expect(result.endpoint).toBe("http://localhost:6420/"); + expect(result.namespace).toBeUndefined(); + expect(result.token).toBeUndefined(); + }); + }); + + describe("error handling", () => { + test("throws on invalid URL", () => { + expect(() => zodParseEndpoint("not-a-url")).toThrow(); + }); + + test("throws on empty string", () => { + expect(() => zodParseEndpoint("")).toThrow(); + }); + + test("throws on token without namespace", () => { + expect(() => zodParseEndpoint("https://:token@api.rivet.dev")).toThrow( + "endpoint cannot have a token without a namespace", + ); + }); + }); +}); + +describe("EndpointSchema", () => { + test("parses endpoint with full auth", () => { + const result = EndpointSchema.parse("https://foo:bar@api.rivet.dev"); + expect(result).toEqual({ + endpoint: "https://api.rivet.dev/", + namespace: "foo", + token: "bar", + }); + }); + + test("parses endpoint with namespace only", () => { + const result = EndpointSchema.parse("https://foo@api.rivet.dev"); + expect(result).toEqual({ + endpoint: "https://api.rivet.dev/", + namespace: "foo", + token: undefined, + }); + }); + + test("parses endpoint without auth", () => { + const result = EndpointSchema.parse("https://api.rivet.dev"); + expect(result).toEqual({ + endpoint: "https://api.rivet.dev/", + namespace: undefined, + token: undefined, + }); + }); + + test("preserves path", () => { + const result = EndpointSchema.parse( + "https://foo:bar@api.rivet.dev/v1/actors", + ); + expect(result).toEqual({ + endpoint: "https://api.rivet.dev/v1/actors", + namespace: "foo", + token: "bar", + }); + }); + + test("throws on query string", () => { + expect(() => + EndpointSchema.parse("https://foo:bar@api.rivet.dev?region=us"), + ).toThrow(); + }); + + test("throws on fragment", () => { + expect(() => + EndpointSchema.parse("https://foo:bar@api.rivet.dev#section"), + ).toThrow(); + }); + + test("throws on invalid URL", () => { + expect(() => EndpointSchema.parse("not-a-url")).toThrow(); + }); + + test("works with optional()", () => { + const schema = EndpointSchema.optional(); + expect(schema.parse(undefined)).toBeUndefined(); + expect(schema.parse("https://foo:bar@api.rivet.dev")).toEqual({ + endpoint: "https://api.rivet.dev/", + namespace: "foo", + token: "bar", + }); + }); +}); diff --git a/rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.ts b/rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.ts new file mode 100644 index 0000000000..f7bc610bf3 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/src/utils/endpoint-parser.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; + +export interface ParsedEndpoint { + endpoint: string; + namespace: string | undefined; + token: string | undefined; +} + +/** + * Parses an endpoint URL that may contain auth syntax for namespace and token. + * + * Supports formats like: + * - `https://namespace:token@api.rivet.dev` + * - `https://namespace@api.rivet.dev` (namespace only, no token) + * - `https://api.rivet.dev` (no auth) + * - `https://namespace:token@api.rivet.dev/path` (with path) + * + * Query strings and fragments are not allowed as they may conflict with + * runtime parameters. + */ +export function zodParseEndpoint(endpoint: string): ParsedEndpoint { + // Parse the URL + const url = new URL(endpoint); + + // Reject query strings + if (url.search) { + throw new z.ZodError([ + { + code: "custom", + message: "endpoint cannot contain a query string", + path: ["endpoint"], + }, + ]); + } + + // Reject fragments + if (url.hash) { + throw new z.ZodError([ + { + code: "custom", + message: "endpoint cannot contain a fragment", + path: ["endpoint"], + }, + ]); + } + + // Extract namespace and token from username and password + // URL stores these as percent-encoded, so we need to decode them + const namespace = url.username ? decodeURIComponent(url.username) : undefined; + const token = url.password ? decodeURIComponent(url.password) : undefined; + + // Reject token without namespace (e.g., https://:token@api.rivet.dev) + if (token && !namespace) { + throw new z.ZodError([ + { + code: "custom", + message: "endpoint cannot have a token without a namespace", + path: ["endpoint"], + }, + ]); + } + + // Strip auth from the URL by clearing username and password + url.username = ""; + url.password = ""; + + // Get the cleaned endpoint without auth + const cleanedEndpoint = url.toString(); + + return { + endpoint: cleanedEndpoint, + namespace, + token, + }; +} + +/** + * Zod schema that parses an endpoint URL string and extracts namespace/token from HTTP auth syntax. + * + * Input: `"https://namespace:token@api.rivet.dev/path"` + * Output: `{ endpoint: "https://api.rivet.dev/path", namespace: "namespace", token: "token" }` + */ +export const EndpointSchema = z.string().transform((endpoint): ParsedEndpoint => { + return zodParseEndpoint(endpoint); +}); + +export type EndpointSchemaInput = z.input; +export type EndpointSchemaOutput = z.output; + +/** + * Zod refinement that validates namespace/token aren't specified both in the endpoint URL + * and as separate config options. + */ +export function zodCheckDuplicateCredentials( + resolvedEndpoint: ParsedEndpoint, + config: { namespace?: string; token?: string }, + ctx: z.RefinementCtx, +): void { + // Check if endpoint contains namespace but namespace is also specified in config + if (resolvedEndpoint.namespace && config.namespace) { + ctx.addIssue({ + code: "custom", + message: + "cannot specify namespace both in endpoint URL and as a separate config option", + path: ["namespace"], + }); + } + + // Check if endpoint contains token but token is also specified in config + if (resolvedEndpoint.token && config.token) { + ctx.addIssue({ + code: "custom", + message: + "cannot specify token both in endpoint URL and as a separate config option", + path: ["token"], + }); + } +} +