npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@platform-x/shp-service-client

v1.0.3

Published

Shared Shopify/Keycloak tenant config resolver, resilient GraphQL client, and Keycloak token verification for Platform-X microservices

Readme

@platform-x/shp-service-client

Shared library for Platform-X's Shopify microservices — shp-auth-service, shp-cart-service, shp-customer-service, shp-order-service, shp-product-service. Given a tenant's site name (e.g. KIWI), it resolves that tenant's Shopify and Keycloak configuration from a secret store, and provides three ready-to-use pieces built on top of that config:

  1. A resilient, tenant-scoped GraphQL client for Shopify's Admin or Storefront API.
  2. An Express middleware that verifies a Keycloak-issued bearer token, so every consumer verifies tokens the same way instead of each running its own jwks-rsa setup.
  3. A small set of resilience helpers (retry, timeout, circuit breaker) and error classes shared across services.

Install

npm install @platform-x/shp-service-client

Peer dependency: express ^4.18.0.

Quick start

import { getTenantConfig, getGraphQLClient, gql } from '@platform-x/shp-service-client';

// Tenant config (Shopify + Keycloak settings for one site)
const config = await getTenantConfig('KIWI');
console.log(config.shopify.storeDomain); // e.g. "knma-vofh9g70.myshopify.com"

// A ready-to-use GraphQL client, pre-configured with the tenant's endpoint + auth header
const admin = await getGraphQLClient('KIWI', 'admin');
const data = await admin.request(gql`query { shop { name } }`);

Tenant config

How it's resolved

Every secret is looked up under an id built as:

${SITENAME}_${PROVIDER}_${KEY}   (uppercased)

e.g. buildSecretId('KIWI', 'SHOPIFY', 'STORE_DOMAIN')KIWI_SHOPIFY_STORE_DOMAIN.

getTenantConfig(siteName: string): Promise<TenantConfig>
clearTenantCache(siteName?: string): void
setConfigProvider(provider: ConfigProvider): void   // mainly for tests — see Testing below

getTenantConfig(siteName) fetches all 18 keys for that tenant — 9 Shopify + 9 Keycloak — in parallel, and caches the result for 5 minutes per site name (clearTenantCache() to bust it early, e.g. after rotating a secret).

Shopify keys: ADMIN_API_ACCESS_TOKEN, IS_ENABLED, PLAN_NAME, CLIENT_ID, CLIENT_SECRET, STORE_DOMAIN, STOREFRONT_API_VERSION, STOREFRONT_PRIVATE_TOKEN, WEBHOOK_HMAC_SECRET.

Keycloak keys: BASE_URL, REALM, CLIENT_ID, CLIENT_SECRET, DISCOVERY_URL, JWKS_URI, ISSUER, CALLBACK_URL, POST_LOGOUT_REDIRECT_URI.

Fetches are always per-key, never a bulk "list everything in the project" call — a GCP project may hold hundreds of unrelated secrets, and this package only ever needs these 18 per tenant.

Where secrets actually come from: SECRET_MODE

Each of those 18 lookups goes through hep-secret-access's secretManager(), which picks a backing provider based on the SECRET_MODE env var (the same convention hep-delivery-service uses):

| SECRET_MODE | Source | Notes | |---|---|---| | env | process.env[secretId] | For local dev — no GCP dependency. | | gsm | GCP Secret Manager, projects/{GOOGLE_PROJECT_ID}/secrets/{secretId}/versions/latest | Needs GOOGLE_PROJECT_ID and valid GCP credentials (ADC or a service account). | | csi | A file per secret under BASEPATH (default /var/secrets), named after the secret id | For a Kubernetes CSI Secret Store volume mount. |

If SECRET_MODE isn't set at all, createConfigProvider('auto') (used internally by getTenantConfig) falls back based on NODE_ENV:

  • NODE_ENV=productiongsm (throws if GOOGLE_PROJECT_ID is missing)
  • anything else → env

Force a specific mode for one call without touching the ambient env:

import { createConfigProvider, setConfigProvider } from '@platform-x/shp-service-client';

setConfigProvider(createConfigProvider('secret-manager')); // forces gsm just for this provider

Note: unlike hep-secret-access's own EnvProvider (which silently returns '' for a missing key), this package always throws SecretNotFoundError on a missing/empty secret, regardless of mode — a misconfigured tenant should fail loudly, not propagate blank strings.

GOOGLE_PROJECT_ID vs GCP_PROJECT_ID: GSM mode reads GOOGLE_PROJECT_ID — that's what hep-secret-access's GoogleSecretManagerProvider actually uses. It is not the same as GCP_PROJECT_ID, which this package doesn't read at all. If GOOGLE_PROJECT_ID is missing while SECRET_MODE=gsm is explicitly set, nothing here will catch it — GSM lookups will silently target hep-secret-access's own fallback project (x-site-qa) instead of yours, and fail with a confusing "not found or no permission" error.

Environment variables

| Variable | Required when | Purpose | |---|---|---| | SECRET_MODE | optional | env | gsm | csi — overrides the NODE_ENV-based auto-selection. | | NODE_ENV | used by auto mode | production → defaults to gsm; anything else → env. | | GOOGLE_PROJECT_ID | gsm mode | GCP project id secrets are looked up in. | | BASEPATH | csi mode | Directory secret files are mounted under (default /var/secrets). | | KIWI_SHOPIFY_*, KIWI_KEYCLOAK_* | env mode | The 18 per-tenant values themselves (see table above), read directly from process.env. |

(Swap KIWI for whatever site name you call getTenantConfig/getGraphQLClient with — the prefix is derived from that argument, not hardcoded.)


GraphQL client

getGraphQLClient(siteName: string, type?: 'admin' | 'storefront'): Promise<GraphQLClient>
clearGraphQLClientCache(siteName?: string): void

Returns a cached graphql-request client, pre-configured per type:

| type | Endpoint | Auth header | |---|---|---| | admin (default) | https://{storeDomain}/admin/api/{storefrontApiVersion}/graphql.json | X-Shopify-Access-Token: {adminApiAccessToken} | | storefront | https://{storeDomain}/api/{storefrontApiVersion}/graphql.json | Shopify-Storefront-Private-Token: {storefrontPrivateToken} |

storefrontPrivateToken is a private (server-side) Storefront token — it must go through the Shopify-Storefront-Private-Token header, not the public-token header (X-Shopify-Storefront-Access-Token), which Shopify rejects it with a 401.

Call clearGraphQLClientCache(siteName?) after rotating a token so the next call rebuilds the client with fresh config instead of reusing the cached one.

This package also extends Node's default TLS trust store with the OS certificate store at import time (see tls-trust.ts), so getGraphQLClient calls succeed behind a TLS-intercepting corporate proxy without every consuming service needing its own --use-system-ca flag or CA bundle file.

Resilience helpers

Building blocks for wrapping outbound calls — used internally by services that call getGraphQLClient, also exported for direct use:

withRetry(fn, retries = 3, baseMs = 200, shouldRetry?): Promise<T>   // exponential backoff
withTimeout(fn, ms = 5000): Promise<T>                               // throws TimeoutError
new CircuitBreaker(failureThreshold = 5, resetAfterMs = 30_000)       // .call(fn), .isOpen(), .reset()

Typical usage in a consuming service:

const breaker = new CircuitBreaker();

async function adminGraphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
  return breaker.call(() =>
    withTimeout(() =>
      withRetry(async () => {
        const client = await getGraphQLClient('KIWI', 'admin');
        return client.request<T>(query, variables);
      }, 3, 300, (err) => err instanceof ClientError && (err.response?.status === 429 || (err.response?.status ?? 0) >= 500)),
      15000
    )
  );
}

Keycloak token verification

extractBearer(authorization?: string): string
verifyKeycloakToken(token: string, config: KeycloakVerifyConfig): Promise<KeycloakTokenPayload>
keycloakConfigForTenant(siteName: string, audience?: string): Promise<KeycloakVerifyConfig>
requireKeycloakAuth(config: KeycloakVerifyConfig | ((req) => KeycloakVerifyConfig | Promise<KeycloakVerifyConfig>))
  • extractBearer strips the Bearer prefix from an Authorization header, throwing UnauthorizedError if it's missing or malformed.
  • verifyKeycloakToken checks a token's RS256 signature (via config.jwksUri), issuer, and — if given — audience. One jwks-rsa client is created per distinct jwksUri and reused across calls (it has its own internal signing-key cache + rate limiter), so calling this repeatedly for the same tenant doesn't refetch keys.
  • keycloakConfigForTenant(siteName, audience?) builds a KeycloakVerifyConfig from getTenantConfig(siteName).keycloak — the issuer/JWKS come from the tenant's resolved config, not a locally duplicated env var. audience is passed through as-is since it's a per-caller restriction, not a per-tenant secret.
  • requireKeycloakAuth is the Express middleware most services actually use. It takes either a static KeycloakVerifyConfig (a service with its own fixed issuer/JWKS env vars — e.g. shp-auth-service's admin-facing Keycloak config) or a function that resolves one per request — typically () => keycloakConfigForTenant(SITE_NAME). On success it sets req.user = { sub, role } and req.keycloakUser (the full decoded payload) before calling next().
import { requireKeycloakAuth, keycloakConfigForTenant } from '@platform-x/shp-service-client';

export const requireAuth = requireKeycloakAuth(() =>
  keycloakConfigForTenant('KIWI', process.env.KEYCLOAK_AUDIENCE || undefined),
);

router.get('/orders/:id', requireAuth, ordersController.getOrder);

Failures reject with TokenExpiredError, TokenInvalidError, or UnauthorizedError (see Errors below) — requireKeycloakAuth forwards them to next(err), so a service's own globalErrorHandler (or this package's) maps them to the right HTTP status.


Errors

All thrown as plain Error subclasses with a distinct .name:

| Class | Meaning | |---|---| | TenantNotFoundError | Tenant config not found for a site name | | SecretNotFoundError | A specific secret was missing/empty | | TokenExpiredError / TokenInvalidError | Keycloak token verification failures | | UnauthorizedError / ForbiddenError / CrossTenantAccessError | Access control | | ShopifyApiError / ShopifyUserError | Shopify API / GraphQL user errors | | TimeoutError / CircuitOpenError | From withTimeout / CircuitBreaker |

globalErrorHandler(err, req, res, next) is an optional Express error-handling middleware that maps each of the above to an HTTP status code and returns { error, code } — use it directly, or as a reference for a service's own error handler.

Multi-tenancy from a request (optional)

extractSiteName(req): string | null   // 'sitehost'/'sitename' header, or hostname, uppercased

Not currently wired into any consuming service's request pipeline (each service today resolves a single fixed SITE_NAME from its own env at startup) — provided for a future per-request, multi-tenant routing scenario.


Testing

setConfigProvider(provider) swaps the module-level ConfigProvider getTenantConfig uses (and clears the tenant cache) — pass a mock implementing { get(secretId): Promise<string> } in tests instead of hitting env/gsm/csi for real.

npm test         # cross-env NODE_ENV=test jest --runInBand
npm run typecheck
npm run lint

Full API surface

export { getTenantConfig, clearTenantCache, setConfigProvider };
export { extractSiteName, buildSecretId };
export { createConfigProvider };
export type { ProviderMode };
export { HepSecretProvider };
export { TTLCache };
export { getGraphQLClient, clearGraphQLClientCache, gql, ClientError };
export { withRetry, withTimeout, CircuitBreaker };
export { extractBearer, verifyKeycloakToken, keycloakConfigForTenant, requireKeycloakAuth };
export type { KeycloakTokenPayload, KeycloakVerifyConfig };
export {
  TenantNotFoundError, SecretNotFoundError,
  TokenExpiredError, TokenInvalidError, UnauthorizedError,
  CrossTenantAccessError, ForbiddenError,
  ShopifyApiError, ShopifyUserError,
  TimeoutError, CircuitOpenError,
  globalErrorHandler,
};
export type { TenantConfig, ShopifyConfig, KeycloakConfig, ConfigProvider };

Development

npm run build   # tsc -> dist/
npm run dev     # tsc --watch
npm run clean   # rm -rf dist

Bump version in package.json and npm publish (registry: registry.npmjs.org, scope @platform-x per .npmrc) to ship changes to consuming services — they pin @platform-x/shp-service-client by version, so a local change here has no effect on them until published and their dependency is bumped + reinstalled.