@heyatom/bastion-client 0.2.0 → 0.3.1

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 (56) hide show
  1. package/README.md +32 -1
  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-audit.service.d.ts +13 -2
  18. package/dist/cjs/nest/bastion-audit.service.js +7 -1
  19. package/dist/cjs/nest/bastion-http-exception.d.ts +19 -0
  20. package/dist/cjs/nest/bastion-http-exception.js +39 -0
  21. package/dist/cjs/nest/bastion.module.js +2 -0
  22. package/dist/cjs/nest/bastion.service.d.ts +5 -7
  23. package/dist/cjs/nest/bastion.service.js +5 -13
  24. package/dist/cjs/nest/index.d.ts +3 -1
  25. package/dist/cjs/nest/index.js +6 -1
  26. package/dist/cjs/nest/options.d.ts +2 -0
  27. package/dist/cjs/webhooks/index.d.ts +54 -0
  28. package/dist/cjs/webhooks/index.js +72 -0
  29. package/dist/esm/core/api-client.d.ts +98 -0
  30. package/dist/esm/core/api-client.js +222 -0
  31. package/dist/esm/core/api-types.d.ts +138 -0
  32. package/dist/esm/core/api-types.js +7 -0
  33. package/dist/esm/core/bastion-http.d.ts +13 -8
  34. package/dist/esm/core/bastion-http.js +30 -18
  35. package/dist/esm/core/errors.d.ts +23 -2
  36. package/dist/esm/core/errors.js +43 -2
  37. package/dist/esm/core/index.d.ts +4 -2
  38. package/dist/esm/core/index.js +2 -0
  39. package/dist/esm/core/jwks-verifier.js +2 -1
  40. package/dist/esm/core/service-token-provider.d.ts +20 -12
  41. package/dist/esm/core/service-token-provider.js +55 -27
  42. package/dist/esm/nest/bastion-api-client.provider.d.ts +13 -0
  43. package/dist/esm/nest/bastion-api-client.provider.js +40 -0
  44. package/dist/esm/nest/bastion-audit.service.d.ts +13 -2
  45. package/dist/esm/nest/bastion-audit.service.js +7 -1
  46. package/dist/esm/nest/bastion-http-exception.d.ts +19 -0
  47. package/dist/esm/nest/bastion-http-exception.js +34 -0
  48. package/dist/esm/nest/bastion.module.js +2 -0
  49. package/dist/esm/nest/bastion.service.d.ts +5 -7
  50. package/dist/esm/nest/bastion.service.js +6 -14
  51. package/dist/esm/nest/index.d.ts +3 -1
  52. package/dist/esm/nest/index.js +2 -0
  53. package/dist/esm/nest/options.d.ts +2 -0
  54. package/dist/esm/webhooks/index.d.ts +54 -0
  55. package/dist/esm/webhooks/index.js +68 -0
  56. package/package.json +14 -1
@@ -4,8 +4,29 @@ export declare class BastionTokenError extends Error {
4
4
  readonly code: TokenErrorCode;
5
5
  constructor(code: TokenErrorCode, message: string);
6
6
  }
7
- /** A non-2xx answer from Bastion's HTTP API. */
7
+ /**
8
+ * Stable classification of a Bastion error answer, so an app can branch on a
9
+ * code instead of on Bastion's message text. The age-gate codes are Bastion's
10
+ * own (it returns them verbatim); the rest are derived from status + message
11
+ * until Bastion carries a `code` field on every error.
12
+ */
13
+ export declare const BastionErrorCode: {
14
+ readonly EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED";
15
+ readonly INVALID_CREDENTIALS: "INVALID_CREDENTIALS";
16
+ readonly NO_APP_ACCESS: "NO_APP_ACCESS";
17
+ readonly ACCOUNT_INACTIVE: "ACCOUNT_INACTIVE";
18
+ readonly CONFLICT: "CONFLICT";
19
+ readonly BIRTH_DATE_REQUIRED: "BIRTH_DATE_REQUIRED";
20
+ readonly BIRTH_DATE_INVALID: "BIRTH_DATE_INVALID";
21
+ readonly UNDER_MINIMUM_AGE: "UNDER_MINIMUM_AGE";
22
+ readonly UNAVAILABLE: "UNAVAILABLE";
23
+ readonly UPSTREAM: "UPSTREAM";
24
+ };
25
+ export type BastionErrorCode = (typeof BastionErrorCode)[keyof typeof BastionErrorCode];
26
+ export declare function classifyBastionError(status: number, message: string): BastionErrorCode;
27
+ /** A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`). */
8
28
  export declare class BastionHttpError extends Error {
9
29
  readonly status: number;
10
- constructor(status: number, message: string);
30
+ readonly code: BastionErrorCode;
31
+ constructor(status: number, message: string, code?: BastionErrorCode);
11
32
  }
@@ -7,12 +7,53 @@ export class BastionTokenError extends Error {
7
7
  this.name = 'BastionTokenError';
8
8
  }
9
9
  }
10
- /** A non-2xx answer from Bastion's HTTP API. */
10
+ /**
11
+ * Stable classification of a Bastion error answer, so an app can branch on a
12
+ * code instead of on Bastion's message text. The age-gate codes are Bastion's
13
+ * own (it returns them verbatim); the rest are derived from status + message
14
+ * until Bastion carries a `code` field on every error.
15
+ */
16
+ export const BastionErrorCode = {
17
+ EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
18
+ INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
19
+ NO_APP_ACCESS: 'NO_APP_ACCESS',
20
+ ACCOUNT_INACTIVE: 'ACCOUNT_INACTIVE',
21
+ CONFLICT: 'CONFLICT',
22
+ BIRTH_DATE_REQUIRED: 'BIRTH_DATE_REQUIRED',
23
+ BIRTH_DATE_INVALID: 'BIRTH_DATE_INVALID',
24
+ UNDER_MINIMUM_AGE: 'UNDER_MINIMUM_AGE',
25
+ UNAVAILABLE: 'UNAVAILABLE',
26
+ UPSTREAM: 'UPSTREAM',
27
+ };
28
+ export function classifyBastionError(status, message) {
29
+ if (message === BastionErrorCode.BIRTH_DATE_REQUIRED ||
30
+ message === BastionErrorCode.BIRTH_DATE_INVALID ||
31
+ message === BastionErrorCode.UNDER_MINIMUM_AGE) {
32
+ return message;
33
+ }
34
+ if (status === 403 && /email not verified/i.test(message))
35
+ return BastionErrorCode.EMAIL_NOT_VERIFIED;
36
+ if (status === 401) {
37
+ if (/no access to this app/i.test(message))
38
+ return BastionErrorCode.NO_APP_ACCESS;
39
+ if (/inactive|locked/i.test(message))
40
+ return BastionErrorCode.ACCOUNT_INACTIVE;
41
+ return BastionErrorCode.INVALID_CREDENTIALS;
42
+ }
43
+ if (status === 409)
44
+ return BastionErrorCode.CONFLICT;
45
+ if (status === 503)
46
+ return BastionErrorCode.UNAVAILABLE;
47
+ return BastionErrorCode.UPSTREAM;
48
+ }
49
+ /** A non-2xx answer from Bastion's HTTP API, or Bastion unreachable (`status` 503, code `UNAVAILABLE`). */
11
50
  export class BastionHttpError extends Error {
12
51
  status;
13
- constructor(status, message) {
52
+ code;
53
+ constructor(status, message, code) {
14
54
  super(message);
15
55
  this.status = status;
16
56
  this.name = 'BastionHttpError';
57
+ this.code = code ?? classifyBastionError(status, message);
17
58
  }
18
59
  }
@@ -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';
@@ -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 } from './jwks-verifier.js';
4
5
  export { BastionHttp } from './bastion-http.js';
5
6
  export { ServiceTokenProvider } from './service-token-provider.js';
7
+ export { BastionApiClient } from './api-client.js';
@@ -30,7 +30,8 @@ export class JwksVerifier {
30
30
  this.ttlMs = options.ttlMs ?? 300_000;
31
31
  this.issuer = options.issuer === undefined ? 'bastion' : options.issuer;
32
32
  this.logger = options.logger ?? silentLogger;
33
- this.fetchImpl = options.fetch ?? fetch;
33
+ // Late-bound on purpose: a consumer (or its tests) may replace global fetch after construction.
34
+ this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
34
35
  }
35
36
  async verify(token) {
36
37
  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
  }
@@ -2,42 +2,70 @@ import { decodeJwt } from 'jose';
2
2
  import { silentLogger } from './types.js';
3
3
  const REFRESH_MARGIN_MS = 5 * 60 * 1000;
4
4
  const FALLBACK_TTL_MS = 60 * 60 * 1000;
5
- /**
6
- * Holds the service-client token for outbound calls and renews it ahead of
7
- * `exp`. Single-flight: a burst of first calls shares one `POST /auth/client`
8
- * instead of issuing one per caller — the previous per-service copies had no
9
- * such guard.
10
- */
11
5
  export class ServiceTokenProvider {
12
6
  http;
13
- credentials;
14
7
  logger;
15
- token = null;
16
- expiresAt = 0;
17
- inFlight = null;
18
- /** Tenant the token was issued for — the tenant `POST /events` rows land in. */
19
- tenantId = null;
8
+ resolveCredential;
9
+ defaultSlug;
10
+ cache = new Map();
11
+ inFlight = new Map();
20
12
  constructor(http, credentials, logger = silentLogger) {
21
13
  this.http = http;
22
- this.credentials = credentials;
23
14
  this.logger = logger;
24
- }
25
- getToken() {
26
- if (this.token && Date.now() < this.expiresAt - REFRESH_MARGIN_MS) {
27
- return Promise.resolve(this.token);
15
+ if ('resolve' in credentials && typeof credentials.resolve === 'function') {
16
+ const resolver = credentials;
17
+ this.resolveCredential = resolver.resolve;
18
+ this.defaultSlug = resolver.defaultSlug;
19
+ }
20
+ else if ('apiKey' in credentials && typeof credentials.apiKey === 'string') {
21
+ const single = credentials;
22
+ this.resolveCredential = (slug) => (slug === single.serviceSlug ? single : null);
23
+ this.defaultSlug = single.serviceSlug;
24
+ }
25
+ else {
26
+ const record = credentials;
27
+ const [first] = Object.keys(record);
28
+ if (!first)
29
+ throw new Error('ServiceTokenProvider: no credentials configured');
30
+ this.resolveCredential = (slug) => record[slug];
31
+ this.defaultSlug = first;
28
32
  }
29
- this.inFlight ??= this.refresh().finally(() => {
30
- this.inFlight = null;
31
- });
32
- return this.inFlight;
33
33
  }
34
- async refresh() {
35
- const { accessToken } = await this.http.clientAuth(this.credentials);
34
+ /** Tenant of the default credential's token, once minted — the tenant `POST /events` rows land in. */
35
+ get tenantId() {
36
+ return this.cache.get(this.defaultSlug)?.tenantId ?? null;
37
+ }
38
+ getToken(serviceSlug = this.defaultSlug) {
39
+ const cached = this.cache.get(serviceSlug);
40
+ if (cached && Date.now() < cached.expiresAt - REFRESH_MARGIN_MS)
41
+ return Promise.resolve(cached.token);
42
+ const running = this.inFlight.get(serviceSlug);
43
+ if (running)
44
+ return running;
45
+ const pending = this.refresh(serviceSlug)
46
+ .catch((err) => {
47
+ if (cached && Date.now() < cached.expiresAt) {
48
+ this.logger.warn(`bastion: token mint for ${serviceSlug} failed, serving stale cache`);
49
+ return cached.token;
50
+ }
51
+ throw err;
52
+ })
53
+ .finally(() => this.inFlight.delete(serviceSlug));
54
+ this.inFlight.set(serviceSlug, pending);
55
+ return pending;
56
+ }
57
+ async refresh(serviceSlug) {
58
+ const credential = this.resolveCredential(serviceSlug);
59
+ if (!credential?.apiKey)
60
+ throw new Error(`Bastion service client for "${serviceSlug}" not configured`);
61
+ const { accessToken } = await this.http.clientAuth(credential);
36
62
  const { exp, tenantId } = decodeJwt(accessToken);
37
- this.token = accessToken;
38
- this.expiresAt = exp ? exp * 1000 : Date.now() + FALLBACK_TTL_MS;
39
- this.tenantId = tenantId ?? null;
40
- this.logger.log('service client token refreshed');
63
+ this.cache.set(serviceSlug, {
64
+ token: accessToken,
65
+ expiresAt: exp ? exp * 1000 : Date.now() + FALLBACK_TTL_MS,
66
+ tenantId: tenantId ?? null,
67
+ });
68
+ this.logger.log(`service client token refreshed for ${serviceSlug}`);
41
69
  return accessToken;
42
70
  }
43
71
  }
@@ -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,40 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ 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;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ import { Inject, Injectable } from '@nestjs/common';
14
+ import { BastionApiClient } from '../core/api-client.js';
15
+ import { BastionService } from './bastion.service.js';
16
+ import { BASTION_OPTIONS } from './options.js';
17
+ import { nestLogger } from './nest-logger.js';
18
+ /**
19
+ * `BastionApiClient` bound to this app (`serviceSlug` as `appSlug`,
20
+ * `tenantSlug`), sharing `BastionService.http`. Inject it in an app backend
21
+ * that proxies its users' login/refresh/account flows; a pure service
22
+ * (Herald, Beacon) never needs it. Failures are `BastionHttpException`s:
23
+ * an `HttpException` with Bastion's status plus `code`/`upstreamMessage`.
24
+ */
25
+ let BastionApiClientProvider = class BastionApiClientProvider extends BastionApiClient {
26
+ constructor(bastion, options) {
27
+ super({
28
+ http: bastion.http,
29
+ appSlug: options.serviceSlug,
30
+ tenantSlug: options.tenantSlug,
31
+ logger: nestLogger(BastionApiClient.name),
32
+ });
33
+ }
34
+ };
35
+ BastionApiClientProvider = __decorate([
36
+ Injectable(),
37
+ __param(1, Inject(BASTION_OPTIONS)),
38
+ __metadata("design:paramtypes", [BastionService, Object])
39
+ ], BastionApiClientProvider);
40
+ export { BastionApiClientProvider };
@@ -1,7 +1,17 @@
1
1
  import { OnModuleInit } from '@nestjs/common';
2
- import type { UserJwtPayload } from '../core/types.js';
3
2
  import { BastionService } from './bastion.service.js';
4
3
  import { type BastionModuleOptions } from './options.js';
4
+ /**
5
+ * The fields `writeAsAdmin` reads from an actor. A `UserJwtPayload` fits, and
6
+ * so does a consumer's own admin payload that carries extra fields.
7
+ */
8
+ export interface AuditActor {
9
+ sub: string;
10
+ tenantId?: string;
11
+ tenantSlug?: string;
12
+ role?: string;
13
+ appSlug?: string;
14
+ }
5
15
  /**
6
16
  * Fire-and-forget audit writes to `POST /events` under this service's own
7
17
  * machine token. Never throws: a failed write is logged, the request that
@@ -12,6 +22,7 @@ export declare class BastionAuditService implements OnModuleInit {
12
22
  private readonly logger;
13
23
  private readonly tokens;
14
24
  private tenantMismatchWarned;
25
+ private noApiKeyWarned;
15
26
  constructor(bastion: BastionService, options: BastionModuleOptions);
16
27
  onModuleInit(): Promise<void>;
17
28
  /** Tenant of this service's machine token, once known. */
@@ -25,5 +36,5 @@ export declare class BastionAuditService implements OnModuleInit {
25
36
  * belongs to the same tenant as this service's token — Bastion rejects a
26
37
  * foreign userId silently — otherwise the actor goes into `metadata`.
27
38
  */
28
- writeAsAdmin(event: string, actor: UserJwtPayload | undefined, metadata?: Record<string, unknown>): Promise<void>;
39
+ writeAsAdmin(event: string, actor: AuditActor | undefined, metadata?: Record<string, unknown>): Promise<void>;
29
40
  }
@@ -26,6 +26,7 @@ let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
26
26
  logger = new Logger(BastionAuditService_1.name);
27
27
  tokens;
28
28
  tenantMismatchWarned = false;
29
+ noApiKeyWarned = false;
29
30
  constructor(bastion, options) {
30
31
  this.bastion = bastion;
31
32
  this.tokens = options.apiKey
@@ -47,7 +48,12 @@ let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
47
48
  }
48
49
  async write(event, opts = {}) {
49
50
  if (!this.tokens) {
50
- this.logger.warn(`audit write skipped, no apiKey configured event=${event}`);
51
+ // Once, not per event: a consumer that verifies tokens but never writes
52
+ // audit (no apiKey on purpose) would otherwise log every skipped write.
53
+ if (!this.noApiKeyWarned) {
54
+ this.noApiKeyWarned = true;
55
+ this.logger.warn(`audit writes skipped, no apiKey configured (first event=${event})`);
56
+ }
51
57
  return;
52
58
  }
53
59
  let token;
@@ -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,34 @@
1
+ import { HttpException } from '@nestjs/common';
2
+ import { BastionHttp } from '../core/bastion-http.js';
3
+ import { BastionHttpError } 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 class BastionHttpException extends HttpException {
11
+ code;
12
+ upstreamMessage;
13
+ constructor(code, status, upstreamMessage) {
14
+ super({ code, message: upstreamMessage }, status);
15
+ this.code = code;
16
+ this.upstreamMessage = upstreamMessage;
17
+ }
18
+ static from(err) {
19
+ return new BastionHttpException(err.code, err.status, err.message);
20
+ }
21
+ }
22
+ /** `BastionHttp` whose failures are `BastionHttpException`s — what `BastionService.http` is. */
23
+ export class NestBastionHttp extends BastionHttp {
24
+ async call(method, path, body, init) {
25
+ try {
26
+ return await super.call(method, path, body, init);
27
+ }
28
+ catch (err) {
29
+ if (err instanceof BastionHttpError)
30
+ throw BastionHttpException.from(err);
31
+ throw err;
32
+ }
33
+ }
34
+ }
@@ -13,6 +13,7 @@ import { BastionAuditService } from './bastion-audit.service.js';
13
13
  import { ServiceClientJwtGuard } from './guards/service-client-jwt.guard.js';
14
14
  import { BastionUserGuard } from './guards/bastion-user.guard.js';
15
15
  import { AuditInterceptor } from './interceptors/audit.interceptor.js';
16
+ import { BastionApiClientProvider } from './bastion-api-client.provider.js';
16
17
  const PROVIDERS = [
17
18
  BastionJwksService,
18
19
  BastionService,
@@ -20,6 +21,7 @@ const PROVIDERS = [
20
21
  ServiceClientJwtGuard,
21
22
  BastionUserGuard,
22
23
  AuditInterceptor,
24
+ BastionApiClientProvider,
23
25
  ];
24
26
  /**
25
27
  * ```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;
@@ -10,27 +10,19 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  var __param = (this && this.__param) || function (paramIndex, decorator) {
11
11
  return function (target, key) { decorator(target, key, paramIndex); }
12
12
  };
13
- import { HttpException, Inject, Injectable } from '@nestjs/common';
14
- import { BastionHttp } from '../core/bastion-http.js';
15
- import { BastionHttpError } from '../core/errors.js';
13
+ import { Inject, Injectable } from '@nestjs/common';
14
+ import { NestBastionHttp } from './bastion-http-exception.js';
16
15
  import { BASTION_OPTIONS } from './options.js';
17
- /** Outbound HTTP to Bastion. A non-2xx answer surfaces as `HttpException` with Bastion's status. */
16
+ /** Outbound HTTP to Bastion. A failure surfaces as `BastionHttpException` (an `HttpException` with Bastion's status, a stable `code` and the upstream message). */
18
17
  let BastionService = class BastionService {
19
18
  options;
20
19
  http;
21
20
  constructor(options) {
22
21
  this.options = options;
23
- this.http = new BastionHttp({ baseUrl: options.baseUrl });
22
+ this.http = new NestBastionHttp({ baseUrl: options.baseUrl, timeoutMs: options.timeoutMs });
24
23
  }
25
- async call(method, path, body, init) {
26
- try {
27
- return await this.http.call(method, path, body, init);
28
- }
29
- catch (err) {
30
- if (err instanceof BastionHttpError)
31
- throw new HttpException(err.message, err.status);
32
- throw err;
33
- }
24
+ call(method, path, body, init) {
25
+ return this.http.call(method, path, body, init);
34
26
  }
35
27
  clientAuth() {
36
28
  if (!this.options.apiKey) {
@@ -3,7 +3,9 @@ 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 { BastionAuditService } from './bastion-audit.service.js';
6
+ export { BastionApiClientProvider } from './bastion-api-client.provider.js';
7
+ export { BastionHttpException, NestBastionHttp } from './bastion-http-exception.js';
8
+ export { BastionAuditService, type AuditActor } 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';
9
11
  export { AuditInterceptor } from './interceptors/audit.interceptor.js';
@@ -3,6 +3,8 @@ export { BastionModule } from './bastion.module.js';
3
3
  export { BASTION_OPTIONS } 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';
@@ -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;