@hivemagnus/hive-common 0.1.0 → 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 CHANGED
@@ -6,6 +6,29 @@ All notable changes to `@hivemagnus/hive-common` are recorded here. The format f
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-11-12
10
+
11
+ Options that let a product keep its existing behaviour without wrapping the shared helpers. Every
12
+ change is additive: the defaults are those of 0.1.0.
13
+
14
+ ### Added
15
+
16
+ - `/oathweave`: `requireRoles` (default `false`) refuses a token without a `roles` array.
17
+ - `/oathweave`: `clientIdClaim: 'clientId' | 'client_id' | 'either'` (default `'either'`) chooses
18
+ which claim names supply the client ID. Both claims present must still agree. `clientIdOf()`,
19
+ `principalKindOf()` and `extractRoles()` take matching optional arguments, and `ClaimPolicy`
20
+ matching optional fields. `CLIENT_ID_CLAIMS` and the `ClientIdClaim` type are exported.
21
+ - `/oathweave`: `parseIdentityLinks()` accepts `localId` as an alias of `localUserId`. An entry
22
+ that sets both to different values is refused. Parsed links always use `localUserId`.
23
+ - `/context`: `trustInboundRequestId` and `trustInboundTrace` trust the inbound request ID and the
24
+ inbound `traceparent`/`tracestate` separately. Each defaults to `trustInbound`. The `/hono` and
25
+ `/node-http` adapters take them too.
26
+ - `/health`: `liveBody` replaces the `/health/live` body with a product's own, as an object or a
27
+ function of the standard body. `liveness()`, `createHealth()`, `Health`, `registerHealthRoutes()`
28
+ and `hiveHandler({ health })` accept it; `HealthOptions` and `Health` take an optional type
29
+ parameter for the body, defaulting to `LiveBody`.
30
+ - README: "Choosing settings for an existing product".
31
+
9
32
  ## [0.1.0] - 2026-11-12
10
33
 
11
34
  First release: the shared server kit that replaces each product's own copy of the Oathweave
@@ -37,5 +60,6 @@ verifier and its own error, health and request-ID conventions.
37
60
  pagination and agent identity.
38
61
  - `/hono` and `/node-http` adapters for auth, request context, the error envelope and health.
39
62
 
40
- [Unreleased]: https://github.com/ChefTaco/HiveCommon/compare/v0.1.0...HEAD
63
+ [Unreleased]: https://github.com/ChefTaco/HiveCommon/compare/v0.2.0...HEAD
64
+ [0.2.0]: https://github.com/ChefTaco/HiveCommon/compare/v0.1.0...v0.2.0
41
65
  [0.1.0]: https://github.com/ChefTaco/HiveCommon/releases/tag/v0.1.0
package/README.md CHANGED
@@ -104,7 +104,8 @@ refetched, at most every 30 s, when a token names an unknown `kid`); `iss` is on
104
104
 
105
105
  - **User:** `actorType: "USER"`, a `sessionId`, a `clientId` other than the subject, string-array
106
106
  `permissions` and (optional) `roles`. With `userinfoUrl`, the session is confirmed online; the
107
- answer is shared for 10 s from when the request started (failures for 2 s).
107
+ answer is shared for 10 s from when the request started (failures for 2 s), or not at all with
108
+ `sessionCacheTtlMs: 0`.
108
109
  - **Service:** `clientId` (or `client_id`) equals `sub`, `actorType: "SERVICE_ACCOUNT"`,
109
110
  `token_type: "access"`, no session.
110
111
 
@@ -129,6 +130,18 @@ const watch = watchStreamAuthorization({
129
130
  });
130
131
  ```
131
132
 
133
+ Two options make the claim rules stricter for products that always required more:
134
+
135
+ - `requireRoles: true` refuses a token without a `roles` array. The default (`false`) treats an
136
+ absent `roles` claim as no roles. A `roles` claim that is not a string array is always refused.
137
+ - `clientIdClaim` chooses which claim names supply the client ID: `'clientId'` (Oathweave),
138
+ `'client_id'` (RFC 9068) or `'either'` (the default). When both claims are present they must
139
+ agree in every mode.
140
+
141
+ Identity links name the local account `localUserId`. `localId`, the name older deployment configs
142
+ use, is accepted as an alias; an entry that sets both to different values is refused. Parsed links
143
+ always carry `localUserId`.
144
+
132
145
  Endpoints must be `https`. For tests against a loopback server, pass `allowInsecureLoopback: true`.
133
146
 
134
147
  ### Conformance vectors
@@ -205,6 +218,16 @@ A check is down when it throws, returns `false` or `{ ok: false }`, or exceeds i
205
218
  `signal` aborts). Error text is hidden unless `exposeErrors: true`. Serve legacy aliases only
206
219
  while old clients still call them.
207
220
 
221
+ A product that has published its own liveness body can keep it with `liveBody`: an object served
222
+ as is, or a function that receives the standard body on every request. Status, headers and
223
+ readiness are unchanged, and liveness still never runs checks. `liveness()`, `createHealth()`,
224
+ `registerHealthRoutes()` and `hiveHandler({ health })` all take it.
225
+
226
+ ```ts
227
+ createHealth({ service: 'studio', liveBody: { status: 'ok' } });
228
+ createHealth({ service: 'hive-skills', liveBody: standard => ({ status: 'ok', service: standard.service, protocol: 1 }) });
229
+ ```
230
+
208
231
  ### context
209
232
 
210
233
  ```ts
@@ -221,6 +244,12 @@ Also `parseTraceparent()`, `formatTraceparent()`, `generateTraceparent()`, `chil
221
244
  `readRequestId()`, `requireIdempotencyKey()` (428 when missing). On an edge service exposed to the
222
245
  internet, pass `trustInbound: false` so callers cannot choose IDs.
223
246
 
247
+ `trustInboundRequestId` and `trustInboundTrace` decide the request ID and the trace separately; each
248
+ defaults to `trustInbound` (itself `true`). An internet-facing service that should still join its
249
+ callers' traces passes `{ trustInboundRequestId: false, trustInboundTrace: true }`. The `/hono`
250
+ `requestContext()` middleware and the `/node-http` `hiveHandler()` and `requestContextFor()` take
251
+ the same options.
252
+
224
253
  ### registry
225
254
 
226
255
  ```ts
@@ -366,6 +395,34 @@ Behaviour that changes when a product adopts the shared verifier:
366
395
  - Duplicate identity links fail at startup by default (`duplicates: 'ignore'` keeps the old
367
396
  Magnus behaviour of granting nothing to that identity).
368
397
 
398
+ ## Choosing settings for an existing product
399
+
400
+ The defaults suit a new service. A product moving from its own code to HiveCommon can keep its
401
+ existing behaviour with these settings instead of wrapping the shared helpers:
402
+
403
+ | Setting | Default | Set it to keep |
404
+ | --- | --- | --- |
405
+ | `clockToleranceSec: 0` (verifier) | 30 | no clock skew allowance on `exp`, `nbf` and `iat` |
406
+ | `sessionCacheTtlMs: 0` (verifier) | 10 000 | an online session check on every request, so sign-out is immediate |
407
+ | `requireRoles: true` (verifier) | `false` | refusing tokens without a `roles` array |
408
+ | `clientIdClaim: 'clientId'` (verifier) | `'either'` | requiring the Oathweave `clientId` claim; RFC 9068 `client_id` alone is refused |
409
+ | `trustInboundRequestId: false` with `trustInboundTrace: true` (context, `/hono`, `/node-http`) | both `trustInbound` | generating every request ID on an internet-facing service while continuing callers' traces |
410
+ | `liveBody` (health, `/hono`, `/node-http`) | the standard body | a published `/health/live` body such as `{ status: 'ok' }` |
411
+ | `localId` in identity links | `localUserId` | existing link configs, unchanged |
412
+ | `duplicates: 'ignore'` (identity links) | `'reject'` | granting nothing to a duplicated identity instead of failing at startup |
413
+
414
+ ```ts
415
+ const verifier = createOathweaveVerifier({
416
+ issuer, audience: 'chattermost', jwksUrl, userinfoUrl,
417
+ clockToleranceSec: 0, sessionCacheTtlMs: 0, requireRoles: true, clientIdClaim: 'clientId',
418
+ });
419
+ app.use(requestContext({ trustInboundRequestId: false, trustInboundTrace: true }));
420
+ registerHealthRoutes(app, { service: 'chattermost', liveBody: { status: 'ok' } });
421
+ ```
422
+
423
+ Every refusal, including 503 `auth_unavailable`, is still an `OathweaveError`; a product that
424
+ answers 401 for all of them maps the error in its own handler.
425
+
369
426
  ## Development
370
427
 
371
428
  ```bash
@@ -66,9 +66,21 @@ export interface RequestContext {
66
66
  export interface CreateRequestContextOptions {
67
67
  /**
68
68
  * Accept the inbound request ID and trace context. Defaults to true; set false on an edge
69
- * service that must not let the public internet choose its IDs.
69
+ * service that must not let the public internet choose its IDs. The two settings below
70
+ * override it for one of the two.
70
71
  */
71
72
  readonly trustInbound?: boolean;
73
+ /**
74
+ * Accept an inbound `x-request-id` (or `x-hive-request-id`, `x-correlation-id`). Defaults to
75
+ * `trustInbound`. When false, every request gets a generated ID.
76
+ */
77
+ readonly trustInboundRequestId?: boolean;
78
+ /**
79
+ * Continue an inbound `traceparent` and `tracestate`. Defaults to `trustInbound`. When false,
80
+ * every request starts a new trace. An internet-facing service can set this true with
81
+ * `trustInboundRequestId: false` to join a caller's trace while choosing its own request IDs.
82
+ */
83
+ readonly trustInboundTrace?: boolean;
72
84
  readonly generateRequestId?: () => string;
73
85
  readonly now?: () => number;
74
86
  }
@@ -147,8 +147,10 @@ export function requireIdempotencyKey(headers) {
147
147
  }
148
148
  export function createRequestContext(headers, options = {}) {
149
149
  const trust = options.trustInbound ?? true;
150
- const requestId = (trust ? readRequestId(headers) : undefined) ?? (options.generateRequestId ?? generateRequestId)();
151
- const parentTrace = trust ? parseTraceparent(readHeader(headers, HIVE_HEADERS.traceparent)) : undefined;
150
+ const trustRequestId = options.trustInboundRequestId ?? trust;
151
+ const trustTrace = options.trustInboundTrace ?? trust;
152
+ const requestId = (trustRequestId ? readRequestId(headers) : undefined) ?? (options.generateRequestId ?? generateRequestId)();
153
+ const parentTrace = trustTrace ? parseTraceparent(readHeader(headers, HIVE_HEADERS.traceparent)) : undefined;
152
154
  const rawState = parentTrace ? readHeader(headers, HIVE_HEADERS.tracestate)?.trim() : undefined;
153
155
  // eslint-disable-next-line no-control-regex
154
156
  const tracestate = rawState && rawState.length <= 512 && !/[\u0000-\u001f\u007f]/.test(rawState) ? rawState : undefined;
@@ -51,7 +51,13 @@ export interface HealthResponse<B> {
51
51
  readonly body: B;
52
52
  readonly headers: Readonly<Record<string, string>>;
53
53
  }
54
- export interface HealthOptions {
54
+ /**
55
+ * A product's own liveness body, in place of the standard `LiveBody`: an object served as is,
56
+ * or a function called on every liveness request with the standard body.
57
+ */
58
+ export type LiveBodyOverride<L extends object> = L | ((standard: LiveBody) => L);
59
+ /** `L` is the liveness body type: `LiveBody` unless `liveBody` is set. */
60
+ export interface HealthOptions<L extends object = LiveBody> {
55
61
  readonly service?: string;
56
62
  readonly version?: string;
57
63
  readonly checks?: readonly HealthCheck[];
@@ -65,21 +71,31 @@ export interface HealthOptions {
65
71
  /** Also serve the legacy paths in `LEGACY_HEALTH_ALIASES`, or a custom alias map. */
66
72
  readonly aliases?: boolean | Partial<Readonly<Record<HealthKind, readonly string[]>>>;
67
73
  readonly now?: () => number;
74
+ /**
75
+ * Serve this body from `/health/live` instead of the standard one, for a product that keeps
76
+ * a published liveness body such as `{ status: 'ok' }`. The status (200) and headers are
77
+ * unchanged, and liveness still never runs checks. Readiness is not affected.
78
+ */
79
+ readonly liveBody?: LiveBodyOverride<L>;
68
80
  }
69
- /** The liveness response: 200 whenever the process can answer. It never runs checks. */
70
- export declare function liveness(options?: HealthOptions): HealthResponse<LiveBody>;
81
+ /**
82
+ * The liveness response: 200 whenever the process can answer. It never runs checks. The body
83
+ * is the standard `LiveBody`, or the product's own when `liveBody` is set.
84
+ */
85
+ export declare function liveness<L extends object = LiveBody>(options?: HealthOptions<L>): HealthResponse<L>;
71
86
  /** Run every check concurrently and build the readiness response: 503 if a critical check is down. */
72
- export declare function readiness(options?: HealthOptions): Promise<HealthResponse<ReadyBody>>;
87
+ export declare function readiness(options?: HealthOptions<object>): Promise<HealthResponse<ReadyBody>>;
73
88
  /** Which health endpoint a path names, including legacy aliases when enabled. */
74
89
  export declare function resolveHealthPath(pathname: string, aliases?: HealthOptions['aliases']): HealthKind | undefined;
75
90
  /** All paths served for each kind, standard first. */
76
91
  export declare function healthPaths(aliases?: HealthOptions['aliases']): Readonly<Record<HealthKind, readonly string[]>>;
77
- export interface Health {
78
- live(): HealthResponse<LiveBody>;
92
+ /** `L` is the liveness body type: `LiveBody` unless `liveBody` was set. */
93
+ export interface Health<L extends object = LiveBody> {
94
+ live(): HealthResponse<L>;
79
95
  ready(): Promise<HealthResponse<ReadyBody>>;
80
96
  /** The response for a health path, or undefined when the path is not a health path. */
81
- handle(pathname: string): Promise<HealthResponse<LiveBody | ReadyBody>> | undefined;
97
+ handle(pathname: string): Promise<HealthResponse<L | ReadyBody>> | undefined;
82
98
  readonly paths: Readonly<Record<HealthKind, readonly string[]>>;
83
99
  }
84
100
  /** Bind options once and serve both endpoints. */
85
- export declare function createHealth(options?: HealthOptions): Health;
101
+ export declare function createHealth<L extends object = LiveBody>(options?: HealthOptions<L>): Health<L>;
@@ -12,10 +12,22 @@ const HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-cont
12
12
  function identity(options) {
13
13
  return { ...(options.service ? { service: options.service } : {}), ...(options.version ? { version: options.version } : {}) };
14
14
  }
15
- /** The liveness response: 200 whenever the process can answer. It never runs checks. */
15
+ function assertLiveBody(value) {
16
+ if (value !== undefined && typeof value !== 'function' && (typeof value !== 'object' || value === null || Array.isArray(value))) {
17
+ throw new TypeError('liveBody must be an object or a function');
18
+ }
19
+ }
20
+ /**
21
+ * The liveness response: 200 whenever the process can answer. It never runs checks. The body
22
+ * is the standard `LiveBody`, or the product's own when `liveBody` is set.
23
+ */
16
24
  export function liveness(options = {}) {
25
+ assertLiveBody(options.liveBody);
17
26
  const now = options.now ?? Date.now;
18
- return { status: 200, body: { status: 'live', ...identity(options), time: new Date(now()).toISOString() }, headers: HEADERS };
27
+ const standard = { status: 'live', ...identity(options), time: new Date(now()).toISOString() };
28
+ const custom = options.liveBody;
29
+ const body = custom === undefined ? standard : typeof custom === 'function' ? custom(standard) : { ...custom };
30
+ return { status: 200, body: body, headers: HEADERS };
19
31
  }
20
32
  class CheckTimeout extends Error {
21
33
  }
@@ -97,6 +109,7 @@ function aliasMap(aliases) {
97
109
  }
98
110
  /** Bind options once and serve both endpoints. */
99
111
  export function createHealth(options = {}) {
112
+ assertLiveBody(options.liveBody);
100
113
  return {
101
114
  live: () => liveness(options),
102
115
  ready: () => readiness(options),
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { Context, Env, ErrorHandler, Hono, MiddlewareHandler, NotFoundHandler } from 'hono';
13
13
  import { type CreateRequestContextOptions, type RequestContext } from '../context/index.js';
14
- import { type HealthOptions } from '../health/index.js';
14
+ import { type HealthOptions, type LiveBody } from '../health/index.js';
15
15
  import type { Logger } from '../log/index.js';
16
16
  import type { AcceptPrincipal, OathweavePrincipal } from '../oathweave/claims.js';
17
17
  import { type PermissionOptions } from '../oathweave/links.js';
@@ -60,5 +60,8 @@ export interface ErrorHandlerOptions {
60
60
  export declare function errorHandler(options?: ErrorHandlerOptions): ErrorHandler;
61
61
  /** `app.notFound(notFoundHandler())`: a 404 envelope. */
62
62
  export declare function notFoundHandler(): NotFoundHandler;
63
- /** Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). */
64
- export declare function registerHealthRoutes<E extends Env>(app: Hono<E>, options?: HealthOptions): void;
63
+ /**
64
+ * Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). Set
65
+ * `liveBody` to keep a product's published liveness body.
66
+ */
67
+ export declare function registerHealthRoutes<E extends Env, L extends object = LiveBody>(app: Hono<E>, options?: HealthOptions<L>): void;
@@ -91,7 +91,10 @@ export function errorHandler(options = {}) {
91
91
  export function notFoundHandler() {
92
92
  return c => respond(c, HiveError.notFound(), {});
93
93
  }
94
- /** Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). */
94
+ /**
95
+ * Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). Set
96
+ * `liveBody` to keep a product's published liveness body.
97
+ */
95
98
  export function registerHealthRoutes(app, options = {}) {
96
99
  const health = createHealth(options);
97
100
  const serve = (kind) => async (c) => {
@@ -24,11 +24,14 @@ export interface SendErrorOptions {
24
24
  }
25
25
  /** Send any thrown value as the error envelope. Non-HiveErrors become a logged, generic 500. */
26
26
  export declare function sendError(res: ServerResponse, error: unknown, options?: SendErrorOptions): void;
27
- /** Serve a health path. Returns true when `req` was a health request and has been answered. */
28
- export declare function handleHealth(req: IncomingMessage, res: ServerResponse, health: Health): Promise<boolean>;
27
+ /**
28
+ * Serve a health path. Returns true when `req` was a health request and has been answered.
29
+ * The liveness body is the one `health` was created with (see `liveBody`).
30
+ */
31
+ export declare function handleHealth(req: IncomingMessage, res: ServerResponse, health: Health<object>): Promise<boolean>;
29
32
  export interface HiveHandlerOptions extends CreateRequestContextOptions {
30
33
  /** Serve health endpoints before `handler` runs. */
31
- readonly health?: HealthOptions | Health;
34
+ readonly health?: HealthOptions<object> | Health<object>;
32
35
  readonly logger?: Pick<Logger, 'error'>;
33
36
  /** Echo `x-request-id` on every response. Defaults to true. */
34
37
  readonly responseHeader?: boolean;
@@ -46,7 +46,10 @@ export function sendError(res, error, options = {}) {
46
46
  });
47
47
  sendJson(res, response.status, response.body, response.headers);
48
48
  }
49
- /** Serve a health path. Returns true when `req` was a health request and has been answered. */
49
+ /**
50
+ * Serve a health path. Returns true when `req` was a health request and has been answered.
51
+ * The liveness body is the one `health` was created with (see `liveBody`).
52
+ */
50
53
  export async function handleHealth(req, res, health) {
51
54
  if (req.method !== 'GET' && req.method !== 'HEAD')
52
55
  return false;
@@ -38,18 +38,34 @@ export interface OathweaveServicePrincipal extends PrincipalBase {
38
38
  readonly kind: 'service';
39
39
  }
40
40
  export type OathweavePrincipal = OathweaveUserPrincipal | OathweaveServicePrincipal;
41
- /** The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree. */
42
- export declare function clientIdOf(payload: JWTPayload): string | undefined;
41
+ /**
42
+ * Which claim names carry the client ID: Oathweave's `clientId`, RFC 9068 `client_id`, or
43
+ * `either` (the default).
44
+ */
45
+ export type ClientIdClaim = 'clientId' | 'client_id' | 'either';
46
+ export declare const CLIENT_ID_CLAIMS: readonly ClientIdClaim[];
47
+ /**
48
+ * The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree,
49
+ * whichever name is accepted. With `clientId` or `client_id`, only that claim supplies the value.
50
+ */
51
+ export declare function clientIdOf(payload: JWTPayload, claim?: ClientIdClaim): string | undefined;
43
52
  /** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
44
53
  export declare function extractScopes(payload: JWTPayload): string[];
45
- /** Roles; an absent claim is no roles. A present claim that is not a string array is invalid. */
46
- export declare function extractRoles(payload: JWTPayload): string[] | undefined;
54
+ /**
55
+ * Roles; an absent claim is no roles, or invalid when `required` is true. A present claim that
56
+ * is not a string array is invalid.
57
+ */
58
+ export declare function extractRoles(payload: JWTPayload, required?: boolean): string[] | undefined;
47
59
  /** A service token is one whose client ID equals its subject. */
48
- export declare function principalKindOf(payload: JWTPayload): PrincipalKind;
60
+ export declare function principalKindOf(payload: JWTPayload, claim?: ClientIdClaim): PrincipalKind;
49
61
  export interface ClaimPolicy {
50
62
  readonly accept: AcceptPrincipal;
51
63
  /** Largest allowed `exp - iat`, in seconds. */
52
64
  readonly maxLifetimeSec: number;
65
+ /** Refuse a token without a `roles` claim. Defaults to false: an absent claim is no roles. */
66
+ readonly requireRoles?: boolean;
67
+ /** Which claim names supply the client ID. Defaults to `either`. */
68
+ readonly clientIdClaim?: ClientIdClaim;
53
69
  }
54
70
  /**
55
71
  * Check the Oathweave claim rules on a payload whose signature, issuer, audience and time
@@ -5,13 +5,17 @@ function stringArray(value) {
5
5
  function nonEmpty(value) {
6
6
  return typeof value === 'string' && value.length > 0;
7
7
  }
8
- /** The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree. */
9
- export function clientIdOf(payload) {
8
+ export const CLIENT_ID_CLAIMS = ['clientId', 'client_id', 'either'];
9
+ /**
10
+ * The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree,
11
+ * whichever name is accepted. With `clientId` or `client_id`, only that claim supplies the value.
12
+ */
13
+ export function clientIdOf(payload, claim = 'either') {
10
14
  const camel = payload['clientId'];
11
15
  const snake = payload['client_id'];
12
16
  if (camel !== undefined && snake !== undefined && camel !== snake)
13
17
  return undefined;
14
- const value = camel ?? snake;
18
+ const value = claim === 'clientId' ? camel : claim === 'client_id' ? snake : camel ?? snake;
15
19
  return typeof value === 'string' ? value : undefined;
16
20
  }
17
21
  /** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
@@ -20,16 +24,19 @@ export function extractScopes(payload) {
20
24
  const list = typeof raw === 'string' ? raw.split(/\s+/) : stringArray(raw) ? raw : [];
21
25
  return [...new Set(list.map(item => item.trim()).filter(Boolean))];
22
26
  }
23
- /** Roles; an absent claim is no roles. A present claim that is not a string array is invalid. */
24
- export function extractRoles(payload) {
27
+ /**
28
+ * Roles; an absent claim is no roles, or invalid when `required` is true. A present claim that
29
+ * is not a string array is invalid.
30
+ */
31
+ export function extractRoles(payload, required = false) {
25
32
  const roles = payload['roles'];
26
33
  if (roles === undefined)
27
- return [];
34
+ return required ? undefined : [];
28
35
  return stringArray(roles) ? [...roles] : undefined;
29
36
  }
30
37
  /** A service token is one whose client ID equals its subject. */
31
- export function principalKindOf(payload) {
32
- const clientId = clientIdOf(payload);
38
+ export function principalKindOf(payload, claim = 'either') {
39
+ const clientId = clientIdOf(payload, claim);
33
40
  return clientId !== undefined && clientId === payload.sub ? 'service' : 'user';
34
41
  }
35
42
  function validAuthTime(value) {
@@ -51,11 +58,12 @@ export function principalFromClaims(payload, policy) {
51
58
  if (!nonEmpty(sub) || !nonEmpty(jti) || !nonEmpty(iss))
52
59
  throw new OathweaveError('invalid_claims');
53
60
  const permissions = payload['permissions'];
54
- const roles = extractRoles(payload);
55
- const clientId = clientIdOf(payload);
61
+ const clientIdClaim = policy.clientIdClaim ?? 'either';
62
+ const roles = extractRoles(payload, policy.requireRoles ?? false);
63
+ const clientId = clientIdOf(payload, clientIdClaim);
56
64
  if (!stringArray(permissions) || roles === undefined || clientId === undefined)
57
65
  throw new OathweaveError('invalid_claims');
58
- const kind = principalKindOf(payload);
66
+ const kind = principalKindOf(payload, clientIdClaim);
59
67
  if (policy.accept !== 'any' && policy.accept !== kind)
60
68
  throw new OathweaveError('wrong_principal_kind', { details: { kind } });
61
69
  const base = {
@@ -3,7 +3,7 @@
3
3
  * Framework-free; see `/hono` and `/node-http` for middleware.
4
4
  */
5
5
  export { createOathweaveVerifier, parseBearerToken, OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC, OATHWEAVE_FAILED_CHECK_TTL_MS, OATHWEAVE_MAX_LIFETIME_SEC, OATHWEAVE_MAX_TOKEN_LENGTH, OATHWEAVE_REQUEST_TIMEOUT_MS, OATHWEAVE_SESSION_CACHE_MAX, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, type OathweaveVerifier, type OathweaveVerifierOptions, type VerifyOptions, } from './verifier.js';
6
- export { clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, type AcceptPrincipal, type ClaimPolicy, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal, type PrincipalKind, } from './claims.js';
6
+ export { CLIENT_ID_CLAIMS, clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, type AcceptPrincipal, type ClaimPolicy, type ClientIdClaim, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal, type PrincipalKind, } from './claims.js';
7
7
  export { OathweaveError, isOathweaveError, statusForReason, type OathweaveFailureReason } from './errors.js';
8
8
  export { assertOathweaveEndpoint, endpointProblem, isLoopbackHost, isOathweaveEndpoint, type EndpointOptions } from './endpoint.js';
9
9
  export { effectivePermissions, hasAnyRole, hasPermissions, hasScopes, IdentityLinkError, parseIdentityLinks, permissionMatches, requireIdentityLink, requirePermissions, uniqueIdentityLink, type OathweaveIdentityLink, type ParseIdentityLinksOptions, type PermissionOptions, } from './links.js';
@@ -3,7 +3,7 @@
3
3
  * Framework-free; see `/hono` and `/node-http` for middleware.
4
4
  */
5
5
  export { createOathweaveVerifier, parseBearerToken, OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC, OATHWEAVE_FAILED_CHECK_TTL_MS, OATHWEAVE_MAX_LIFETIME_SEC, OATHWEAVE_MAX_TOKEN_LENGTH, OATHWEAVE_REQUEST_TIMEOUT_MS, OATHWEAVE_SESSION_CACHE_MAX, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, } from './verifier.js';
6
- export { clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, } from './claims.js';
6
+ export { CLIENT_ID_CLAIMS, clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, } from './claims.js';
7
7
  export { OathweaveError, isOathweaveError, statusForReason } from './errors.js';
8
8
  export { assertOathweaveEndpoint, endpointProblem, isLoopbackHost, isOathweaveEndpoint } from './endpoint.js';
9
9
  export { effectivePermissions, hasAnyRole, hasPermissions, hasScopes, IdentityLinkError, parseIdentityLinks, permissionMatches, requireIdentityLink, requirePermissions, uniqueIdentityLink, } from './links.js';
@@ -33,6 +33,8 @@ export interface ParseIdentityLinksOptions {
33
33
  }
34
34
  /**
35
35
  * Parse a JSON array of links, for example from `<PRODUCT>_OATHWEAVE_IDENTITIES_JSON`.
36
+ * An entry may name its local account `localUserId` or, as older configs do, `localId`; an
37
+ * entry that sets both to different values is refused. Parsed links always use `localUserId`.
36
38
  * Throws an `IdentityLinkError` naming the first problem; the raw value is never echoed.
37
39
  */
38
40
  export declare function parseIdentityLinks(raw: string | unknown, options?: ParseIdentityLinksOptions): OathweaveIdentityLink[];
@@ -2,7 +2,8 @@ import { OathweaveError } from './errors.js';
2
2
  export class IdentityLinkError extends Error {
3
3
  name = 'IdentityLinkError';
4
4
  }
5
- const LINK_FIELDS = new Set(['issuer', 'subject', 'localUserId', 'role', 'displayName', 'permissions']);
5
+ /** `localId` is accepted as an alias of `localUserId`, the name older deployment configs use. */
6
+ const LINK_FIELDS = new Set(['issuer', 'subject', 'localUserId', 'localId', 'role', 'displayName', 'permissions']);
6
7
  function isHttpsUrl(value) {
7
8
  if (typeof value !== 'string')
8
9
  return false;
@@ -15,6 +16,8 @@ function isHttpsUrl(value) {
15
16
  }
16
17
  /**
17
18
  * Parse a JSON array of links, for example from `<PRODUCT>_OATHWEAVE_IDENTITIES_JSON`.
19
+ * An entry may name its local account `localUserId` or, as older configs do, `localId`; an
20
+ * entry that sets both to different values is refused. Parsed links always use `localUserId`.
18
21
  * Throws an `IdentityLinkError` naming the first problem; the raw value is never echoed.
19
22
  */
20
23
  export function parseIdentityLinks(raw, options = {}) {
@@ -40,7 +43,11 @@ export function parseIdentityLinks(raw, options = {}) {
40
43
  for (const key of Object.keys(link))
41
44
  if (!LINK_FIELDS.has(key))
42
45
  throw new IdentityLinkError(`${where} has unknown field ${key.slice(0, 40)}`);
43
- const { issuer, subject, localUserId, role, displayName, permissions } = link;
46
+ const { issuer, subject, localId, role, displayName, permissions } = link;
47
+ if (link['localUserId'] !== undefined && localId !== undefined && link['localUserId'] !== localId) {
48
+ throw new IdentityLinkError(`${where}: localId and localUserId disagree`);
49
+ }
50
+ const localUserId = link['localUserId'] ?? localId;
44
51
  if (!isHttpsUrl(issuer))
45
52
  throw new IdentityLinkError(`${where}: issuer must be an https URL`);
46
53
  if (typeof subject !== 'string' || !subject || subject.length > 256)
@@ -1,5 +1,5 @@
1
1
  import { type JSONWebKeySet, type JWTVerifyGetKey } from 'jose';
2
- import { type AcceptPrincipal, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal } from './claims.js';
2
+ import { type AcceptPrincipal, type ClientIdClaim, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal } from './claims.js';
3
3
  import { type EndpointOptions } from './endpoint.js';
4
4
  /** How long a userinfo answer is reused, measured from when its request started. */
5
5
  export declare const OATHWEAVE_SESSION_CACHE_TTL_MS = 10000;
@@ -42,8 +42,22 @@ export interface OathweaveVerifierOptions extends EndpointOptions {
42
42
  readonly jwksCacheMaxAgeMs?: number;
43
43
  /** Minimum time between refetches triggered by an unknown `kid` (key rotation). Defaults to 30 s. */
44
44
  readonly jwksCooldownMs?: number;
45
+ /**
46
+ * How long a userinfo answer is reused, in milliseconds. Defaults to 10 000; 0 checks the
47
+ * session online on every call.
48
+ */
45
49
  readonly sessionCacheTtlMs?: number;
46
50
  readonly sessionCacheMax?: number;
51
+ /**
52
+ * Refuse a token without a `roles` array. Defaults to false, where an absent `roles` claim
53
+ * means no roles. A present claim that is not a string array is always refused.
54
+ */
55
+ readonly requireRoles?: boolean;
56
+ /**
57
+ * Which claim names supply the client ID: `clientId` (Oathweave), `client_id` (RFC 9068) or
58
+ * `either` (the default). When both claims are present they must agree in every mode.
59
+ */
60
+ readonly clientIdClaim?: ClientIdClaim;
47
61
  }
48
62
  export interface VerifyOptions {
49
63
  /** Override the verifier's default `accept` for this call. */
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
9
  import { createLocalJWKSet, createRemoteJWKSet, customFetch, decodeProtectedHeader, errors as joseErrors, jwtVerify, } from 'jose';
10
- import { principalFromClaims, } from './claims.js';
10
+ import { CLIENT_ID_CLAIMS, principalFromClaims, } from './claims.js';
11
11
  import { assertOathweaveEndpoint } from './endpoint.js';
12
12
  import { OathweaveError } from './errors.js';
13
13
  /** How long a userinfo answer is reused, measured from when its request started. */
@@ -110,6 +110,12 @@ export function createOathweaveVerifier(options) {
110
110
  throw new TypeError('clockToleranceSec must be 0-300');
111
111
  if (!(maxLifetimeSec > 0 && maxLifetimeSec <= 86_400))
112
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'");
113
119
  const defaultAccept = options.accept ?? 'user';
114
120
  const timeoutMs = options.requestTimeoutMs ?? OATHWEAVE_REQUEST_TIMEOUT_MS;
115
121
  const request = options.fetch ?? ((input, init) => fetch(input, init));
@@ -202,7 +208,12 @@ export function createOathweaveVerifier(options) {
202
208
  // jose accepts an audience array that contains ours; Oathweave issues exactly one audience.
203
209
  if (payload.aud !== options.audience)
204
210
  throw new OathweaveError('invalid_audience');
205
- const principal = principalFromClaims(payload, { accept: verifyOptions.accept ?? defaultAccept, maxLifetimeSec });
211
+ const principal = principalFromClaims(payload, {
212
+ accept: verifyOptions.accept ?? defaultAccept,
213
+ maxLifetimeSec,
214
+ requireRoles,
215
+ clientIdClaim,
216
+ });
206
217
  if (principal.kind === 'user' && options.userinfoUrl) {
207
218
  const displayName = await sessionCheck(token, principal.subject);
208
219
  return { ...principal, displayName, sessionChecked: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hivemagnus/hive-common",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "keywords": [
6
6
  "hive",