From 2f47771bb66cf7b8ed9ac1e55c3c53c6860972fc Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Tue, 26 May 2026 08:24:14 +1000 Subject: [PATCH 1/5] Align provider name between libraries --- src/octopusFeatureProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/octopusFeatureProvider.ts b/src/octopusFeatureProvider.ts index 7b96b91..6227142 100644 --- a/src/octopusFeatureProvider.ts +++ b/src/octopusFeatureProvider.ts @@ -26,7 +26,7 @@ export class OctopusFeatureProvider implements Provider { } metadata = { - name: OctopusFeatureProvider.name, + name: "octopus-ts-web-provider", }; readonly runsOn = "client"; From a610a8588f85568042f73cf2e0c62a64aa704c98 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Tue, 26 May 2026 09:42:07 +1000 Subject: [PATCH 2/5] Initial implementation --- .github/workflows/build-test-release.yml | 3 +- .release-please-manifest.json | 3 + release-please-config.json | 8 +++ src/index.ts | 1 + src/octopusFeatureClient.test.ts | 55 ++++++++++++++++++- src/octopusFeatureClient.ts | 13 +++++ src/octopusFeatureProvider.test.ts | 17 +++++- src/octopusFeatureProvider.ts | 4 ++ src/productMetadata.test.ts | 53 ++++++++++++++++++ src/productMetadata.ts | 29 ++++++++++ .../fixtureEvaluationTests.test.ts | 2 + src/version.ts | 1 + 12 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json create mode 100644 src/productMetadata.test.ts create mode 100644 src/productMetadata.ts create mode 100644 src/version.ts diff --git a/.github/workflows/build-test-release.yml b/.github/workflows/build-test-release.yml index d1ebbf8..8934131 100644 --- a/.github/workflows/build-test-release.yml +++ b/.github/workflows/build-test-release.yml @@ -52,7 +52,8 @@ jobs: - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 id: release with: - release-type: node + config-file: release-please-config.json + manifest-file: .release-please-manifest.json token: ${{ secrets.RELEASE_PLEASE_TOKEN }} - uses: actions/checkout@v6 if: ${{ steps.release.outputs.release_created }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..9318fd8 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "3.0.2" +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..8bc4b09 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,8 @@ +{ + "packages": { + ".": { + "release-type": "node", + "extra-files": ["src/version.ts"] + } + } +} diff --git a/src/index.ts b/src/index.ts index 56df78b..4ff9f43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./octopusFeatureProvider"; +export * from "./productMetadata"; diff --git a/src/octopusFeatureClient.test.ts b/src/octopusFeatureClient.test.ts index d2a94dc..77acb20 100644 --- a/src/octopusFeatureClient.test.ts +++ b/src/octopusFeatureClient.test.ts @@ -1,4 +1,6 @@ import { OctopusFeatureClient } from "./octopusFeatureClient"; +import { ProductMetadata } from "./productMetadata"; +import { PROVIDER_VERSION } from "./version"; import axios from "axios"; import axiosRetry from "axios-retry"; import MockAdapter from "axios-mock-adapter"; @@ -21,7 +23,10 @@ describe("OctopusFeatureClient", () => { test("Should invoke the toggles evaluations v3 endpoint", async () => { mockAdapter.onGet().reply(200, {}); - const client = new OctopusFeatureClient({ clientIdentifier: "a.b.c" }); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("TestClient"), + }); await client.getEvaluationContext(); @@ -31,10 +36,56 @@ describe("OctopusFeatureClient", () => { test("Should include releaseVersionOverride in HTTP header if provided in configuration", async () => { const releaseVersionOverride = "1.2.3"; mockAdapter.onGet().reply(200, {}); - const client = new OctopusFeatureClient({ clientIdentifier: "a.b.c", releaseVersionOverride }); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("TestClient"), + releaseVersionOverride, + }); await client.getEvaluationContext(); expect(mockAdapter.history.get[0].headers!["X-Release-Version"]).toEqual(releaseVersionOverride); }); + + test("Should include X-Octopus-Client header with product name only", async () => { + mockAdapter.onGet().reply(200, {}); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("MyProduct"), + }); + + await client.getEvaluationContext(); + + expect(mockAdapter.history.get[0].headers!["X-Octopus-Client"]).toEqual( + `MyProduct openfeature-provider-ts-web/${PROVIDER_VERSION}` + ); + }); + + test("Should include X-Octopus-Client header with product name and version", async () => { + mockAdapter.onGet().reply(200, {}); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("MyProduct", "2024.1.0"), + }); + + await client.getEvaluationContext(); + + expect(mockAdapter.history.get[0].headers!["X-Octopus-Client"]).toEqual( + `MyProduct/2024.1.0 openfeature-provider-ts-web/${PROVIDER_VERSION}` + ); + }); + + test("Should strip unsupported chars from product name in X-Octopus-Client header", async () => { + mockAdapter.onGet().reply(200, {}); + const client = new OctopusFeatureClient({ + clientIdentifier: "a.b.c", + productMetadata: new ProductMetadata("My Product"), + }); + + await client.getEvaluationContext(); + + expect(mockAdapter.history.get[0].headers!["X-Octopus-Client"]).toEqual( + `MyProduct openfeature-provider-ts-web/${PROVIDER_VERSION}` + ); + }); }); diff --git a/src/octopusFeatureClient.ts b/src/octopusFeatureClient.ts index a3c1103..25edc03 100644 --- a/src/octopusFeatureClient.ts +++ b/src/octopusFeatureClient.ts @@ -3,6 +3,8 @@ import axiosRetry from "axios-retry"; import { V2FeatureToggleEvaluation, V2FeatureToggles, OctopusFeatureContext } from "./octopusFeatureContext"; import { OctopusFeatureConfiguration } from "./octopusFeatureProvider"; import { DefaultLogger, Logger } from "@openfeature/web-sdk"; +import { ProductMetadata } from "./productMetadata"; +import { PROVIDER_VERSION } from "./version"; interface V2CacheEntry { schemaVersion: "v2"; @@ -16,11 +18,13 @@ export class OctopusFeatureClient { private readonly axiosInstance: AxiosInstance; private readonly localStorageKey = "octopus-openfeature-ts-feature-manifest"; private readonly releaseVersionOverride?: string; + private readonly productMetadata: ProductMetadata; constructor(configuration: OctopusFeatureConfiguration) { this.clientIdentifier = configuration.clientIdentifier; this.serverUri = configuration.serverUri ? configuration.serverUri.replace(/\/$/, "") : "https://features.octopus.com"; this.releaseVersionOverride = configuration.releaseVersionOverride; + this.productMetadata = configuration.productMetadata; this.logger = configuration.logger ?? new DefaultLogger(); this.axiosInstance = axios.create(); axiosRetry(this.axiosInstance, { @@ -78,6 +82,7 @@ export class OctopusFeatureClient { responseType: "json", headers: { Authorization: `Bearer ${this.clientIdentifier}`, + "X-Octopus-Client": this.buildOctopusClientHeaderValue(), }, }; if (this.releaseVersionOverride) { @@ -100,4 +105,12 @@ export class OctopusFeatureClient { return { evaluations: response.data, contentHash: contentHash }; } + + private buildOctopusClientHeaderValue(): string { + let value = this.productMetadata.name; + if (this.productMetadata.version) { + value += `/${this.productMetadata.version}`; + } + return `${value} openfeature-provider-ts-web/${PROVIDER_VERSION}`; + } } diff --git a/src/octopusFeatureProvider.test.ts b/src/octopusFeatureProvider.test.ts index d354b06..c2fb524 100644 --- a/src/octopusFeatureProvider.test.ts +++ b/src/octopusFeatureProvider.test.ts @@ -1,4 +1,5 @@ import { OctopusFeatureProvider } from "./octopusFeatureProvider"; +import { ProductMetadata } from "./productMetadata"; import { OpenFeature } from "@openfeature/web-sdk"; import { OctopusFeatureClient } from "./octopusFeatureClient"; import { OctopusFeatureContext } from "./octopusFeatureContext"; @@ -19,6 +20,7 @@ describe.skip("octopusFeatureProvider", () => { test("use this to verify that the provider is happy end to end", async () => { const client = new OctopusFeatureProvider({ clientIdentifier: "TODO", + productMetadata: new ProductMetadata("TestClient"), }); await OpenFeature.setProviderAndWait(client); @@ -51,7 +53,10 @@ describe("Context is available for segment evaluation immediately after provider }); test("setContext before setProviderAndWait — SDK passes context to initialize", async () => { - const provider = new OctopusFeatureProvider({ clientIdentifier: "test" }); + const provider = new OctopusFeatureProvider({ + clientIdentifier: "test", + productMetadata: new ProductMetadata("TestClient"), + }); await OpenFeature.setContext(context); await OpenFeature.setProviderAndWait(provider); @@ -61,7 +66,10 @@ describe("Context is available for segment evaluation immediately after provider }); test("setProviderAndWait with context — context passed directly to initialize", async () => { - const provider = new OctopusFeatureProvider({ clientIdentifier: "test" }); + const provider = new OctopusFeatureProvider({ + clientIdentifier: "test", + productMetadata: new ProductMetadata("TestClient"), + }); await OpenFeature.setProviderAndWait(provider, context); @@ -73,7 +81,10 @@ describe("Context is available for segment evaluation immediately after provider // so any flag evaluation (e.g. from React hooks) between these two calls will fail // segment matching. Prefer calling setContext before setProviderAndWait to avoid this. test("setProviderAndWait then setContext — context is not available until setContext completes", async () => { - const provider = new OctopusFeatureProvider({ clientIdentifier: "test" }); + const provider = new OctopusFeatureProvider({ + clientIdentifier: "test", + productMetadata: new ProductMetadata("TestClient"), + }); await OpenFeature.setProviderAndWait(provider); diff --git a/src/octopusFeatureProvider.ts b/src/octopusFeatureProvider.ts index 6227142..880f2ea 100644 --- a/src/octopusFeatureProvider.ts +++ b/src/octopusFeatureProvider.ts @@ -1,11 +1,15 @@ import { EvaluationContext, JsonValue, Logger, Provider, ResolutionDetails } from "@openfeature/web-sdk"; import { OctopusFeatureClient } from "./octopusFeatureClient"; import { OctopusFeatureContext } from "./octopusFeatureContext"; +import { ProductMetadata } from "./productMetadata"; export interface OctopusFeatureConfiguration { /** The ClientIdentifier provided by the Octopus variable Octopus.FeatureToggles.ClientIdentifier */ clientIdentifier: string; + /** Product name and optional version to include in the X-Octopus-Client request header */ + productMetadata: ProductMetadata; + serverUri?: string; logger?: Logger; diff --git a/src/productMetadata.test.ts b/src/productMetadata.test.ts new file mode 100644 index 0000000..6dba4fb --- /dev/null +++ b/src/productMetadata.test.ts @@ -0,0 +1,53 @@ +import { ProductMetadata } from "./productMetadata"; + +describe("ProductMetadata", () => { + describe("name", () => { + test("valid name characters are preserved unchanged", () => { + const metadata = new ProductMetadata("OctopusDeploy"); + expect(metadata.name).toBe("OctopusDeploy"); + }); + + test("spaces and punctuation are stripped from name", () => { + const metadata = new ProductMetadata("My ,Product (v2.0)/release@2024:final"); + expect(metadata.name).toBe("MyProductv2.0release2024final"); + }); + + test("hyphens are preserved in name", () => { + const metadata = new ProductMetadata("My-Product"); + expect(metadata.name).toBe("My-Product"); + }); + + test("throws when name is whitespace only", () => { + expect(() => new ProductMetadata(" ")).toThrow("Product name"); + }); + + test("throws when name becomes empty after stripping invalid chars", () => { + expect(() => new ProductMetadata(" @@@ ")).toThrow("Product name"); + }); + }); + + describe("version", () => { + test("version is undefined when not provided", () => { + const metadata = new ProductMetadata("MyProduct"); + expect(metadata.version).toBeUndefined(); + }); + + test("valid version characters are preserved unchanged", () => { + const metadata = new ProductMetadata("MyProduct", "2024.1.0"); + expect(metadata.version).toBe("2024.1.0"); + }); + + test("unsupported chars in version are stripped", () => { + const metadata = new ProductMetadata("MyProduct", "2024.1 (beta)"); + expect(metadata.version).toBe("2024.1beta"); + }); + + test("throws when version is whitespace only", () => { + expect(() => new ProductMetadata("MyProduct", " ")).toThrow("Product version"); + }); + + test("throws when version becomes empty after stripping invalid chars", () => { + expect(() => new ProductMetadata("MyProduct", " @@@ ")).toThrow("Product version"); + }); + }); +}); diff --git a/src/productMetadata.ts b/src/productMetadata.ts new file mode 100644 index 0000000..a1a2f5b --- /dev/null +++ b/src/productMetadata.ts @@ -0,0 +1,29 @@ +// RFC 9110 token characters: https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens +const INVALID_TCHAR = /[^a-zA-Z0-9!#$%&'*+\-.^_`|~]/g; + +function clean(value: string): string { + return value.replace(INVALID_TCHAR, ""); +} + +export class ProductMetadata { + readonly name: string; + readonly version: string | undefined; + + constructor(name: string, version?: string) { + const cleanedName = clean(name); + if (!cleanedName) { + throw new Error("Product name must contain at least one valid token character."); + } + this.name = cleanedName; + + if (version !== undefined) { + const cleanedVersion = clean(version); + if (!cleanedVersion) { + throw new Error("Product version must contain at least one valid token character."); + } + this.version = cleanedVersion; + } else { + this.version = undefined; + } + } +} diff --git a/src/specificationTests/fixtureEvaluationTests.test.ts b/src/specificationTests/fixtureEvaluationTests.test.ts index ef8d33c..721350b 100644 --- a/src/specificationTests/fixtureEvaluationTests.test.ts +++ b/src/specificationTests/fixtureEvaluationTests.test.ts @@ -3,6 +3,7 @@ import * as path from "path"; import { ErrorCode } from "@openfeature/core"; import { OpenFeature } from "@openfeature/web-sdk"; import { OctopusFeatureProvider } from "../octopusFeatureProvider"; +import { ProductMetadata } from "../productMetadata"; import { Server } from "./server"; interface Fixture { @@ -66,6 +67,7 @@ test.each(testCases)("$testCase.description", async ({ testResponse, testCase }) const provider = new OctopusFeatureProvider({ clientIdentifier: token, serverUri: server.url, + productMetadata: new ProductMetadata("TestClient"), }); await OpenFeature.setProviderAndWait(provider); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..4b35be8 --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const PROVIDER_VERSION = "3.0.2"; // x-release-please-version From ff948fde2a28924c93138b98f17bd6b639266406 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Tue, 26 May 2026 09:54:26 +1000 Subject: [PATCH 3/5] Improve ProductMetadata constructors --- src/productMetadata.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/productMetadata.ts b/src/productMetadata.ts index a1a2f5b..25fc85e 100644 --- a/src/productMetadata.ts +++ b/src/productMetadata.ts @@ -9,21 +9,29 @@ export class ProductMetadata { readonly name: string; readonly version: string | undefined; + constructor(name: string); + constructor(name: string, version: string); constructor(name: string, version?: string) { - const cleanedName = clean(name); - if (!cleanedName) { - throw new Error("Product name must contain at least one valid token character."); - } - this.name = cleanedName; + this.name = clean(name); + this.validateName(); if (version !== undefined) { - const cleanedVersion = clean(version); - if (!cleanedVersion) { - throw new Error("Product version must contain at least one valid token character."); - } - this.version = cleanedVersion; + this.version = clean(version); + this.validateVersion(); } else { this.version = undefined; } } + + private validateName(): void { + if (!this.name) { + throw new Error("Product name must contain at least one valid token character."); + } + } + + private validateVersion(): void { + if (!this.version) { + throw new Error("Product version must contain at least one valid token character."); + } + } } From 0837ed38600c7d6ba761cfcec0bda9074a3933f2 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Tue, 26 May 2026 15:08:02 +1000 Subject: [PATCH 4/5] Update README.md --- README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0f049af..de951a3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Octopus Deploy OpenFeature Provider for TypeScript/JavaScript (web clients) +# Octopus Deploy OpenFeature Provider for TypeScript/JavaScript (web clients) [![Build test and release](https://github.com/OctopusDeploy/openfeature-provider-ts-web/actions/workflows/build-test-release.yml/badge.svg)](https://github.com/OctopusDeploy/openfeature-provider-ts-web/actions/workflows/build-test-release.yml) The OctopusDeploy TypeScript/JavaScript [OpenFeature provider ](https://openfeature.dev/docs/reference/concepts/provider/) for web clients, to be used with the [OpenFeature web SDK](https://openfeature.dev/docs/reference/technologies/client/web/). -## About Octopus Deploy +## About Octopus Deploy [Octopus Deploy](https://octopus.com) is a sophisticated, best-of-breed continuous delivery (CD) platform for modern software teams. Octopus offers powerful release orchestration, deployment automation, and runbook automation, while handling the scale, complexity and governance expectations of even the largest organizations with the most complex deployment challenges. @@ -20,14 +20,20 @@ npm i @octopusdeploy/openfeature ### Usage ```ts -const provider = new OctopusFeatureProvider({ clientIdentifier: "YourClientIdentifier" }); +import { OctopusFeatureProvider, ProductMetadata } from "@octopusdeploy/openfeature"; +import { OpenFeature } from "@openfeature/web-sdk"; + +const provider = new OctopusFeatureProvider({ + clientIdentifier: "YourClientIdentifier", + productMetadata: new ProductMetadata("YourProductName", "1.0.0"), +}); await OpenFeature.setContext({ userid: "bob@octopus.com" }); await OpenFeature.setProviderAndWait(provider); const client = OpenFeature.getClient(); - + if (client.getBooleanValue("to-the-moon-feature", false, {})) { - console.log('🚀🚀🚀'); + console.log("🚀🚀🚀"); } ``` From 5df2f0603cab335a67aa6bf3191fec4570a533b9 Mon Sep 17 00:00:00 2001 From: Liam Hughes Date: Tue, 26 May 2026 15:47:22 +1000 Subject: [PATCH 5/5] Self review --- src/octopusFeatureProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/octopusFeatureProvider.ts b/src/octopusFeatureProvider.ts index 880f2ea..3957c00 100644 --- a/src/octopusFeatureProvider.ts +++ b/src/octopusFeatureProvider.ts @@ -7,7 +7,7 @@ export interface OctopusFeatureConfiguration { /** The ClientIdentifier provided by the Octopus variable Octopus.FeatureToggles.ClientIdentifier */ clientIdentifier: string; - /** Product name and optional version to include in the X-Octopus-Client request header */ + /** Metadata about the application using the OpenFeature provider. Used to populate header for telemetry. */ productMetadata: ProductMetadata; serverUri?: string;