@xignet/client 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marx Biotech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # @xignet/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Signet OAuth **machine client** for Node.js. Uses `openid-client` for the
4
+ `client_credentials` protocol and RFC 6749 `client_secret_basic` encoding,
5
+ including client IDs containing colons. Independent of NestJS, Passport, Medusa
6
+ and the existing `@marxbiotech/signet-integration` resource-server package.
7
+
8
+ Requires Node.js **22.22.0 or newer**. Supports CommonJS `require()` and ESM
9
+ named imports. The CommonJS entry loads the ESM protocol library with native
10
+ dynamic `import()`.
11
+
12
+ ```sh
13
+ npm install @xignet/client
14
+ ```
15
+
16
+ ```ts
17
+ import { SignetClient, SignetOAuthError } from '@xignet/client';
18
+
19
+ const auth = new SignetClient({
20
+ tokenUrl: process.env.SIGNET_TOKEN_URL!, // token_endpoint from discovery
21
+ clientId: process.env.SIGNET_CLIENT_ID!, // original ID, NOT pre-encoded
22
+ clientSecret: process.env.SIGNET_CLIENT_SECRET!,
23
+ audience: 'https://stocksync.marxbiotech.com/api/mcp',
24
+ scope: 'stocksync:access',
25
+ timeoutMs: 30_000, // optional
26
+ });
27
+
28
+ const token = await auth.getAccessToken();
29
+ // Use Authorization: Bearer <token> with your API client; never add X-API-Key.
30
+ // On API 401, invalidate only the rejected token, then retry at most once:
31
+ auth.invalidateToken(token);
32
+ const replacement = await auth.getAccessToken();
33
+ ```
34
+
35
+ Create one client per credentials/audience/scope configuration and reuse it.
36
+ The cache is process-local; it is not persisted. A request reuses the cached
37
+ token until `expires_in` minus a 60-second margin, capped at half the lifetime
38
+ for short-lived tokens. `refreshMarginSeconds` overrides the margin. Time spent
39
+ obtaining the token counts towards its lifetime. Concurrent exchanges share a
40
+ single promise. Failures clear that promise so a later call can recover.
41
+
42
+ `audience` and `scope` are sent in the form body. Credentials use Basic auth;
43
+ the caller must pass their original values without pre-encoding them. The token
44
+ URL must use HTTPS and contain no credentials, query or fragment. Redirects
45
+ are rejected. This package uses the explicitly configured token endpoint; it
46
+ does not perform discovery, decode the access token or manage user sessions.
47
+
48
+ The SDK does **not** retry token failures or make business API requests. API
49
+ 401/403 handling belongs to the consumer: invalidate and retry once for 401;
50
+ fail immediately for 403. `invalidateToken(rejectedToken)` cannot evict a newer
51
+ token obtained for another request. There is no refresh token in this flow.
52
+
53
+ `SignetOAuthError` exposes only `code` and optional HTTP `status`. Codes are
54
+ `INVALID_CONFIGURATION`, `TOKEN_REQUEST_FAILED` and `INVALID_TOKEN_RESPONSE`.
55
+ Raw upstream errors, response bodies and causes are discarded so they cannot
56
+ leak credentials or tokens through application logging. The SDK itself emits no
57
+ logs. The optional `fetch` transport override receives credentials; treat it as
58
+ trusted code, preserve abort handling and do not log request headers or bodies.
59
+
60
+ ## Migrating from @xignet/oauth-client
61
+
62
+ Replace the package dependency and import source with `@xignet/client`.
63
+ `SignetClient`, `SignetOAuthError`, configuration and runtime behavior are
64
+ unchanged. The previous package remains installable with a deprecation notice.
65
+
66
+ ## Development and release
67
+
68
+ From the repository root:
69
+
70
+ ```sh
71
+ pnpm install
72
+ pnpm --filter @xignet/client test
73
+ pnpm --filter @xignet/client typecheck
74
+ pnpm --filter @xignet/client build
75
+ ```
76
+
77
+ Releases are independent of the existing root package. Bump this package's
78
+ version and changelog, then push a matching `client-vX.Y.Z` tag. The
79
+ `client-publish.yml` GitHub Actions workflow validates, builds, checks the
80
+ packed artifact and publishes to npm with OIDC. Configure its npm Trusted
81
+ Publisher as GitHub owner `marxbiobuilder`, repository `signet-integration`,
82
+ workflow `client-publish.yml`. A first package publication/registration
83
+ and npm organization permissions must be established before relying on OIDC.
84
+ Prereleases are published under their first prerelease identifier, not `latest`.
package/dist/index.cjs ADDED
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SignetClient = exports.SignetOAuthError = void 0;
4
+ /** Contains no upstream body, URL, credentials, token or raw cause. Safe to log. */
5
+ class SignetOAuthError extends Error {
6
+ code;
7
+ status;
8
+ constructor(code, status) {
9
+ super(`Signet OAuth: ${code}${status ? ` (HTTP ${status})` : ''}`);
10
+ this.name = 'SignetOAuthError';
11
+ this.code = code;
12
+ this.status = status;
13
+ }
14
+ }
15
+ exports.SignetOAuthError = SignetOAuthError;
16
+ /**
17
+ * One instance per client/audience/scope configuration. Holds a single token in
18
+ * process memory. No timers, persistent storage, logging or API retries.
19
+ */
20
+ class SignetClient {
21
+ #options;
22
+ #cached;
23
+ #inFlight;
24
+ constructor(options) {
25
+ const invalid = () => new SignetOAuthError('INVALID_CONFIGURATION');
26
+ for (const value of [
27
+ options.tokenUrl,
28
+ options.clientId,
29
+ options.clientSecret,
30
+ options.audience,
31
+ options.scope,
32
+ ]) {
33
+ if (typeof value !== 'string' || !value.trim())
34
+ throw invalid();
35
+ }
36
+ let url;
37
+ try {
38
+ url = new URL(options.tokenUrl);
39
+ }
40
+ catch {
41
+ throw invalid();
42
+ }
43
+ if (url.protocol !== 'https:' ||
44
+ url.username ||
45
+ url.password ||
46
+ url.search ||
47
+ url.hash)
48
+ throw invalid();
49
+ const timeout = options.timeoutMs ?? 30_000;
50
+ const margin = options.refreshMarginSeconds ?? 60;
51
+ if (!Number.isSafeInteger(timeout) ||
52
+ timeout <= 0 ||
53
+ timeout > 2_147_483_647 ||
54
+ !Number.isFinite(margin) ||
55
+ margin < 0 ||
56
+ (options.fetch !== undefined && typeof options.fetch !== 'function'))
57
+ throw invalid();
58
+ this.#options = Object.freeze({
59
+ ...options,
60
+ timeoutMs: timeout,
61
+ refreshMarginSeconds: margin,
62
+ });
63
+ }
64
+ async getAccessToken() {
65
+ if (this.#cached && Date.now() < this.#cached.expiresAt)
66
+ return this.#cached.value;
67
+ if (this.#inFlight)
68
+ return this.#inFlight;
69
+ // A finally on the shared promise avoids rejected promises being retained.
70
+ this.#inFlight = this.#requestToken().finally(() => {
71
+ this.#inFlight = undefined;
72
+ });
73
+ return this.#inFlight;
74
+ }
75
+ /**
76
+ * Invalidate only the rejected token. A late 401 must not evict a newer token
77
+ * already obtained for another request. Does not cancel an in-flight exchange.
78
+ */
79
+ invalidateToken(rejectedToken) {
80
+ if (this.#cached?.value === rejectedToken)
81
+ this.#cached = undefined;
82
+ }
83
+ async #requestToken() {
84
+ let status;
85
+ try {
86
+ // Keep ESM import native in the CommonJS build for Node.js >=22.22.
87
+ const oidc = await import('openid-client');
88
+ const options = this.#options;
89
+ const config = new oidc.Configuration({
90
+ issuer: new URL(options.tokenUrl).origin,
91
+ token_endpoint: options.tokenUrl,
92
+ }, options.clientId, { token_endpoint_auth_method: 'client_secret_basic' }, oidc.ClientSecretBasic(options.clientSecret));
93
+ // The protocol library uses redirect:error. Preserve it to avoid forwarding
94
+ // credentials to redirected endpoints; share a deadline across transport.
95
+ const signal = AbortSignal.timeout(options.timeoutMs);
96
+ const transport = options.fetch ?? globalThis.fetch;
97
+ config[oidc.customFetch] = async (url, init) => {
98
+ // This SDK only performs form-encoded client credentials requests.
99
+ if (!(init.body instanceof URLSearchParams)) {
100
+ throw new SignetOAuthError('TOKEN_REQUEST_FAILED');
101
+ }
102
+ const response = await transport(url, {
103
+ ...init,
104
+ body: init.body,
105
+ redirect: 'error',
106
+ signal,
107
+ });
108
+ status = response.status;
109
+ return response;
110
+ };
111
+ const requestedAt = Date.now();
112
+ const data = await oidc.clientCredentialsGrant(config, {
113
+ audience: options.audience,
114
+ scope: options.scope,
115
+ });
116
+ if (!data.access_token ||
117
+ /\s/.test(data.access_token) ||
118
+ data.token_type.toLowerCase() !== 'bearer' ||
119
+ typeof data.expires_in !== 'number' ||
120
+ !Number.isFinite(data.expires_in) ||
121
+ data.expires_in <= 0)
122
+ throw new SignetOAuthError('INVALID_TOKEN_RESPONSE');
123
+ const margin = Math.min(options.refreshMarginSeconds, data.expires_in / 2);
124
+ const expiresAt = requestedAt + (data.expires_in - margin) * 1000;
125
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
126
+ throw new SignetOAuthError('INVALID_TOKEN_RESPONSE');
127
+ }
128
+ this.#cached = { value: data.access_token, expiresAt };
129
+ return data.access_token;
130
+ }
131
+ catch (error) {
132
+ if (error instanceof SignetOAuthError)
133
+ throw error;
134
+ // Discard openid-client errors: cause/response/error_description may be sensitive.
135
+ throw new SignetOAuthError(status === 200 ? 'INVALID_TOKEN_RESPONSE' : 'TOKEN_REQUEST_FAILED', status);
136
+ }
137
+ }
138
+ }
139
+ exports.SignetClient = SignetClient;
@@ -0,0 +1,35 @@
1
+ export interface SignetClientOptions {
2
+ /** Explicit token_endpoint from the issuer's discovery document. HTTPS only. */
3
+ tokenUrl: string;
4
+ clientId: string;
5
+ clientSecret: string;
6
+ /** Signet/Hydra expects audience in the form body, not resource or query. */
7
+ audience: string;
8
+ /** Required: use the scopes configured for this resource/client. */
9
+ scope: string;
10
+ timeoutMs?: number;
11
+ refreshMarginSeconds?: number;
12
+ /** Optional transport override for proxy integration and isolated tests. */
13
+ fetch?: typeof globalThis.fetch;
14
+ }
15
+ export type SignetOAuthErrorCode = 'INVALID_CONFIGURATION' | 'TOKEN_REQUEST_FAILED' | 'INVALID_TOKEN_RESPONSE';
16
+ /** Contains no upstream body, URL, credentials, token or raw cause. Safe to log. */
17
+ export declare class SignetOAuthError extends Error {
18
+ readonly code: SignetOAuthErrorCode;
19
+ readonly status?: number;
20
+ constructor(code: SignetOAuthErrorCode, status?: number);
21
+ }
22
+ /**
23
+ * One instance per client/audience/scope configuration. Holds a single token in
24
+ * process memory. No timers, persistent storage, logging or API retries.
25
+ */
26
+ export declare class SignetClient {
27
+ #private;
28
+ constructor(options: SignetClientOptions);
29
+ getAccessToken(): Promise<string>;
30
+ /**
31
+ * Invalidate only the rejected token. A late 401 must not evict a newer token
32
+ * already obtained for another request. Does not cancel an in-flight exchange.
33
+ */
34
+ invalidateToken(rejectedToken: string): void;
35
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@xignet/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Signet machine-to-machine OAuth client with safe errors, token caching and client_secret_basic authentication.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/marxbiobuilder/signet-integration.git",
9
+ "directory": "packages/client"
10
+ },
11
+ "type": "commonjs",
12
+ "main": "./dist/index.cjs",
13
+ "types": "./dist/index.d.cts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22.22.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "registry": "https://registry.npmjs.org"
30
+ },
31
+ "dependencies": {
32
+ "openid-client": "^6.8.9"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.10.7",
36
+ "typescript": "^5.7.3"
37
+ }
38
+ }