@heyatom/bastion-client 0.2.0 → 0.3.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.
Files changed (52) hide show
  1. package/README.md +31 -0
  2. package/dist/cjs/core/api-client.d.ts +98 -0
  3. package/dist/cjs/core/api-client.js +226 -0
  4. package/dist/cjs/core/api-types.d.ts +138 -0
  5. package/dist/cjs/core/api-types.js +10 -0
  6. package/dist/cjs/core/bastion-http.d.ts +13 -8
  7. package/dist/cjs/core/bastion-http.js +29 -17
  8. package/dist/cjs/core/errors.d.ts +23 -2
  9. package/dist/cjs/core/errors.js +45 -3
  10. package/dist/cjs/core/index.d.ts +4 -2
  11. package/dist/cjs/core/index.js +4 -1
  12. package/dist/cjs/core/jwks-verifier.js +2 -1
  13. package/dist/cjs/core/service-token-provider.d.ts +20 -12
  14. package/dist/cjs/core/service-token-provider.js +55 -27
  15. package/dist/cjs/nest/bastion-api-client.provider.d.ts +13 -0
  16. package/dist/cjs/nest/bastion-api-client.provider.js +43 -0
  17. package/dist/cjs/nest/bastion-http-exception.d.ts +19 -0
  18. package/dist/cjs/nest/bastion-http-exception.js +39 -0
  19. package/dist/cjs/nest/bastion.module.js +2 -0
  20. package/dist/cjs/nest/bastion.service.d.ts +5 -7
  21. package/dist/cjs/nest/bastion.service.js +5 -13
  22. package/dist/cjs/nest/index.d.ts +2 -0
  23. package/dist/cjs/nest/index.js +6 -1
  24. package/dist/cjs/nest/options.d.ts +2 -0
  25. package/dist/cjs/webhooks/index.d.ts +54 -0
  26. package/dist/cjs/webhooks/index.js +72 -0
  27. package/dist/esm/core/api-client.d.ts +98 -0
  28. package/dist/esm/core/api-client.js +222 -0
  29. package/dist/esm/core/api-types.d.ts +138 -0
  30. package/dist/esm/core/api-types.js +7 -0
  31. package/dist/esm/core/bastion-http.d.ts +13 -8
  32. package/dist/esm/core/bastion-http.js +30 -18
  33. package/dist/esm/core/errors.d.ts +23 -2
  34. package/dist/esm/core/errors.js +43 -2
  35. package/dist/esm/core/index.d.ts +4 -2
  36. package/dist/esm/core/index.js +2 -0
  37. package/dist/esm/core/jwks-verifier.js +2 -1
  38. package/dist/esm/core/service-token-provider.d.ts +20 -12
  39. package/dist/esm/core/service-token-provider.js +55 -27
  40. package/dist/esm/nest/bastion-api-client.provider.d.ts +13 -0
  41. package/dist/esm/nest/bastion-api-client.provider.js +40 -0
  42. package/dist/esm/nest/bastion-http-exception.d.ts +19 -0
  43. package/dist/esm/nest/bastion-http-exception.js +34 -0
  44. package/dist/esm/nest/bastion.module.js +2 -0
  45. package/dist/esm/nest/bastion.service.d.ts +5 -7
  46. package/dist/esm/nest/bastion.service.js +6 -14
  47. package/dist/esm/nest/index.d.ts +2 -0
  48. package/dist/esm/nest/index.js +2 -0
  49. package/dist/esm/nest/options.d.ts +2 -0
  50. package/dist/esm/webhooks/index.d.ts +54 -0
  51. package/dist/esm/webhooks/index.js +68 -0
  52. package/package.json +14 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BastionHttpError = exports.BastionTokenError = void 0;
3
+ exports.BastionHttpError = exports.BastionErrorCode = exports.BastionTokenError = void 0;
4
+ exports.classifyBastionError = classifyBastionError;
4
5
  /** Verification failure. `code` is safe to log; the message is safe to return to the caller. */
5
6
  class BastionTokenError extends Error {
6
7
  code;
@@ -11,13 +12,54 @@ class BastionTokenError extends Error {
11
12
  }
12
13
  }
13
14
  exports.BastionTokenError = BastionTokenError;
14
- /** A non-2xx answer from Bastion's HTTP API. */
15
+ /**
16
+ * Stable classification of a Bastion error answer, so an app can branch on a
17
+ * code instead of on Bastion's message text. The age-gate codes are Bastion's
18
+ * own (it returns them verbatim); the rest are derived from status + message
19
+ * until Bastion carries a `code` field on every error.
20
+ */
21
+ exports.BastionErrorCode = {
22
+ EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
23
+ INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
24
+ NO_APP_ACCESS: 'NO_APP_ACCESS',
25
+ ACCOUNT_INACTIVE: 'ACCOUNT_INACTIVE',
26
+ CONFLICT: 'CONFLICT',
27
+ BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
28
+ BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
29
+ UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
30
+ UNAVAILABLE: 'UNAVAILABLE',
31
+ UPSTREAM: 'UPSTREAM',
32
+ };
33
+ function classifyBastionError(status, message) {
34
+ if (message === exports.BastionErrorCode.BIRTH_DATE_REQUIRED ||
35
+ message === exports.BastionErrorCode.BIRTH_DATE_INVALID ||
36
+ message === exports.BastionErrorCode.UNDER_MINIMUM_AGE) {
37
+ return message;
38
+ }
39
+ if (status === 403 && /email not verified/i.test(message))
40
+ return exports.BastionErrorCode.EMAIL_NOT_VERIFIED;
41
+ if (status === 401) {
42
+ if (/no access to this app/i.test(message))
43
+ return exports.BastionErrorCode.NO_APP_ACCESS;
44
+ if (/inactive|locked/i.test(message))
45
+ return exports.BastionErrorCode.ACCOUNT_INACTIVE;
46
+ return exports.BastionErrorCode.INVALID_CREDENTIALS;
47
+ }
48
+ if (status === 409)
49
+ return exports.BastionErrorCode.CONFLICT;
50
+ if (status === 503)
51
+ return exports.BastionErrorCode.UNAVAILABLE;
52
+ return exports.BastionErrorCode.UPSTREAM;
53
+ }
54
+ /** A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`). */
15
55
  class BastionHttpError extends Error {
16
56
  status;
17
- constructor(status, message) {
57
+ code;
58
+ constructor(status, message, code) {
18
59
  super(message);
19
60
  this.status = status;
20
61
  this.name = 'BastionHttpError';
62
+ this.code = code ?? classifyBastionError(status, message);
21
63
  }
22
64
  }
23
65
  exports.BastionHttpError = BastionHttpError;
@@ -1,5 +1,7 @@
1
1
  export * from './types.js';
2
+ export * from './api-types.js';
2
3
  export * from './errors.js';
3
4
  export { JwksVerifier, type JwksVerifierOptions } from './jwks-verifier.js';
4
- export { BastionHttp, type BastionHttpOptions, type ClientAuthInput } from './bastion-http.js';
5
- export { ServiceTokenProvider } from './service-token-provider.js';
5
+ export { BastionHttp, type BastionHttpOptions, type BastionRequestInit, type ClientAuthInput } from './bastion-http.js';
6
+ export { ServiceTokenProvider, type CredentialResolver, type ServiceTokenCredentials } from './service-token-provider.js';
7
+ export { BastionApiClient, type BastionApiClientOptions } from './api-client.js';
@@ -14,8 +14,9 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.ServiceTokenProvider = exports.BastionHttp = exports.JwksVerifier = void 0;
17
+ exports.BastionApiClient = exports.ServiceTokenProvider = exports.BastionHttp = exports.JwksVerifier = void 0;
18
18
  __exportStar(require("./types.js"), exports);
19
+ __exportStar(require("./api-types.js"), exports);
19
20
  __exportStar(require("./errors.js"), exports);
20
21
  var jwks_verifier_js_1 = require("./jwks-verifier.js");
21
22
  Object.defineProperty(exports, "JwksVerifier", { enumerable: true, get: function () { return jwks_verifier_js_1.JwksVerifier; } });
@@ -23,3 +24,5 @@ var bastion_http_js_1 = require("./bastion-http.js");
23
24
  Object.defineProperty(exports, "BastionHttp", { enumerable: true, get: function () { return bastion_http_js_1.BastionHttp; } });
24
25
  var service_token_provider_js_1 = require("./service-token-provider.js");
25
26
  Object.defineProperty(exports, "ServiceTokenProvider", { enumerable: true, get: function () { return service_token_provider_js_1.ServiceTokenProvider; } });
27
+ var api_client_js_1 = require("./api-client.js");
28
+ Object.defineProperty(exports, "BastionApiClient", { enumerable: true, get: function () { return api_client_js_1.BastionApiClient; } });
@@ -33,7 +33,8 @@ class JwksVerifier {
33
33
  this.ttlMs = options.ttlMs ?? 300_000;
34
34
  this.issuer = options.issuer === undefined ? 'bastion' : options.issuer;
35
35
  this.logger = options.logger ?? types_js_1.silentLogger;
36
- this.fetchImpl = options.fetch ?? fetch;
36
+ // Late-bound on purpose: a consumer (or its tests) may replace global fetch after construction.
37
+ this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
37
38
  }
38
39
  async verify(token) {
39
40
  let kid;
@@ -1,21 +1,29 @@
1
1
  import type { BastionHttp, ClientAuthInput } from './bastion-http.js';
2
2
  import { type BastionLogger } from './types.js';
3
3
  /**
4
- * Holds the service-client token for outbound calls and renews it ahead of
5
- * `exp`. Single-flight: a burst of first calls shares one `POST /auth/client`
6
- * instead of issuing one per caller — the previous per-service copies had no
7
- * such guard.
4
+ * Holds machine tokens for outbound calls and renews each ahead of `exp`.
5
+ * One credential (the common case) or several keyed by `serviceSlug`, for an
6
+ * app that calls more than one service with its own client for each. Per
7
+ * slug: single-flight (a burst of first calls shares one `POST /auth/client`)
8
+ * and stale-cache fallback (a mint that fails while the old token is still
9
+ * inside its `exp` keeps serving it).
8
10
  */
11
+ /** Credentials looked up per `serviceSlug` on demand — for an app whose targets are configured lazily. */
12
+ export interface CredentialResolver {
13
+ resolve: (serviceSlug: string) => ClientAuthInput | null | undefined;
14
+ defaultSlug: string;
15
+ }
16
+ export type ServiceTokenCredentials = ClientAuthInput | Record<string, ClientAuthInput> | CredentialResolver;
9
17
  export declare class ServiceTokenProvider {
10
18
  private readonly http;
11
- private readonly credentials;
12
19
  private readonly logger;
13
- private token;
14
- private expiresAt;
15
- private inFlight;
16
- /** Tenant the token was issued for — the tenant `POST /events` rows land in. */
17
- tenantId: string | null;
18
- constructor(http: BastionHttp, credentials: ClientAuthInput, logger?: BastionLogger);
19
- getToken(): Promise<string>;
20
+ private readonly resolveCredential;
21
+ private readonly defaultSlug;
22
+ private readonly cache;
23
+ private readonly inFlight;
24
+ constructor(http: BastionHttp, credentials: ServiceTokenCredentials, logger?: BastionLogger);
25
+ /** Tenant of the default credential's token, once minted — the tenant `POST /events` rows land in. */
26
+ get tenantId(): string | null;
27
+ getToken(serviceSlug?: string): Promise<string>;
20
28
  private refresh;
21
29
  }
@@ -5,42 +5,70 @@ const jose_1 = require("jose");
5
5
  const types_js_1 = require("./types.js");
6
6
  const REFRESH_MARGIN_MS = 5 * 60 * 1000;
7
7
  const FALLBACK_TTL_MS = 60 * 60 * 1000;
8
- /**
9
- * Holds the service-client token for outbound calls and renews it ahead of
10
- * `exp`. Single-flight: a burst of first calls shares one `POST /auth/client`
11
- * instead of issuing one per caller — the previous per-service copies had no
12
- * such guard.
13
- */
14
8
  class ServiceTokenProvider {
15
9
  http;
16
- credentials;
17
10
  logger;
18
- token = null;
19
- expiresAt = 0;
20
- inFlight = null;
21
- /** Tenant the token was issued for — the tenant `POST /events` rows land in. */
22
- tenantId = null;
11
+ resolveCredential;
12
+ defaultSlug;
13
+ cache = new Map();
14
+ inFlight = new Map();
23
15
  constructor(http, credentials, logger = types_js_1.silentLogger) {
24
16
  this.http = http;
25
- this.credentials = credentials;
26
17
  this.logger = logger;
27
- }
28
- getToken() {
29
- if (this.token && Date.now() < this.expiresAt - REFRESH_MARGIN_MS) {
30
- return Promise.resolve(this.token);
18
+ if ('resolve' in credentials && typeof credentials.resolve === 'function') {
19
+ const resolver = credentials;
20
+ this.resolveCredential = resolver.resolve;
21
+ this.defaultSlug = resolver.defaultSlug;
22
+ }
23
+ else if ('apiKey' in credentials && typeof credentials.apiKey === 'string') {
24
+ const single = credentials;
25
+ this.resolveCredential = (slug) => (slug === single.serviceSlug ? single : null);
26
+ this.defaultSlug = single.serviceSlug;
27
+ }
28
+ else {
29
+ const record = credentials;
30
+ const [first] = Object.keys(record);
31
+ if (!first)
32
+ throw new Error('ServiceTokenProvider: no credentials configured');
33
+ this.resolveCredential = (slug) => record[slug];
34
+ this.defaultSlug = first;
31
35
  }
32
- this.inFlight ??= this.refresh().finally(() => {
33
- this.inFlight = null;
34
- });
35
- return this.inFlight;
36
36
  }
37
- async refresh() {
38
- const { accessToken } = await this.http.clientAuth(this.credentials);
37
+ /** Tenant of the default credential's token, once minted — the tenant `POST /events` rows land in. */
38
+ get tenantId() {
39
+ return this.cache.get(this.defaultSlug)?.tenantId ?? null;
40
+ }
41
+ getToken(serviceSlug = this.defaultSlug) {
42
+ const cached = this.cache.get(serviceSlug);
43
+ if (cached && Date.now() < cached.expiresAt - REFRESH_MARGIN_MS)
44
+ return Promise.resolve(cached.token);
45
+ const running = this.inFlight.get(serviceSlug);
46
+ if (running)
47
+ return running;
48
+ const pending = this.refresh(serviceSlug)
49
+ .catch((err) => {
50
+ if (cached && Date.now() < cached.expiresAt) {
51
+ this.logger.warn(`bastion: token mint for ${serviceSlug} failed, serving stale cache`);
52
+ return cached.token;
53
+ }
54
+ throw err;
55
+ })
56
+ .finally(() => this.inFlight.delete(serviceSlug));
57
+ this.inFlight.set(serviceSlug, pending);
58
+ return pending;
59
+ }
60
+ async refresh(serviceSlug) {
61
+ const credential = this.resolveCredential(serviceSlug);
62
+ if (!credential?.apiKey)
63
+ throw new Error(`Bastion service client for "${serviceSlug}" not configured`);
64
+ const { accessToken } = await this.http.clientAuth(credential);
39
65
  const { exp, tenantId } = (0, jose_1.decodeJwt)(accessToken);
40
- this.token = accessToken;
41
- this.expiresAt = exp ? exp * 1000 : Date.now() + FALLBACK_TTL_MS;
42
- this.tenantId = tenantId ?? null;
43
- this.logger.log('service client token refreshed');
66
+ this.cache.set(serviceSlug, {
67
+ token: accessToken,
68
+ expiresAt: exp ? exp * 1000 : Date.now() + FALLBACK_TTL_MS,
69
+ tenantId: tenantId ?? null,
70
+ });
71
+ this.logger.log(`service client token refreshed for ${serviceSlug}`);
44
72
  return accessToken;
45
73
  }
46
74
  }
@@ -0,0 +1,13 @@
1
+ import { BastionApiClient } from '../core/api-client.js';
2
+ import { BastionService } from './bastion.service.js';
3
+ import { type BastionModuleOptions } from './options.js';
4
+ /**
5
+ * `BastionApiClient` bound to this app (`serviceSlug` as `appSlug`,
6
+ * `tenantSlug`), sharing `BastionService.http`. Inject it in an app backend
7
+ * that proxies its users' login/refresh/account flows; a pure service
8
+ * (Herald, Beacon) never needs it. Failures are `BastionHttpException`s:
9
+ * an `HttpException` with Bastion's status plus `code`/`upstreamMessage`.
10
+ */
11
+ export declare class BastionApiClientProvider extends BastionApiClient {
12
+ constructor(bastion: BastionService, options: BastionModuleOptions);
13
+ }
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.BastionApiClientProvider = void 0;
16
+ const common_1 = require("@nestjs/common");
17
+ const api_client_js_1 = require("../core/api-client.js");
18
+ const bastion_service_js_1 = require("./bastion.service.js");
19
+ const options_js_1 = require("./options.js");
20
+ const nest_logger_js_1 = require("./nest-logger.js");
21
+ /**
22
+ * `BastionApiClient` bound to this app (`serviceSlug` as `appSlug`,
23
+ * `tenantSlug`), sharing `BastionService.http`. Inject it in an app backend
24
+ * that proxies its users' login/refresh/account flows; a pure service
25
+ * (Herald, Beacon) never needs it. Failures are `BastionHttpException`s:
26
+ * an `HttpException` with Bastion's status plus `code`/`upstreamMessage`.
27
+ */
28
+ let BastionApiClientProvider = class BastionApiClientProvider extends api_client_js_1.BastionApiClient {
29
+ constructor(bastion, options) {
30
+ super({
31
+ http: bastion.http,
32
+ appSlug: options.serviceSlug,
33
+ tenantSlug: options.tenantSlug,
34
+ logger: (0, nest_logger_js_1.nestLogger)(api_client_js_1.BastionApiClient.name),
35
+ });
36
+ }
37
+ };
38
+ exports.BastionApiClientProvider = BastionApiClientProvider;
39
+ exports.BastionApiClientProvider = BastionApiClientProvider = __decorate([
40
+ (0, common_1.Injectable)(),
41
+ __param(1, (0, common_1.Inject)(options_js_1.BASTION_OPTIONS)),
42
+ __metadata("design:paramtypes", [bastion_service_js_1.BastionService, Object])
43
+ ], BastionApiClientProvider);
@@ -0,0 +1,19 @@
1
+ import { HttpException } from '@nestjs/common';
2
+ import { BastionHttp, type BastionRequestInit } from '../core/bastion-http.js';
3
+ import { BastionHttpError, type BastionErrorCode } from '../core/errors.js';
4
+ /**
5
+ * The Nest face of a Bastion error: an `HttpException` carrying Bastion's
6
+ * status, so the global filter answers with it unchanged, plus the stable
7
+ * `code` and the upstream message for the handlers that branch on them.
8
+ * `getResponse()` is `{ code, message }`.
9
+ */
10
+ export declare class BastionHttpException extends HttpException {
11
+ readonly code: BastionErrorCode;
12
+ readonly upstreamMessage: string;
13
+ constructor(code: BastionErrorCode, status: number, upstreamMessage: string);
14
+ static from(err: BastionHttpError): BastionHttpException;
15
+ }
16
+ /** `BastionHttp` whose failures are `BastionHttpException`s — what `BastionService.http` is. */
17
+ export declare class NestBastionHttp extends BastionHttp {
18
+ call<T>(method: string, path: string, body?: unknown, init?: BastionRequestInit): Promise<T>;
19
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NestBastionHttp = exports.BastionHttpException = void 0;
4
+ const common_1 = require("@nestjs/common");
5
+ const bastion_http_js_1 = require("../core/bastion-http.js");
6
+ const errors_js_1 = require("../core/errors.js");
7
+ /**
8
+ * The Nest face of a Bastion error: an `HttpException` carrying Bastion's
9
+ * status, so the global filter answers with it unchanged, plus the stable
10
+ * `code` and the upstream message for the handlers that branch on them.
11
+ * `getResponse()` is `{ code, message }`.
12
+ */
13
+ class BastionHttpException extends common_1.HttpException {
14
+ code;
15
+ upstreamMessage;
16
+ constructor(code, status, upstreamMessage) {
17
+ super({ code, message: upstreamMessage }, status);
18
+ this.code = code;
19
+ this.upstreamMessage = upstreamMessage;
20
+ }
21
+ static from(err) {
22
+ return new BastionHttpException(err.code, err.status, err.message);
23
+ }
24
+ }
25
+ exports.BastionHttpException = BastionHttpException;
26
+ /** `BastionHttp` whose failures are `BastionHttpException`s — what `BastionService.http` is. */
27
+ class NestBastionHttp extends bastion_http_js_1.BastionHttp {
28
+ async call(method, path, body, init) {
29
+ try {
30
+ return await super.call(method, path, body, init);
31
+ }
32
+ catch (err) {
33
+ if (err instanceof errors_js_1.BastionHttpError)
34
+ throw BastionHttpException.from(err);
35
+ throw err;
36
+ }
37
+ }
38
+ }
39
+ exports.NestBastionHttp = NestBastionHttp;
@@ -16,6 +16,7 @@ const bastion_audit_service_js_1 = require("./bastion-audit.service.js");
16
16
  const service_client_jwt_guard_js_1 = require("./guards/service-client-jwt.guard.js");
17
17
  const bastion_user_guard_js_1 = require("./guards/bastion-user.guard.js");
18
18
  const audit_interceptor_js_1 = require("./interceptors/audit.interceptor.js");
19
+ const bastion_api_client_provider_js_1 = require("./bastion-api-client.provider.js");
19
20
  const PROVIDERS = [
20
21
  bastion_jwks_service_js_1.BastionJwksService,
21
22
  bastion_service_js_1.BastionService,
@@ -23,6 +24,7 @@ const PROVIDERS = [
23
24
  service_client_jwt_guard_js_1.ServiceClientJwtGuard,
24
25
  bastion_user_guard_js_1.BastionUserGuard,
25
26
  audit_interceptor_js_1.AuditInterceptor,
27
+ bastion_api_client_provider_js_1.BastionApiClientProvider,
26
28
  ];
27
29
  /**
28
30
  * ```ts
@@ -1,15 +1,13 @@
1
- import { BastionHttp } from '../core/bastion-http.js';
1
+ import { type BastionRequestInit } from '../core/bastion-http.js';
2
+ import { NestBastionHttp } from './bastion-http-exception.js';
2
3
  import type { AuditEventInput, TokenResponse } from '../core/types.js';
3
4
  import { type BastionModuleOptions } from './options.js';
4
- /** Outbound HTTP to Bastion. A non-2xx answer surfaces as `HttpException` with Bastion's status. */
5
+ /** Outbound HTTP to Bastion. A failure surfaces as `BastionHttpException` (an `HttpException` with Bastion's status, a stable `code` and the upstream message). */
5
6
  export declare class BastionService {
6
7
  private readonly options;
7
- readonly http: BastionHttp;
8
+ readonly http: NestBastionHttp;
8
9
  constructor(options: BastionModuleOptions);
9
- call<T>(method: string, path: string, body?: unknown, init?: {
10
- token?: string;
11
- headers?: Record<string, string>;
12
- }): Promise<T>;
10
+ call<T>(method: string, path: string, body?: unknown, init?: BastionRequestInit): Promise<T>;
13
11
  clientAuth(): Promise<TokenResponse>;
14
12
  writeAuditEvent(token: string, data: AuditEventInput): Promise<{
15
13
  id: string;
@@ -14,26 +14,18 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.BastionService = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
- const bastion_http_js_1 = require("../core/bastion-http.js");
18
- const errors_js_1 = require("../core/errors.js");
17
+ const bastion_http_exception_js_1 = require("./bastion-http-exception.js");
19
18
  const options_js_1 = require("./options.js");
20
- /** Outbound HTTP to Bastion. A non-2xx answer surfaces as `HttpException` with Bastion's status. */
19
+ /** Outbound HTTP to Bastion. A failure surfaces as `BastionHttpException` (an `HttpException` with Bastion's status, a stable `code` and the upstream message). */
21
20
  let BastionService = class BastionService {
22
21
  options;
23
22
  http;
24
23
  constructor(options) {
25
24
  this.options = options;
26
- this.http = new bastion_http_js_1.BastionHttp({ baseUrl: options.baseUrl });
25
+ this.http = new bastion_http_exception_js_1.NestBastionHttp({ baseUrl: options.baseUrl, timeoutMs: options.timeoutMs });
27
26
  }
28
- async call(method, path, body, init) {
29
- try {
30
- return await this.http.call(method, path, body, init);
31
- }
32
- catch (err) {
33
- if (err instanceof errors_js_1.BastionHttpError)
34
- throw new common_1.HttpException(err.message, err.status);
35
- throw err;
36
- }
27
+ call(method, path, body, init) {
28
+ return this.http.call(method, path, body, init);
37
29
  }
38
30
  clientAuth() {
39
31
  if (!this.options.apiKey) {
@@ -3,6 +3,8 @@ export { BastionModule } from './bastion.module.js';
3
3
  export { BASTION_OPTIONS, type BastionModuleOptions, type BastionModuleAsyncOptions } from './options.js';
4
4
  export { BastionJwksService } from './bastion-jwks.service.js';
5
5
  export { BastionService } from './bastion.service.js';
6
+ export { BastionApiClientProvider } from './bastion-api-client.provider.js';
7
+ export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.js';
6
8
  export { BastionAuditService } from './bastion-audit.service.js';
7
9
  export { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
8
10
  export { BastionUserGuard } from './guards/bastion-user.guard.js';
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.CROSS_TENANT_ROLE = exports.resolveTenantScope = exports.CurrentAdminUser = exports.CurrentClient = exports.AUDIT_EVENT_KEY = exports.Audit = exports.REQUIRED_SCOPES_KEY = exports.RequireScope = exports.IS_PUBLIC_KEY = exports.Public = exports.AuditInterceptor = exports.BastionUserGuard = exports.ServiceClientJwtGuard = exports.BastionAuditService = exports.BastionService = exports.BastionJwksService = exports.BASTION_OPTIONS = exports.BastionModule = void 0;
17
+ exports.CROSS_TENANT_ROLE = exports.resolveTenantScope = exports.CurrentAdminUser = exports.CurrentClient = exports.AUDIT_EVENT_KEY = exports.Audit = exports.REQUIRED_SCOPES_KEY = exports.RequireScope = exports.IS_PUBLIC_KEY = exports.Public = exports.AuditInterceptor = exports.BastionUserGuard = exports.ServiceClientJwtGuard = exports.BastionAuditService = exports.NestBastionHttp = exports.BastionHttpException = exports.BastionApiClientProvider = exports.BastionService = exports.BastionJwksService = exports.BASTION_OPTIONS = exports.BastionModule = void 0;
18
18
  __exportStar(require("../core/index.js"), exports);
19
19
  var bastion_module_js_1 = require("./bastion.module.js");
20
20
  Object.defineProperty(exports, "BastionModule", { enumerable: true, get: function () { return bastion_module_js_1.BastionModule; } });
@@ -24,6 +24,11 @@ var bastion_jwks_service_js_1 = require("./bastion-jwks.service.js");
24
24
  Object.defineProperty(exports, "BastionJwksService", { enumerable: true, get: function () { return bastion_jwks_service_js_1.BastionJwksService; } });
25
25
  var bastion_service_js_1 = require("./bastion.service.js");
26
26
  Object.defineProperty(exports, "BastionService", { enumerable: true, get: function () { return bastion_service_js_1.BastionService; } });
27
+ var bastion_api_client_provider_js_1 = require("./bastion-api-client.provider.js");
28
+ Object.defineProperty(exports, "BastionApiClientProvider", { enumerable: true, get: function () { return bastion_api_client_provider_js_1.BastionApiClientProvider; } });
29
+ var bastion_http_exception_js_1 = require("./bastion-http-exception.js");
30
+ Object.defineProperty(exports, "BastionHttpException", { enumerable: true, get: function () { return bastion_http_exception_js_1.BastionHttpException; } });
31
+ Object.defineProperty(exports, "NestBastionHttp", { enumerable: true, get: function () { return bastion_http_exception_js_1.NestBastionHttp; } });
27
32
  var bastion_audit_service_js_1 = require("./bastion-audit.service.js");
28
33
  Object.defineProperty(exports, "BastionAuditService", { enumerable: true, get: function () { return bastion_audit_service_js_1.BastionAuditService; } });
29
34
  var service_client_jwt_guard_js_1 = require("./guards/service-client-jwt.guard.js");
@@ -9,6 +9,8 @@ export interface BastionModuleOptions {
9
9
  apiKey?: string;
10
10
  /** Required by Bastion when the app is `isGlobal` or registered in more than one tenant. */
11
11
  tenantSlug?: string;
12
+ /** Per-request timeout for outbound calls, default 8 000 ms. */
13
+ timeoutMs?: number;
12
14
  /** `BASTION_JWKS_TTL_MS`, default 300 000. */
13
15
  jwksTtlMs?: number;
14
16
  /** Expected `iss`. Default `'bastion'`; `null` disables the check. */
@@ -0,0 +1,54 @@
1
+ /** Headers Bastion sends with every delivery. Lower-case, as Node exposes them. */
2
+ export declare const BASTION_SIGNATURE_HEADER = "x-bastion-signature";
3
+ export declare const BASTION_SIGNATURE_V2_HEADER = "x-bastion-signature-v2";
4
+ export declare const BASTION_EVENT_HEADER = "x-bastion-event";
5
+ export declare const BASTION_DELIVERY_ID_HEADER = "x-bastion-delivery-id";
6
+ /** Sent to every app with a webhook URL, whether or not the user has a role in it. */
7
+ export declare const BASTION_LIFECYCLE_EVENTS: readonly ["user.deleted", "user.expired_unverified", "user.deactivated"];
8
+ export interface BastionWebhookPayload {
9
+ event: string;
10
+ tenantSlug?: string;
11
+ userId?: string;
12
+ timestamp?: string;
13
+ /** `auth.email_changed` */
14
+ newEmail?: string;
15
+ /** `auth.email_verified` */
16
+ email?: string;
17
+ /** `user.deleted` from the age gate carries `under_minimum_age`; absent otherwise. */
18
+ reason?: string;
19
+ [key: string]: unknown;
20
+ }
21
+ export interface VerifyWebhookOptions {
22
+ /** The request body exactly as received — never a re-serialized object. */
23
+ rawBody: Buffer | string;
24
+ /** Request headers (any case). */
25
+ headers: Record<string, string | string[] | undefined>;
26
+ /** Your webhook secret, plus the previous one during a rotation. */
27
+ secrets: string | string[];
28
+ /** Max age of `t` on a v2 signature, default 300 s. */
29
+ toleranceSeconds?: number;
30
+ /** For tests. */
31
+ now?: () => number;
32
+ }
33
+ export type VerifyWebhookResult = {
34
+ ok: true;
35
+ version: 'v2';
36
+ timestamp: number;
37
+ deliveryId: string;
38
+ } | {
39
+ ok: true;
40
+ version: 'legacy';
41
+ } | {
42
+ ok: false;
43
+ reason: 'missing' | 'malformed' | 'stale' | 'mismatch';
44
+ };
45
+ /**
46
+ * Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
47
+ * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
48
+ * which binds the timestamp and the delivery id so a stale or replayed
49
+ * delivery is rejected without trusting any unsigned header; falls back to
50
+ * the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
51
+ * v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
52
+ * that is how a secret rotation stays zero-downtime on both sides.
53
+ */
54
+ export declare function verifyWebhookSignature(options: VerifyWebhookOptions): VerifyWebhookResult;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BASTION_LIFECYCLE_EVENTS = exports.BASTION_DELIVERY_ID_HEADER = exports.BASTION_EVENT_HEADER = exports.BASTION_SIGNATURE_V2_HEADER = exports.BASTION_SIGNATURE_HEADER = void 0;
4
+ exports.verifyWebhookSignature = verifyWebhookSignature;
5
+ const node_crypto_1 = require("node:crypto");
6
+ /** Headers Bastion sends with every delivery. Lower-case, as Node exposes them. */
7
+ exports.BASTION_SIGNATURE_HEADER = 'x-bastion-signature';
8
+ exports.BASTION_SIGNATURE_V2_HEADER = 'x-bastion-signature-v2';
9
+ exports.BASTION_EVENT_HEADER = 'x-bastion-event';
10
+ exports.BASTION_DELIVERY_ID_HEADER = 'x-bastion-delivery-id';
11
+ /** Sent to every app with a webhook URL, whether or not the user has a role in it. */
12
+ exports.BASTION_LIFECYCLE_EVENTS = ['user.deleted', 'user.expired_unverified', 'user.deactivated'];
13
+ function header(headers, name) {
14
+ const direct = headers[name] ?? headers[name.toLowerCase()];
15
+ if (direct !== undefined)
16
+ return Array.isArray(direct) ? direct[0] : direct;
17
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
18
+ const v = key ? headers[key] : undefined;
19
+ return Array.isArray(v) ? v[0] : v;
20
+ }
21
+ function macEquals(hexA, hexB) {
22
+ const a = Buffer.from(hexA, 'hex');
23
+ const b = Buffer.from(hexB, 'hex');
24
+ return a.length === b.length && a.length > 0 && (0, node_crypto_1.timingSafeEqual)(a, b);
25
+ }
26
+ /**
27
+ * Verifies a Bastion delivery. Prefers `X-Bastion-Signature-V2`
28
+ * (`t=<unix>,id=<deliveryId>,v1=<hex>[,v1=<hex>]`, MAC over `"<t>.<id>.<body>"`),
29
+ * which binds the timestamp and the delivery id so a stale or replayed
30
+ * delivery is rejected without trusting any unsigned header; falls back to
31
+ * the legacy `X-Bastion-Signature: sha256=<hex>` over the body alone only when
32
+ * v2 is absent. Any of the caller's `secrets` matching any `v1=` accepts:
33
+ * that is how a secret rotation stays zero-downtime on both sides.
34
+ */
35
+ function verifyWebhookSignature(options) {
36
+ const body = typeof options.rawBody === 'string' ? Buffer.from(options.rawBody, 'utf8') : options.rawBody;
37
+ const secrets = (Array.isArray(options.secrets) ? options.secrets : [options.secrets]).filter(Boolean);
38
+ if (secrets.length === 0)
39
+ return { ok: false, reason: 'missing' };
40
+ const v2 = header(options.headers, exports.BASTION_SIGNATURE_V2_HEADER);
41
+ if (v2) {
42
+ const parts = v2.split(',').map((p) => p.trim());
43
+ const t = parts.find((p) => p.startsWith('t='))?.slice(2);
44
+ const id = parts.find((p) => p.startsWith('id='))?.slice(3);
45
+ const macs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
46
+ const timestamp = Number(t);
47
+ if (!t || !id || macs.length === 0 || !Number.isFinite(timestamp))
48
+ return { ok: false, reason: 'malformed' };
49
+ const now = Math.floor((options.now ?? Date.now)() / 1000);
50
+ if (Math.abs(now - timestamp) > (options.toleranceSeconds ?? 300))
51
+ return { ok: false, reason: 'stale' };
52
+ const signed = Buffer.concat([Buffer.from(`${timestamp}.${id}.`, 'utf8'), body]);
53
+ for (const secret of secrets) {
54
+ const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(signed).digest('hex');
55
+ if (macs.some((mac) => macEquals(mac, expected)))
56
+ return { ok: true, version: 'v2', timestamp, deliveryId: id };
57
+ }
58
+ return { ok: false, reason: 'mismatch' };
59
+ }
60
+ const legacy = header(options.headers, exports.BASTION_SIGNATURE_HEADER);
61
+ if (!legacy)
62
+ return { ok: false, reason: 'missing' };
63
+ if (!legacy.startsWith('sha256='))
64
+ return { ok: false, reason: 'malformed' };
65
+ const provided = legacy.slice('sha256='.length).trim();
66
+ for (const secret of secrets) {
67
+ const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
68
+ if (macEquals(provided, expected))
69
+ return { ok: true, version: 'legacy' };
70
+ }
71
+ return { ok: false, reason: 'mismatch' };
72
+ }