@hivemagnus/hive-common 0.0.0-stage → 0.2.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/CHANGELOG.md +65 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +439 -2
- package/dist/context/index.d.ts +98 -0
- package/dist/context/index.js +191 -0
- package/dist/contracts/index.d.ts +117 -0
- package/dist/contracts/index.js +228 -0
- package/dist/errors/index.d.ts +91 -0
- package/dist/errors/index.js +309 -0
- package/dist/events/index.d.ts +107 -0
- package/dist/events/index.js +232 -0
- package/dist/health/index.d.ts +101 -0
- package/dist/health/index.js +126 -0
- package/dist/hono/index.d.ts +67 -0
- package/dist/hono/index.js +108 -0
- package/dist/log/index.d.ts +39 -0
- package/dist/log/index.js +80 -0
- package/dist/log/redact.d.ts +23 -0
- package/dist/log/redact.js +111 -0
- package/dist/node-http/index.d.ts +46 -0
- package/dist/node-http/index.js +91 -0
- package/dist/oathweave/claims.d.ts +79 -0
- package/dist/oathweave/claims.js +99 -0
- package/dist/oathweave/conformance.d.ts +88 -0
- package/dist/oathweave/conformance.js +124 -0
- package/dist/oathweave/endpoint.d.ts +15 -0
- package/dist/oathweave/endpoint.js +35 -0
- package/dist/oathweave/errors.d.ts +19 -0
- package/dist/oathweave/errors.js +70 -0
- package/dist/oathweave/index.d.ts +10 -0
- package/dist/oathweave/index.js +10 -0
- package/dist/oathweave/links.d.ts +63 -0
- package/dist/oathweave/links.js +138 -0
- package/dist/oathweave/stream.d.ts +26 -0
- package/dist/oathweave/stream.js +46 -0
- package/dist/oathweave/verifier.d.ts +83 -0
- package/dist/oathweave/verifier.js +237 -0
- package/dist/registry/index.d.ts +138 -0
- package/dist/registry/index.js +175 -0
- package/package.json +126 -4
- package/test-vectors/README.md +27 -0
- package/test-vectors/oathweave/jwks.json +12 -0
- package/test-vectors/oathweave/vectors.json +174 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Oathweave access-token verifier: RS256 against the issuer's JWKS, exact issuer and
|
|
3
|
+
* audience, bounded lifetime with clock tolerance, user/service principal rules, and an
|
|
4
|
+
* optional online session check through userinfo with a short shared cache.
|
|
5
|
+
*
|
|
6
|
+
* Failure is closed: every refusal is an `OathweaveError` (401, 403 or 503).
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createLocalJWKSet, createRemoteJWKSet, customFetch, decodeProtectedHeader, errors as joseErrors, jwtVerify, } from 'jose';
|
|
10
|
+
import { CLIENT_ID_CLAIMS, principalFromClaims, } from './claims.js';
|
|
11
|
+
import { assertOathweaveEndpoint } from './endpoint.js';
|
|
12
|
+
import { OathweaveError } from './errors.js';
|
|
13
|
+
/** How long a userinfo answer is reused, measured from when its request started. */
|
|
14
|
+
export const OATHWEAVE_SESSION_CACHE_TTL_MS = 10_000;
|
|
15
|
+
/** How often a long-lived connection should re-check its token. */
|
|
16
|
+
export const OATHWEAVE_STREAM_RECHECK_MS = 5_000;
|
|
17
|
+
/** Failed session checks are remembered this long, so one failing request does not verify twice. */
|
|
18
|
+
export const OATHWEAVE_FAILED_CHECK_TTL_MS = 2_000;
|
|
19
|
+
export const OATHWEAVE_SESSION_CACHE_MAX = 5_000;
|
|
20
|
+
export const OATHWEAVE_REQUEST_TIMEOUT_MS = 3_000;
|
|
21
|
+
/** Oathweave access tokens live at most fifteen minutes. */
|
|
22
|
+
export const OATHWEAVE_MAX_LIFETIME_SEC = 900;
|
|
23
|
+
export const OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC = 30;
|
|
24
|
+
/** Longest accepted compact token. Real Oathweave tokens are well under 4 KiB. */
|
|
25
|
+
export const OATHWEAVE_MAX_TOKEN_LENGTH = 16_384;
|
|
26
|
+
const BEARER = /^Bearer[ ]+([A-Za-z0-9\-._~+/]+=*)[ ]*$/i;
|
|
27
|
+
/**
|
|
28
|
+
* The token in an `Authorization: Bearer` header (RFC 6750). Undefined when there is no
|
|
29
|
+
* header; throws `malformed_token` when there is one but it is not a single bearer token.
|
|
30
|
+
*/
|
|
31
|
+
export function parseBearerToken(header) {
|
|
32
|
+
if (header === undefined || header === null || header === '')
|
|
33
|
+
return undefined;
|
|
34
|
+
const match = BEARER.exec(header);
|
|
35
|
+
if (!match?.[1])
|
|
36
|
+
throw new OathweaveError('malformed_token');
|
|
37
|
+
return match[1];
|
|
38
|
+
}
|
|
39
|
+
/** Errors from the key source that mean "could not get keys", not "bad token". */
|
|
40
|
+
class KeySourceUnavailable extends Error {
|
|
41
|
+
}
|
|
42
|
+
function keySource(options, request, timeoutMs) {
|
|
43
|
+
let resolver;
|
|
44
|
+
if (typeof options.keys === 'function')
|
|
45
|
+
resolver = options.keys;
|
|
46
|
+
else if (options.keys)
|
|
47
|
+
resolver = createLocalJWKSet(options.keys);
|
|
48
|
+
else {
|
|
49
|
+
if (!options.jwksUrl)
|
|
50
|
+
throw new TypeError('Oathweave jwksUrl is required unless keys are given');
|
|
51
|
+
resolver = createRemoteJWKSet(new URL(options.jwksUrl), {
|
|
52
|
+
timeoutDuration: timeoutMs,
|
|
53
|
+
cacheMaxAge: options.jwksCacheMaxAgeMs ?? 10 * 60_000,
|
|
54
|
+
cooldownDuration: options.jwksCooldownMs ?? 30_000,
|
|
55
|
+
[customFetch]: (url, init) => request(url, { ...init, redirect: 'error' }),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return async (header, token) => {
|
|
59
|
+
try {
|
|
60
|
+
return await resolver(header, token);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// An unknown kid is the token's problem (after jose's rotation refetch); anything else
|
|
64
|
+
// (network, timeout, non-200, malformed JWKS) means the keys could not be obtained.
|
|
65
|
+
if (error instanceof joseErrors.JWKSNoMatchingKey || error instanceof joseErrors.JWKSMultipleMatchingKeys)
|
|
66
|
+
throw error;
|
|
67
|
+
throw new KeySourceUnavailable('keys unavailable', { cause: error });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Map a jose or key-source failure onto a stable reason. */
|
|
72
|
+
function mapVerifyError(error) {
|
|
73
|
+
if (error instanceof KeySourceUnavailable)
|
|
74
|
+
return new OathweaveError('jwks_unavailable', { cause: error.cause });
|
|
75
|
+
if (error instanceof joseErrors.JWTExpired)
|
|
76
|
+
return new OathweaveError('expired');
|
|
77
|
+
if (error instanceof joseErrors.JWTClaimValidationFailed) {
|
|
78
|
+
if (error.claim === 'nbf' || (error.claim === 'iat' && error.reason === 'check_failed'))
|
|
79
|
+
return new OathweaveError('not_yet_valid');
|
|
80
|
+
if (error.claim === 'iss')
|
|
81
|
+
return new OathweaveError('invalid_issuer');
|
|
82
|
+
if (error.claim === 'aud')
|
|
83
|
+
return new OathweaveError('invalid_audience');
|
|
84
|
+
return new OathweaveError('invalid_claims');
|
|
85
|
+
}
|
|
86
|
+
if (error instanceof joseErrors.JWKSNoMatchingKey)
|
|
87
|
+
return new OathweaveError('unknown_key');
|
|
88
|
+
if (error instanceof joseErrors.JWSSignatureVerificationFailed || error instanceof joseErrors.JWKSMultipleMatchingKeys) {
|
|
89
|
+
return new OathweaveError('invalid_signature');
|
|
90
|
+
}
|
|
91
|
+
// JOSEAlgNotAllowed cannot occur: the header precheck already refused every algorithm but RS256.
|
|
92
|
+
return new OathweaveError('malformed_token');
|
|
93
|
+
}
|
|
94
|
+
/** Create a verifier. Throws a TypeError at startup when the configuration is unsafe. */
|
|
95
|
+
export function createOathweaveVerifier(options) {
|
|
96
|
+
const issuers = typeof options.issuer === 'string' ? [options.issuer] : [...options.issuer];
|
|
97
|
+
if (!issuers.length)
|
|
98
|
+
throw new TypeError('At least one Oathweave issuer is required');
|
|
99
|
+
for (const issuer of issuers)
|
|
100
|
+
assertOathweaveEndpoint(issuer, 'issuer', options);
|
|
101
|
+
if (options.jwksUrl !== undefined)
|
|
102
|
+
assertOathweaveEndpoint(options.jwksUrl, 'jwksUrl', options);
|
|
103
|
+
if (options.userinfoUrl !== undefined)
|
|
104
|
+
assertOathweaveEndpoint(options.userinfoUrl, 'userinfoUrl', options);
|
|
105
|
+
if (typeof options.audience !== 'string' || !options.audience)
|
|
106
|
+
throw new TypeError('Oathweave audience is required');
|
|
107
|
+
const clockTolerance = options.clockToleranceSec ?? OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC;
|
|
108
|
+
const maxLifetimeSec = options.maxLifetimeSec ?? OATHWEAVE_MAX_LIFETIME_SEC;
|
|
109
|
+
if (!(clockTolerance >= 0 && clockTolerance <= 300))
|
|
110
|
+
throw new TypeError('clockToleranceSec must be 0-300');
|
|
111
|
+
if (!(maxLifetimeSec > 0 && maxLifetimeSec <= 86_400))
|
|
112
|
+
throw new TypeError('maxLifetimeSec must be 1-86400');
|
|
113
|
+
const requireRoles = options.requireRoles ?? false;
|
|
114
|
+
const clientIdClaim = options.clientIdClaim ?? 'either';
|
|
115
|
+
if (typeof requireRoles !== 'boolean')
|
|
116
|
+
throw new TypeError('requireRoles must be a boolean');
|
|
117
|
+
if (!CLIENT_ID_CLAIMS.includes(clientIdClaim))
|
|
118
|
+
throw new TypeError("clientIdClaim must be 'clientId', 'client_id' or 'either'");
|
|
119
|
+
const defaultAccept = options.accept ?? 'user';
|
|
120
|
+
const timeoutMs = options.requestTimeoutMs ?? OATHWEAVE_REQUEST_TIMEOUT_MS;
|
|
121
|
+
const request = options.fetch ?? ((input, init) => fetch(input, init));
|
|
122
|
+
const now = options.now ?? Date.now;
|
|
123
|
+
const keys = keySource(options, request, timeoutMs);
|
|
124
|
+
const sessionTtl = options.sessionCacheTtlMs ?? OATHWEAVE_SESSION_CACHE_TTL_MS;
|
|
125
|
+
const sessionMax = options.sessionCacheMax ?? OATHWEAVE_SESSION_CACHE_MAX;
|
|
126
|
+
// Holds only tokens whose signature verified, so garbage can neither fill it nor reach userinfo.
|
|
127
|
+
const sessions = new Map();
|
|
128
|
+
async function fetchDisplayName(token, subject) {
|
|
129
|
+
let response;
|
|
130
|
+
try {
|
|
131
|
+
response = await request(options.userinfoUrl, {
|
|
132
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
|
|
133
|
+
redirect: 'error',
|
|
134
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
throw new OathweaveError('identity_unavailable', { cause: error });
|
|
139
|
+
}
|
|
140
|
+
if (response.status === 401 || response.status === 403)
|
|
141
|
+
throw new OathweaveError('session_inactive');
|
|
142
|
+
if (!response.ok)
|
|
143
|
+
throw new OathweaveError('identity_unavailable');
|
|
144
|
+
let user;
|
|
145
|
+
try {
|
|
146
|
+
user = await response.json();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
throw new OathweaveError('identity_unavailable', { cause: error });
|
|
150
|
+
}
|
|
151
|
+
if (!user || typeof user !== 'object' || user.sub !== subject)
|
|
152
|
+
throw new OathweaveError('subject_mismatch');
|
|
153
|
+
const name = user.name;
|
|
154
|
+
return typeof name === 'string' && name ? name : subject;
|
|
155
|
+
}
|
|
156
|
+
function sessionCheck(token, subject) {
|
|
157
|
+
const key = createHash('sha256').update(token).digest('hex');
|
|
158
|
+
const startedAt = now();
|
|
159
|
+
const current = sessions.get(key);
|
|
160
|
+
if (current && startedAt < current.expiresAt)
|
|
161
|
+
return current.displayName;
|
|
162
|
+
sessions.delete(key);
|
|
163
|
+
if (sessions.size >= sessionMax) {
|
|
164
|
+
for (const [entryKey, entry] of sessions)
|
|
165
|
+
if (entry.expiresAt <= startedAt)
|
|
166
|
+
sessions.delete(entryKey);
|
|
167
|
+
if (sessions.size >= sessionMax)
|
|
168
|
+
sessions.delete(sessions.keys().next().value);
|
|
169
|
+
}
|
|
170
|
+
// Concurrent callers share one in-flight request; the TTL runs from when it started.
|
|
171
|
+
const check = { expiresAt: startedAt + sessionTtl, displayName: fetchDisplayName(token, subject) };
|
|
172
|
+
check.displayName.catch(() => {
|
|
173
|
+
check.expiresAt = Math.min(check.expiresAt, now() + OATHWEAVE_FAILED_CHECK_TTL_MS);
|
|
174
|
+
});
|
|
175
|
+
sessions.set(key, check);
|
|
176
|
+
return check.displayName;
|
|
177
|
+
}
|
|
178
|
+
async function verify(token, verifyOptions = {}) {
|
|
179
|
+
if (typeof token !== 'string' || !token)
|
|
180
|
+
throw new OathweaveError('missing_token');
|
|
181
|
+
if (token.length > OATHWEAVE_MAX_TOKEN_LENGTH || token.split('.').length !== 3)
|
|
182
|
+
throw new OathweaveError('malformed_token');
|
|
183
|
+
let alg;
|
|
184
|
+
try {
|
|
185
|
+
alg = decodeProtectedHeader(token).alg;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new OathweaveError('malformed_token');
|
|
189
|
+
}
|
|
190
|
+
// Refuse `none`, HMAC and every other algorithm before any key lookup.
|
|
191
|
+
if (alg !== 'RS256')
|
|
192
|
+
throw new OathweaveError('algorithm_not_allowed');
|
|
193
|
+
let payload;
|
|
194
|
+
try {
|
|
195
|
+
({ payload } = await jwtVerify(token, keys, {
|
|
196
|
+
algorithms: ['RS256'],
|
|
197
|
+
issuer: issuers,
|
|
198
|
+
audience: options.audience,
|
|
199
|
+
requiredClaims: ['sub', 'iat', 'exp', 'jti'],
|
|
200
|
+
maxTokenAge: maxLifetimeSec,
|
|
201
|
+
clockTolerance,
|
|
202
|
+
currentDate: new Date(now()),
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
throw mapVerifyError(error);
|
|
207
|
+
}
|
|
208
|
+
// jose accepts an audience array that contains ours; Oathweave issues exactly one audience.
|
|
209
|
+
if (payload.aud !== options.audience)
|
|
210
|
+
throw new OathweaveError('invalid_audience');
|
|
211
|
+
const principal = principalFromClaims(payload, {
|
|
212
|
+
accept: verifyOptions.accept ?? defaultAccept,
|
|
213
|
+
maxLifetimeSec,
|
|
214
|
+
requireRoles,
|
|
215
|
+
clientIdClaim,
|
|
216
|
+
});
|
|
217
|
+
if (principal.kind === 'user' && options.userinfoUrl) {
|
|
218
|
+
const displayName = await sessionCheck(token, principal.subject);
|
|
219
|
+
return { ...principal, displayName, sessionChecked: true };
|
|
220
|
+
}
|
|
221
|
+
return principal;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
issuers,
|
|
225
|
+
audience: options.audience,
|
|
226
|
+
verify,
|
|
227
|
+
verifyUser: token => verify(token, { accept: 'user' }),
|
|
228
|
+
verifyService: token => verify(token, { accept: 'service' }),
|
|
229
|
+
async verifyAuthorization(header, verifyOptions) {
|
|
230
|
+
const token = parseBearerToken(header);
|
|
231
|
+
if (token === undefined)
|
|
232
|
+
throw new OathweaveError('missing_token');
|
|
233
|
+
return verify(token, verifyOptions);
|
|
234
|
+
},
|
|
235
|
+
clearSessionCache: () => sessions.clear(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export interface RegistryInstallation {
|
|
2
|
+
readonly schema: 1;
|
|
3
|
+
readonly id: string;
|
|
4
|
+
/** IDs of installed products visible at this access level. */
|
|
5
|
+
readonly products: readonly string[];
|
|
6
|
+
readonly origin?: string | null;
|
|
7
|
+
readonly profile?: 'core' | 'full' | 'custom' | null;
|
|
8
|
+
readonly release?: {
|
|
9
|
+
readonly version: string;
|
|
10
|
+
readonly manifestDigest: string;
|
|
11
|
+
readonly planDigest: string;
|
|
12
|
+
} | null;
|
|
13
|
+
readonly phase?: string | null;
|
|
14
|
+
readonly revision?: number;
|
|
15
|
+
readonly updatedAt?: string | null;
|
|
16
|
+
readonly hosts?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
export interface RegistryProductLinks {
|
|
19
|
+
readonly repository: string | null;
|
|
20
|
+
readonly documentation: string | null;
|
|
21
|
+
readonly license: string | null;
|
|
22
|
+
readonly licenseSpdx: string | null;
|
|
23
|
+
}
|
|
24
|
+
export interface RegistryProductVersion {
|
|
25
|
+
readonly version: string;
|
|
26
|
+
readonly image: string;
|
|
27
|
+
readonly imageDigest: string;
|
|
28
|
+
readonly storageFormat: string | null;
|
|
29
|
+
readonly release: string | null;
|
|
30
|
+
readonly recordedAt: string;
|
|
31
|
+
}
|
|
32
|
+
export interface RegistryProductInstallation {
|
|
33
|
+
readonly inventoryHost: string | null;
|
|
34
|
+
readonly version: string | null;
|
|
35
|
+
readonly image: string | null;
|
|
36
|
+
readonly imageDigest: string | null;
|
|
37
|
+
readonly status: 'activating' | 'installed' | 'external' | string;
|
|
38
|
+
readonly installedAt: string | null;
|
|
39
|
+
readonly updatedAt: string | null;
|
|
40
|
+
readonly release: string | null;
|
|
41
|
+
}
|
|
42
|
+
export interface RegistryProduct {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly displayName: string;
|
|
45
|
+
readonly description: string;
|
|
46
|
+
readonly links: RegistryProductLinks;
|
|
47
|
+
readonly consoleSection: string | null;
|
|
48
|
+
readonly required: boolean;
|
|
49
|
+
/** True only once activation is recorded (or the product is external). */
|
|
50
|
+
readonly installed: boolean;
|
|
51
|
+
readonly dependencies: readonly string[];
|
|
52
|
+
readonly kind?: 'application' | 'datastore' | 'static-ui' | 'ingress' | string;
|
|
53
|
+
readonly source?: string;
|
|
54
|
+
readonly public?: boolean;
|
|
55
|
+
readonly offered?: boolean;
|
|
56
|
+
readonly versions?: readonly RegistryProductVersion[];
|
|
57
|
+
readonly installation?: RegistryProductInstallation | null;
|
|
58
|
+
}
|
|
59
|
+
export interface RegistryConnection {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly consolePath: string | null;
|
|
62
|
+
readonly browserPrefix: string | null;
|
|
63
|
+
/** Public API base URLs on the console origin, by API name. */
|
|
64
|
+
readonly nativeApis: Readonly<Record<string, string>>;
|
|
65
|
+
readonly documentation: string | null;
|
|
66
|
+
readonly inventoryHost?: string | null;
|
|
67
|
+
/** Private-network endpoints, by name. */
|
|
68
|
+
readonly internalEndpoints?: Readonly<Record<string, string>>;
|
|
69
|
+
}
|
|
70
|
+
/** TLS trust for reaching a product. The registry stores none, so it comes from local configuration. */
|
|
71
|
+
export interface ProductTrust {
|
|
72
|
+
/** PEM CA certificate(s) the product's endpoint is signed by. */
|
|
73
|
+
readonly ca?: string | readonly string[];
|
|
74
|
+
}
|
|
75
|
+
export interface ProductEndpoint {
|
|
76
|
+
readonly productId: string;
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly url: string;
|
|
79
|
+
/** True for a private-network endpoint, false for a public native API. */
|
|
80
|
+
readonly internal: boolean;
|
|
81
|
+
readonly trust: ProductTrust;
|
|
82
|
+
}
|
|
83
|
+
export interface CaFetchOptions {
|
|
84
|
+
/** PEM CA certificate(s) to trust in addition to nothing else: the system store is not used. */
|
|
85
|
+
readonly ca: string | readonly string[];
|
|
86
|
+
readonly timeoutMs?: number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A minimal `fetch` over `node:https` that trusts only the given CA. Supports what this
|
|
90
|
+
* package's clients need: any method, headers, string or byte bodies, `signal`; redirects are
|
|
91
|
+
* returned, never followed. For anything more, inject your own fetch (for example undici's
|
|
92
|
+
* with a custom dispatcher).
|
|
93
|
+
*/
|
|
94
|
+
export declare function createCaFetch(options: CaFetchOptions): typeof fetch;
|
|
95
|
+
export interface RegistryClientOptions {
|
|
96
|
+
/**
|
|
97
|
+
* The registry base URL: the native service (`http://hive-registry:8095` on the private
|
|
98
|
+
* network, which needs `allowHttp`) or the ingress prefix (`https://hive.example.com/api/registry`).
|
|
99
|
+
*/
|
|
100
|
+
readonly baseUrl: string;
|
|
101
|
+
/** The read credential. Omit for the public view. Never use it in browser code. */
|
|
102
|
+
readonly token?: string;
|
|
103
|
+
readonly fetch?: typeof fetch;
|
|
104
|
+
/** PEM CA for an https registry signed by a private CA. Ignored when `fetch` is given. */
|
|
105
|
+
readonly ca?: string | readonly string[];
|
|
106
|
+
/** Allow plain http to a non-loopback host, for the registry's private Compose network. */
|
|
107
|
+
readonly allowHttp?: boolean;
|
|
108
|
+
readonly timeoutMs?: number;
|
|
109
|
+
/** TLS trust per product, returned by `endpoint()`. */
|
|
110
|
+
readonly trust?: Readonly<Record<string, ProductTrust>> | ((productId: string) => ProductTrust | undefined);
|
|
111
|
+
/** Extra headers on every request, such as `outboundHeaders()` from `/context`. */
|
|
112
|
+
readonly headers?: () => Record<string, string>;
|
|
113
|
+
}
|
|
114
|
+
export interface EndpointLookup {
|
|
115
|
+
/** Endpoint name. Defaults to the only one, or to one named after the product. */
|
|
116
|
+
readonly name?: string;
|
|
117
|
+
/** Prefer internal endpoints (default, needs a credential) or public native APIs. */
|
|
118
|
+
readonly prefer?: 'internal' | 'native';
|
|
119
|
+
}
|
|
120
|
+
export interface RegistryClient {
|
|
121
|
+
installation(): Promise<RegistryInstallation>;
|
|
122
|
+
/** The catalog, optionally filtered by `installed`. */
|
|
123
|
+
products(filter?: {
|
|
124
|
+
readonly installed?: boolean;
|
|
125
|
+
}): Promise<RegistryProduct[]>;
|
|
126
|
+
/** Installed products only. */
|
|
127
|
+
installedProducts(): Promise<RegistryProduct[]>;
|
|
128
|
+
/** One product, or undefined when the registry does not know it (or hides it at this access level). */
|
|
129
|
+
product(id: string): Promise<RegistryProduct | undefined>;
|
|
130
|
+
connection(id: string): Promise<RegistryConnection | undefined>;
|
|
131
|
+
/** Resolve where to call a product, with its configured TLS trust. Undefined when it has no such endpoint. */
|
|
132
|
+
endpoint(id: string, lookup?: EndpointLookup): Promise<ProductEndpoint | undefined>;
|
|
133
|
+
ready(): Promise<{
|
|
134
|
+
readonly status: string;
|
|
135
|
+
readonly readOnly: boolean;
|
|
136
|
+
}>;
|
|
137
|
+
}
|
|
138
|
+
export declare function createRegistryClient(options: RegistryClientOptions): RegistryClient;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed client for the Hive Registry API: the installation, its product catalog, and how
|
|
3
|
+
* to reach each product. See the Hive repository's `docs/hive-registry-api.md`.
|
|
4
|
+
*/
|
|
5
|
+
import { request as httpsRequest } from 'node:https';
|
|
6
|
+
import { isProductId } from '../contracts/index.js';
|
|
7
|
+
import { HiveError, parseHiveErrorResponse } from '../errors/index.js';
|
|
8
|
+
import { isLoopbackHost } from '../oathweave/endpoint.js';
|
|
9
|
+
/**
|
|
10
|
+
* A minimal `fetch` over `node:https` that trusts only the given CA. Supports what this
|
|
11
|
+
* package's clients need: any method, headers, string or byte bodies, `signal`; redirects are
|
|
12
|
+
* returned, never followed. For anything more, inject your own fetch (for example undici's
|
|
13
|
+
* with a custom dispatcher).
|
|
14
|
+
*/
|
|
15
|
+
export function createCaFetch(options) {
|
|
16
|
+
const ca = typeof options.ca === 'string' ? [options.ca] : [...options.ca];
|
|
17
|
+
if (!ca.length || ca.some(pem => !pem.includes('-----BEGIN CERTIFICATE-----')))
|
|
18
|
+
throw new TypeError('ca must be PEM certificates');
|
|
19
|
+
return (input, init) => new Promise((resolve, reject) => {
|
|
20
|
+
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
21
|
+
if (url.protocol !== 'https:') {
|
|
22
|
+
reject(new TypeError('createCaFetch only supports https URLs'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const headers = {};
|
|
26
|
+
new Headers(init?.headers).forEach((value, key) => { headers[key] = value; });
|
|
27
|
+
const body = init?.body;
|
|
28
|
+
if (body !== undefined && body !== null && typeof body !== 'string' && !(body instanceof Uint8Array)) {
|
|
29
|
+
reject(new TypeError('createCaFetch supports string and byte bodies only'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const signals = [init?.signal, options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined].filter((s) => !!s);
|
|
33
|
+
const signal = signals.length ? AbortSignal.any(signals) : undefined;
|
|
34
|
+
if (signal?.aborted) {
|
|
35
|
+
reject(signal.reason);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const request = httpsRequest(url, { method: init?.method ?? 'GET', headers, ca, ...(signal ? { signal } : {}) }, response => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
41
|
+
response.on('error', reject);
|
|
42
|
+
response.on('end', () => {
|
|
43
|
+
const responseHeaders = new Headers();
|
|
44
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
value.forEach(item => responseHeaders.append(key, item));
|
|
47
|
+
else if (value !== undefined)
|
|
48
|
+
responseHeaders.set(key, value);
|
|
49
|
+
}
|
|
50
|
+
const status = response.statusCode ?? 502;
|
|
51
|
+
const nullBody = status === 204 || status === 304 || init?.method === 'HEAD';
|
|
52
|
+
resolve(new Response(nullBody ? null : Buffer.concat(chunks), { status, statusText: response.statusMessage ?? '', headers: responseHeaders }));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
request.on('error', error => reject(signal?.aborted ? signal.reason : error));
|
|
56
|
+
if (body !== undefined && body !== null)
|
|
57
|
+
request.write(body);
|
|
58
|
+
request.end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function isRecord(value) {
|
|
62
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
function invalid(what) {
|
|
65
|
+
return new HiveError('invalid_registry_response', `The registry returned an unexpected ${what}`, { status: 502 });
|
|
66
|
+
}
|
|
67
|
+
function checkProduct(value) {
|
|
68
|
+
if (!isRecord(value) || !isProductId(value['id']) || typeof value['displayName'] !== 'string' || typeof value['installed'] !== 'boolean'
|
|
69
|
+
|| !Array.isArray(value['dependencies']) || !isRecord(value['links']))
|
|
70
|
+
throw invalid('product');
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function checkConnection(value) {
|
|
74
|
+
if (!isRecord(value) || !isProductId(value['id']) || !isRecord(value['nativeApis'])
|
|
75
|
+
|| (value['internalEndpoints'] !== undefined && !isRecord(value['internalEndpoints'])))
|
|
76
|
+
throw invalid('connection');
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
export function createRegistryClient(options) {
|
|
80
|
+
const base = new URL(options.baseUrl);
|
|
81
|
+
if (base.username || base.password || base.search || base.hash)
|
|
82
|
+
throw new TypeError('Registry baseUrl must be a plain URL');
|
|
83
|
+
if (base.protocol !== 'https:' && !(base.protocol === 'http:' && (options.allowHttp || isLoopbackHost(base.hostname)))) {
|
|
84
|
+
throw new TypeError('Registry baseUrl must use https, or set allowHttp for the private network');
|
|
85
|
+
}
|
|
86
|
+
if (options.token !== undefined && !/^[^\s,]+$/.test(options.token))
|
|
87
|
+
throw new TypeError('Registry token must be a single bearer credential');
|
|
88
|
+
const prefix = base.href.replace(/\/+$/, '');
|
|
89
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
90
|
+
const request = options.fetch
|
|
91
|
+
?? (options.ca ? createCaFetch({ ca: options.ca }) : (input, init) => fetch(input, init));
|
|
92
|
+
async function get(path, notFoundIsUndefined = false) {
|
|
93
|
+
const response = await request(`${prefix}${path}`, {
|
|
94
|
+
method: 'GET',
|
|
95
|
+
headers: {
|
|
96
|
+
accept: 'application/json',
|
|
97
|
+
...options.headers?.(),
|
|
98
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
99
|
+
},
|
|
100
|
+
redirect: 'error',
|
|
101
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
102
|
+
});
|
|
103
|
+
if (notFoundIsUndefined && response.status === 404) {
|
|
104
|
+
void response.body?.cancel().catch(() => undefined);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
throw await parseHiveErrorResponse(response);
|
|
109
|
+
try {
|
|
110
|
+
return await response.json();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw invalid('body');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const productPath = (id) => {
|
|
117
|
+
if (!isProductId(id))
|
|
118
|
+
throw new HiveError('invalid_product_id', 'Product IDs are lowercase letters, digits and dashes', { status: 400 });
|
|
119
|
+
return `/v1/products/${id}`;
|
|
120
|
+
};
|
|
121
|
+
const trustFor = (id) => {
|
|
122
|
+
const trust = typeof options.trust === 'function' ? options.trust(id) : options.trust?.[id];
|
|
123
|
+
return trust ?? {};
|
|
124
|
+
};
|
|
125
|
+
const client = {
|
|
126
|
+
async installation() {
|
|
127
|
+
const body = await get('/v1/installation');
|
|
128
|
+
if (!isRecord(body) || body['schema'] !== 1 || typeof body['id'] !== 'string' || !Array.isArray(body['products']))
|
|
129
|
+
throw invalid('installation');
|
|
130
|
+
return body;
|
|
131
|
+
},
|
|
132
|
+
async products(filter = {}) {
|
|
133
|
+
const query = filter.installed === undefined ? '' : `?installed=${filter.installed}`;
|
|
134
|
+
const body = await get(`/v1/products${query}`);
|
|
135
|
+
if (!isRecord(body) || body['schema'] !== 1 || !Array.isArray(body['products']))
|
|
136
|
+
throw invalid('product list');
|
|
137
|
+
return body['products'].map(checkProduct);
|
|
138
|
+
},
|
|
139
|
+
installedProducts: () => client.products({ installed: true }),
|
|
140
|
+
async product(id) {
|
|
141
|
+
const body = await get(productPath(id), true);
|
|
142
|
+
return body === undefined ? undefined : checkProduct(body);
|
|
143
|
+
},
|
|
144
|
+
async connection(id) {
|
|
145
|
+
const body = await get(`${productPath(id)}/connection`, true);
|
|
146
|
+
return body === undefined ? undefined : checkConnection(body);
|
|
147
|
+
},
|
|
148
|
+
async endpoint(id, lookup = {}) {
|
|
149
|
+
const connection = await client.connection(id);
|
|
150
|
+
if (!connection)
|
|
151
|
+
return undefined;
|
|
152
|
+
const sources = [
|
|
153
|
+
[true, connection.internalEndpoints ?? {}],
|
|
154
|
+
[false, connection.nativeApis],
|
|
155
|
+
];
|
|
156
|
+
if (lookup.prefer === 'native')
|
|
157
|
+
sources.reverse();
|
|
158
|
+
for (const [internal, endpoints] of sources) {
|
|
159
|
+
const names = Object.keys(endpoints);
|
|
160
|
+
const name = lookup.name ?? (names.length === 1 ? names[0] : names.includes(id) ? id : undefined);
|
|
161
|
+
const url = name === undefined ? undefined : endpoints[name];
|
|
162
|
+
if (name !== undefined && url !== undefined)
|
|
163
|
+
return { productId: id, name, url, internal, trust: trustFor(id) };
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
},
|
|
167
|
+
async ready() {
|
|
168
|
+
const body = await get('/health/ready');
|
|
169
|
+
if (!isRecord(body) || typeof body['status'] !== 'string')
|
|
170
|
+
throw invalid('readiness');
|
|
171
|
+
return { status: body['status'], readOnly: body['readOnly'] === true };
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
return client;
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,128 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/hive-common",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Shared server kit for the Hive:Magnus suite: Oathweave token verification, error envelope, health, request context, registry client, logging, events and framework adapters",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"hive",
|
|
7
|
+
"magnus",
|
|
8
|
+
"oathweave",
|
|
9
|
+
"jwt",
|
|
10
|
+
"jwks",
|
|
11
|
+
"error-envelope",
|
|
12
|
+
"health",
|
|
13
|
+
"traceparent",
|
|
14
|
+
"hono"
|
|
15
|
+
],
|
|
16
|
+
"author": "ChefTaco",
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ChefTaco/HiveCommon.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/ChefTaco/HiveCommon#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/ChefTaco/HiveCommon/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
"./oathweave": {
|
|
30
|
+
"types": "./dist/oathweave/index.d.ts",
|
|
31
|
+
"default": "./dist/oathweave/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./oathweave/conformance": {
|
|
34
|
+
"types": "./dist/oathweave/conformance.d.ts",
|
|
35
|
+
"default": "./dist/oathweave/conformance.js"
|
|
36
|
+
},
|
|
37
|
+
"./errors": {
|
|
38
|
+
"types": "./dist/errors/index.d.ts",
|
|
39
|
+
"default": "./dist/errors/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./health": {
|
|
42
|
+
"types": "./dist/health/index.d.ts",
|
|
43
|
+
"default": "./dist/health/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./context": {
|
|
46
|
+
"types": "./dist/context/index.d.ts",
|
|
47
|
+
"default": "./dist/context/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./registry": {
|
|
50
|
+
"types": "./dist/registry/index.d.ts",
|
|
51
|
+
"default": "./dist/registry/index.js"
|
|
52
|
+
},
|
|
53
|
+
"./log": {
|
|
54
|
+
"types": "./dist/log/index.d.ts",
|
|
55
|
+
"default": "./dist/log/index.js"
|
|
56
|
+
},
|
|
57
|
+
"./events": {
|
|
58
|
+
"types": "./dist/events/index.d.ts",
|
|
59
|
+
"default": "./dist/events/index.js"
|
|
60
|
+
},
|
|
61
|
+
"./contracts": {
|
|
62
|
+
"types": "./dist/contracts/index.d.ts",
|
|
63
|
+
"default": "./dist/contracts/index.js"
|
|
64
|
+
},
|
|
65
|
+
"./hono": {
|
|
66
|
+
"types": "./dist/hono/index.d.ts",
|
|
67
|
+
"default": "./dist/hono/index.js"
|
|
68
|
+
},
|
|
69
|
+
"./node-http": {
|
|
70
|
+
"types": "./dist/node-http/index.d.ts",
|
|
71
|
+
"default": "./dist/node-http/index.js"
|
|
72
|
+
},
|
|
73
|
+
"./test-vectors/*": "./test-vectors/*",
|
|
74
|
+
"./package.json": "./package.json"
|
|
75
|
+
},
|
|
76
|
+
"engines": {
|
|
77
|
+
"node": "^24.22.3 || ^26.13.1"
|
|
78
|
+
},
|
|
79
|
+
"publishConfig": {
|
|
80
|
+
"access": "public"
|
|
81
|
+
},
|
|
82
|
+
"files": [
|
|
83
|
+
"dist",
|
|
84
|
+
"test-vectors",
|
|
85
|
+
"README.md",
|
|
86
|
+
"LICENSE",
|
|
87
|
+
"NOTICE",
|
|
88
|
+
"CHANGELOG.md"
|
|
89
|
+
],
|
|
90
|
+
"scripts": {
|
|
91
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
92
|
+
"typecheck": "tsc --noEmit",
|
|
93
|
+
"test": "vitest run",
|
|
94
|
+
"test:coverage": "vitest run --coverage",
|
|
95
|
+
"lint": "eslint src test scripts",
|
|
96
|
+
"vectors:generate": "node scripts/generate-test-vectors.mjs",
|
|
97
|
+
"pack:check": "node scripts/check-package.mjs",
|
|
98
|
+
"scan:secrets": "bash scripts/scan-secrets.sh",
|
|
99
|
+
"check:private": "bash scripts/check-private-data.sh",
|
|
100
|
+
"privacy:test": "node --test scripts/test-privacy-policy.mjs scripts/test-publish-environment.mjs scripts/test-publish-package.mjs",
|
|
101
|
+
"hooks:install": "git config core.hooksPath .githooks",
|
|
102
|
+
"verify": "npm run privacy:test && npm run scan:secrets && npm run check:private && npm run typecheck && npm run lint && npm test && npm run build && npm run pack:check"
|
|
103
|
+
},
|
|
104
|
+
"dependencies": {
|
|
105
|
+
"jose": "^6.2.15"
|
|
106
|
+
},
|
|
107
|
+
"peerDependencies": {
|
|
108
|
+
"hono": "^4.13.18"
|
|
109
|
+
},
|
|
110
|
+
"peerDependenciesMeta": {
|
|
111
|
+
"hono": {
|
|
112
|
+
"optional": true
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"devDependencies": {
|
|
116
|
+
"@types/node": "^24.19.2",
|
|
117
|
+
"@vitest/coverage-v8": "^5.1.0",
|
|
118
|
+
"eslint": "10.14.0",
|
|
119
|
+
"hono": "^4.13.18",
|
|
120
|
+
"typescript": "^6.0.3",
|
|
121
|
+
"typescript-eslint": "8.72.1",
|
|
122
|
+
"vitest": "^5.0.5",
|
|
123
|
+
"yaml": "2.9.1"
|
|
124
|
+
},
|
|
125
|
+
"overrides": {
|
|
126
|
+
"minimatch": "11.0.5"
|
|
127
|
+
}
|
|
128
|
+
}
|