@heyatom/bastion-client 0.1.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 (87) hide show
  1. package/README.md +80 -0
  2. package/dist/cjs/core/bastion-http.d.ts +32 -0
  3. package/dist/cjs/core/bastion-http.js +47 -0
  4. package/dist/cjs/core/errors.d.ts +11 -0
  5. package/dist/cjs/core/errors.js +23 -0
  6. package/dist/cjs/core/index.d.ts +5 -0
  7. package/dist/cjs/core/index.js +25 -0
  8. package/dist/cjs/core/jwks-verifier.d.ts +35 -0
  9. package/dist/cjs/core/jwks-verifier.js +118 -0
  10. package/dist/cjs/core/service-token-provider.d.ts +21 -0
  11. package/dist/cjs/core/service-token-provider.js +47 -0
  12. package/dist/cjs/core/types.d.ts +48 -0
  13. package/dist/cjs/core/types.js +12 -0
  14. package/dist/cjs/nest/bastion-audit.service.d.ts +29 -0
  15. package/dist/cjs/nest/bastion-audit.service.js +97 -0
  16. package/dist/cjs/nest/bastion-jwks.service.d.ts +8 -0
  17. package/dist/cjs/nest/bastion-jwks.service.js +49 -0
  18. package/dist/cjs/nest/bastion.module.d.ts +20 -0
  19. package/dist/cjs/nest/bastion.module.js +60 -0
  20. package/dist/cjs/nest/bastion.service.d.ts +18 -0
  21. package/dist/cjs/nest/bastion.service.js +57 -0
  22. package/dist/cjs/nest/decorators/audit.decorator.d.ts +15 -0
  23. package/dist/cjs/nest/decorators/audit.decorator.js +8 -0
  24. package/dist/cjs/nest/decorators/current-user.decorator.d.ts +4 -0
  25. package/dist/cjs/nest/decorators/current-user.decorator.js +8 -0
  26. package/dist/cjs/nest/decorators/public.decorator.d.ts +3 -0
  27. package/dist/cjs/nest/decorators/public.decorator.js +8 -0
  28. package/dist/cjs/nest/decorators/require-scope.decorator.d.ts +3 -0
  29. package/dist/cjs/nest/decorators/require-scope.decorator.js +8 -0
  30. package/dist/cjs/nest/guards/bastion-user.guard.d.ts +20 -0
  31. package/dist/cjs/nest/guards/bastion-user.guard.js +87 -0
  32. package/dist/cjs/nest/guards/service-client-jwt.guard.d.ts +17 -0
  33. package/dist/cjs/nest/guards/service-client-jwt.guard.js +66 -0
  34. package/dist/cjs/nest/index.d.ts +14 -0
  35. package/dist/cjs/nest/index.js +49 -0
  36. package/dist/cjs/nest/interceptors/audit.interceptor.d.ts +12 -0
  37. package/dist/cjs/nest/interceptors/audit.interceptor.js +51 -0
  38. package/dist/cjs/nest/nest-logger.d.ts +2 -0
  39. package/dist/cjs/nest/nest-logger.js +12 -0
  40. package/dist/cjs/nest/options.d.ts +24 -0
  41. package/dist/cjs/nest/options.js +4 -0
  42. package/dist/cjs/nest/tenant-scope.d.ts +4 -0
  43. package/dist/cjs/nest/tenant-scope.js +14 -0
  44. package/dist/esm/core/bastion-http.d.ts +32 -0
  45. package/dist/esm/core/bastion-http.js +43 -0
  46. package/dist/esm/core/errors.d.ts +11 -0
  47. package/dist/esm/core/errors.js +18 -0
  48. package/dist/esm/core/index.d.ts +5 -0
  49. package/dist/esm/core/index.js +5 -0
  50. package/dist/esm/core/jwks-verifier.d.ts +35 -0
  51. package/dist/esm/core/jwks-verifier.js +114 -0
  52. package/dist/esm/core/service-token-provider.d.ts +21 -0
  53. package/dist/esm/core/service-token-provider.js +43 -0
  54. package/dist/esm/core/types.d.ts +48 -0
  55. package/dist/esm/core/types.js +8 -0
  56. package/dist/esm/nest/bastion-audit.service.d.ts +29 -0
  57. package/dist/esm/nest/bastion-audit.service.js +94 -0
  58. package/dist/esm/nest/bastion-jwks.service.d.ts +8 -0
  59. package/dist/esm/nest/bastion-jwks.service.js +46 -0
  60. package/dist/esm/nest/bastion.module.d.ts +20 -0
  61. package/dist/esm/nest/bastion.module.js +57 -0
  62. package/dist/esm/nest/bastion.service.d.ts +18 -0
  63. package/dist/esm/nest/bastion.service.js +54 -0
  64. package/dist/esm/nest/decorators/audit.decorator.d.ts +15 -0
  65. package/dist/esm/nest/decorators/audit.decorator.js +4 -0
  66. package/dist/esm/nest/decorators/current-user.decorator.d.ts +4 -0
  67. package/dist/esm/nest/decorators/current-user.decorator.js +5 -0
  68. package/dist/esm/nest/decorators/public.decorator.d.ts +3 -0
  69. package/dist/esm/nest/decorators/public.decorator.js +4 -0
  70. package/dist/esm/nest/decorators/require-scope.decorator.d.ts +3 -0
  71. package/dist/esm/nest/decorators/require-scope.decorator.js +4 -0
  72. package/dist/esm/nest/guards/bastion-user.guard.d.ts +20 -0
  73. package/dist/esm/nest/guards/bastion-user.guard.js +84 -0
  74. package/dist/esm/nest/guards/service-client-jwt.guard.d.ts +17 -0
  75. package/dist/esm/nest/guards/service-client-jwt.guard.js +63 -0
  76. package/dist/esm/nest/index.d.ts +14 -0
  77. package/dist/esm/nest/index.js +14 -0
  78. package/dist/esm/nest/interceptors/audit.interceptor.d.ts +12 -0
  79. package/dist/esm/nest/interceptors/audit.interceptor.js +48 -0
  80. package/dist/esm/nest/nest-logger.d.ts +2 -0
  81. package/dist/esm/nest/nest-logger.js +9 -0
  82. package/dist/esm/nest/options.d.ts +24 -0
  83. package/dist/esm/nest/options.js +1 -0
  84. package/dist/esm/nest/tenant-scope.d.ts +4 -0
  85. package/dist/esm/nest/tenant-scope.js +10 -0
  86. package/dist/esm/package.json +1 -0
  87. package/package.json +80 -0
@@ -0,0 +1,24 @@
1
+ import type { ModuleMetadata } from '@nestjs/common';
2
+ export declare const BASTION_OPTIONS: unique symbol;
3
+ export interface BastionModuleOptions {
4
+ /** `BASTION_URL` — base URL, e.g. `http://bastion:3001`. */
5
+ baseUrl: string;
6
+ /** `BASTION_APP_SLUG` — this service's App slug. Machine tokens must carry it as `serviceSlug`. */
7
+ serviceSlug: string;
8
+ /** `BASTION_CLIENT_API_KEY` — needed for outbound calls (audit writes). Optional for verify-only consumers. */
9
+ apiKey?: string;
10
+ /** Required by Bastion when the app is `isGlobal` or registered in more than one tenant. */
11
+ tenantSlug?: string;
12
+ /** `BASTION_JWKS_TTL_MS`, default 300 000. */
13
+ jwksTtlMs?: number;
14
+ /** Expected `iss`. Default `'bastion'`; `null` disables the check. */
15
+ issuer?: string | null;
16
+ /** `BastionUserGuard`: which apps' user tokens are accepted. Default `['meridian']`. */
17
+ acceptedAppSlugs?: string[];
18
+ /** `BastionUserGuard`: which roles are accepted. Default `['ADMIN', 'OWNER', 'SUPER_ADMIN']`. */
19
+ acceptedRoles?: string[];
20
+ }
21
+ export interface BastionModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
22
+ useFactory: (...args: unknown[]) => BastionModuleOptions | Promise<BastionModuleOptions>;
23
+ inject?: unknown[];
24
+ }
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BASTION_OPTIONS = void 0;
4
+ exports.BASTION_OPTIONS = Symbol('BASTION_OPTIONS');
@@ -0,0 +1,4 @@
1
+ import type { UserJwtPayload } from '../core/types.js';
2
+ export declare const CROSS_TENANT_ROLE = "SUPER_ADMIN";
3
+ /** The tenant a console user may read: their own, or the requested one if they are SUPER_ADMIN. */
4
+ export declare function resolveTenantScope(user: UserJwtPayload, requested?: string): string | undefined;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CROSS_TENANT_ROLE = void 0;
4
+ exports.resolveTenantScope = resolveTenantScope;
5
+ const common_1 = require("@nestjs/common");
6
+ exports.CROSS_TENANT_ROLE = 'SUPER_ADMIN';
7
+ /** The tenant a console user may read: their own, or the requested one if they are SUPER_ADMIN. */
8
+ function resolveTenantScope(user, requested) {
9
+ if (user.role === exports.CROSS_TENANT_ROLE)
10
+ return requested;
11
+ if (!user.tenantId)
12
+ throw new common_1.ForbiddenException('Token without tenant scope');
13
+ return user.tenantId;
14
+ }
@@ -0,0 +1,32 @@
1
+ import type { AuditEventInput, TokenResponse } from './types.js';
2
+ export interface BastionHttpOptions {
3
+ baseUrl: string;
4
+ fetch?: typeof fetch;
5
+ }
6
+ export interface ClientAuthInput {
7
+ apiKey: string;
8
+ serviceSlug: string;
9
+ tenantSlug?: string;
10
+ }
11
+ /**
12
+ * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
13
+ * `JwksVerifier`. `call` is public so a service can reach any other route
14
+ * (e.g. `POST /auth/login` from a BFF) with the same error handling; pass the
15
+ * browser's IP as `X-Real-IP` on user-facing calls so Bastion's rate-limit
16
+ * and audit rows see the real client, not your container.
17
+ */
18
+ export declare class BastionHttp {
19
+ private readonly baseUrl;
20
+ private readonly fetchImpl;
21
+ constructor(options: BastionHttpOptions);
22
+ call<T>(method: string, path: string, body?: unknown, init?: {
23
+ token?: string;
24
+ headers?: Record<string, string>;
25
+ }): Promise<T>;
26
+ clientAuth(input: ClientAuthInput): Promise<TokenResponse>;
27
+ /** `POST /events`. Bastion stores the event as `<serviceSlug>.<event>`; send the bare `resource.action`. */
28
+ writeAuditEvent(token: string, data: AuditEventInput): Promise<{
29
+ id: string;
30
+ createdAt: string;
31
+ }>;
32
+ }
@@ -0,0 +1,43 @@
1
+ import { BastionHttpError } from './errors.js';
2
+ /**
3
+ * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
4
+ * `JwksVerifier`. `call` is public so a service can reach any other route
5
+ * (e.g. `POST /auth/login` from a BFF) with the same error handling; pass the
6
+ * browser's IP as `X-Real-IP` on user-facing calls so Bastion's rate-limit
7
+ * and audit rows see the real client, not your container.
8
+ */
9
+ export class BastionHttp {
10
+ baseUrl;
11
+ fetchImpl;
12
+ constructor(options) {
13
+ this.baseUrl = options.baseUrl.replace(/\/$/, '');
14
+ this.fetchImpl = options.fetch ?? fetch;
15
+ }
16
+ async call(method, path, body, init = {}) {
17
+ const headers = {
18
+ 'Content-Type': 'application/json',
19
+ ...init.headers,
20
+ };
21
+ if (init.token)
22
+ headers['Authorization'] = `Bearer ${init.token}`;
23
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
24
+ method,
25
+ headers,
26
+ body: body === undefined ? undefined : JSON.stringify(body),
27
+ });
28
+ if (!res.ok) {
29
+ const err = (await res.json().catch(() => ({})));
30
+ throw new BastionHttpError(res.status, err.message ?? 'Bastion error');
31
+ }
32
+ if (res.status === 204)
33
+ return undefined;
34
+ return (await res.json());
35
+ }
36
+ clientAuth(input) {
37
+ return this.call('POST', '/auth/client', input);
38
+ }
39
+ /** `POST /events`. Bastion stores the event as `<serviceSlug>.<event>`; send the bare `resource.action`. */
40
+ writeAuditEvent(token, data) {
41
+ return this.call('POST', '/events', data, { token });
42
+ }
43
+ }
@@ -0,0 +1,11 @@
1
+ export type TokenErrorCode = 'malformed' | 'missing_kid' | 'unknown_kid' | 'invalid';
2
+ /** Verification failure. `code` is safe to log; the message is safe to return to the caller. */
3
+ export declare class BastionTokenError extends Error {
4
+ readonly code: TokenErrorCode;
5
+ constructor(code: TokenErrorCode, message: string);
6
+ }
7
+ /** A non-2xx answer from Bastion's HTTP API. */
8
+ export declare class BastionHttpError extends Error {
9
+ readonly status: number;
10
+ constructor(status: number, message: string);
11
+ }
@@ -0,0 +1,18 @@
1
+ /** Verification failure. `code` is safe to log; the message is safe to return to the caller. */
2
+ export class BastionTokenError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = 'BastionTokenError';
8
+ }
9
+ }
10
+ /** A non-2xx answer from Bastion's HTTP API. */
11
+ export class BastionHttpError extends Error {
12
+ status;
13
+ constructor(status, message) {
14
+ super(message);
15
+ this.status = status;
16
+ this.name = 'BastionHttpError';
17
+ }
18
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './errors.js';
3
+ 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';
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './errors.js';
3
+ export { JwksVerifier } from './jwks-verifier.js';
4
+ export { BastionHttp } from './bastion-http.js';
5
+ export { ServiceTokenProvider } from './service-token-provider.js';
@@ -0,0 +1,35 @@
1
+ import { type BastionJwtPayload, type BastionLogger } from './types.js';
2
+ export interface JwksVerifierOptions {
3
+ /** Bastion base URL, e.g. `http://bastion:3001`. The JWKS lives at `/.well-known/jwks.json`. */
4
+ baseUrl: string;
5
+ /** How long a fetched key set is trusted before a refetch. Default 300 000 (5 min), matching Bastion's `Cache-Control`. */
6
+ ttlMs?: number;
7
+ /** Expected `iss` claim. Default `'bastion'`. Pass `null` to skip the check. */
8
+ issuer?: string | null;
9
+ logger?: BastionLogger;
10
+ fetch?: typeof fetch;
11
+ }
12
+ /**
13
+ * The canonical consumer of Bastion's JWKS (docs/BASTION_INTEGRATION.md):
14
+ * keys cached and indexed by `kid`, refetch on an unknown kid with a
15
+ * cooldown, stale cache served when a refetch fails. Second safety net only:
16
+ * Bastion publishes a key before signing with it (publish-before-use), so a
17
+ * correctly configured consumer should never meet an unknown kid.
18
+ */
19
+ export declare class JwksVerifier {
20
+ private keys;
21
+ private fetchedAt;
22
+ private lastUnknownKidRefetchAt;
23
+ private inFlight;
24
+ private readonly jwksUrl;
25
+ private readonly ttlMs;
26
+ private readonly issuer;
27
+ private readonly logger;
28
+ private readonly fetchImpl;
29
+ constructor(options: JwksVerifierOptions);
30
+ verify(token: string): Promise<BastionJwtPayload>;
31
+ /** Single-flight: concurrent verifies during a refetch share one HTTP call. */
32
+ private fetchKeys;
33
+ private ensureFresh;
34
+ private refetchForUnknownKid;
35
+ }
@@ -0,0 +1,114 @@
1
+ import { decodeProtectedHeader, importJWK, jwtVerify, } from 'jose';
2
+ import { BastionTokenError } from './errors.js';
3
+ import { silentLogger } from './types.js';
4
+ /**
5
+ * Refetch cooldown when a token names a `kid` we do not have. The kid comes
6
+ * from an unverified header, so it is attacker-controlled: without a
7
+ * cooldown a flood of random kids becomes a request amplifier against
8
+ * Bastion's JWKS route.
9
+ */
10
+ const UNKNOWN_KID_REFETCH_COOLDOWN_MS = 30_000;
11
+ /**
12
+ * The canonical consumer of Bastion's JWKS (docs/BASTION_INTEGRATION.md):
13
+ * keys cached and indexed by `kid`, refetch on an unknown kid with a
14
+ * cooldown, stale cache served when a refetch fails. Second safety net only:
15
+ * Bastion publishes a key before signing with it (publish-before-use), so a
16
+ * correctly configured consumer should never meet an unknown kid.
17
+ */
18
+ export class JwksVerifier {
19
+ keys = new Map();
20
+ fetchedAt = 0;
21
+ lastUnknownKidRefetchAt = 0;
22
+ inFlight = null;
23
+ jwksUrl;
24
+ ttlMs;
25
+ issuer;
26
+ logger;
27
+ fetchImpl;
28
+ constructor(options) {
29
+ this.jwksUrl = `${options.baseUrl.replace(/\/$/, '')}/.well-known/jwks.json`;
30
+ this.ttlMs = options.ttlMs ?? 300_000;
31
+ this.issuer = options.issuer === undefined ? 'bastion' : options.issuer;
32
+ this.logger = options.logger ?? silentLogger;
33
+ this.fetchImpl = options.fetch ?? fetch;
34
+ }
35
+ async verify(token) {
36
+ let kid;
37
+ try {
38
+ ({ kid } = decodeProtectedHeader(token));
39
+ }
40
+ catch {
41
+ throw new BastionTokenError('malformed', 'Malformed token');
42
+ }
43
+ if (!kid)
44
+ throw new BastionTokenError('missing_kid', 'Missing kid in JWT header');
45
+ await this.ensureFresh();
46
+ let key = this.keys.get(kid);
47
+ if (!key) {
48
+ await this.refetchForUnknownKid();
49
+ key = this.keys.get(kid);
50
+ }
51
+ if (!key) {
52
+ this.logger.warn(`JWT verification failed: unknown kid=${kid}`);
53
+ throw new BastionTokenError('unknown_kid', 'Unknown or expired kid');
54
+ }
55
+ try {
56
+ const { payload } = await jwtVerify(token, key, {
57
+ algorithms: ['RS256'],
58
+ ...(this.issuer ? { issuer: this.issuer } : {}),
59
+ });
60
+ return payload;
61
+ }
62
+ catch {
63
+ throw new BastionTokenError('invalid', 'Invalid token');
64
+ }
65
+ }
66
+ /** Single-flight: concurrent verifies during a refetch share one HTTP call. */
67
+ fetchKeys() {
68
+ this.inFlight ??= (async () => {
69
+ try {
70
+ const res = await this.fetchImpl(this.jwksUrl);
71
+ if (!res.ok)
72
+ throw new Error(`JWKS fetch failed: ${res.status}`);
73
+ const { keys } = (await res.json());
74
+ const next = new Map();
75
+ for (const jwk of keys) {
76
+ if (!jwk.kid)
77
+ continue;
78
+ next.set(jwk.kid, await importJWK(jwk, 'RS256'));
79
+ }
80
+ this.keys = next;
81
+ this.fetchedAt = Date.now();
82
+ }
83
+ finally {
84
+ this.inFlight = null;
85
+ }
86
+ })();
87
+ return this.inFlight;
88
+ }
89
+ async ensureFresh() {
90
+ const stale = Date.now() - this.fetchedAt >= this.ttlMs;
91
+ if (!stale && this.keys.size)
92
+ return;
93
+ try {
94
+ await this.fetchKeys();
95
+ }
96
+ catch (err) {
97
+ if (!this.keys.size)
98
+ throw err;
99
+ this.logger.warn(`JWKS fetch failed, serving stale cache: ${err.message}`);
100
+ }
101
+ }
102
+ async refetchForUnknownKid() {
103
+ const now = Date.now();
104
+ if (now - this.lastUnknownKidRefetchAt < UNKNOWN_KID_REFETCH_COOLDOWN_MS)
105
+ return;
106
+ this.lastUnknownKidRefetchAt = now;
107
+ try {
108
+ await this.fetchKeys();
109
+ }
110
+ catch (err) {
111
+ this.logger.warn(`JWKS refetch for unknown kid failed: ${err.message}`);
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,21 @@
1
+ import type { BastionHttp, ClientAuthInput } from './bastion-http.js';
2
+ import { type BastionLogger } from './types.js';
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.
8
+ */
9
+ export declare class ServiceTokenProvider {
10
+ private readonly http;
11
+ private readonly credentials;
12
+ 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 refresh;
21
+ }
@@ -0,0 +1,43 @@
1
+ import { decodeJwt } from 'jose';
2
+ import { silentLogger } from './types.js';
3
+ const REFRESH_MARGIN_MS = 5 * 60 * 1000;
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
+ export class ServiceTokenProvider {
12
+ http;
13
+ credentials;
14
+ 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;
20
+ constructor(http, credentials, logger = silentLogger) {
21
+ this.http = http;
22
+ this.credentials = credentials;
23
+ this.logger = logger;
24
+ }
25
+ getToken() {
26
+ if (this.token && Date.now() < this.expiresAt - REFRESH_MARGIN_MS) {
27
+ return Promise.resolve(this.token);
28
+ }
29
+ this.inFlight ??= this.refresh().finally(() => {
30
+ this.inFlight = null;
31
+ });
32
+ return this.inFlight;
33
+ }
34
+ async refresh() {
35
+ const { accessToken } = await this.http.clientAuth(this.credentials);
36
+ 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');
41
+ return accessToken;
42
+ }
43
+ }
@@ -0,0 +1,48 @@
1
+ /** Claims Bastion puts on every RS256 token it signs (see bastion/CLAUDE.md, "JWT payload"). */
2
+ interface CommonClaims {
3
+ sub: string;
4
+ tenantId: string;
5
+ tenantSlug: string;
6
+ iss?: string;
7
+ aud?: string;
8
+ jti?: string;
9
+ iat: number;
10
+ exp: number;
11
+ }
12
+ /** A user session token: `POST /auth/login`, `/auth/refresh`, `/auth/exchange`. Never carries `type`. */
13
+ export interface UserJwtPayload extends CommonClaims {
14
+ type?: undefined;
15
+ email: string | null;
16
+ username: string | null;
17
+ image: string | null;
18
+ preferredLocale: string | null;
19
+ appSlug: string;
20
+ role: string;
21
+ permissions: string[];
22
+ sessionId?: string;
23
+ }
24
+ /** A machine token from `POST /auth/client`. Discriminated by `type`. */
25
+ export interface ServiceClientJwtPayload extends CommonClaims {
26
+ type: 'service_client';
27
+ clientName: string;
28
+ serviceSlug: string;
29
+ scopes: string[];
30
+ }
31
+ export type BastionJwtPayload = UserJwtPayload | ServiceClientJwtPayload;
32
+ export declare function isServiceClientToken(payload: BastionJwtPayload): payload is ServiceClientJwtPayload;
33
+ export interface TokenResponse {
34
+ accessToken: string;
35
+ }
36
+ export interface AuditEventInput {
37
+ event: string;
38
+ userId?: string;
39
+ metadata?: Record<string, unknown>;
40
+ }
41
+ /** Minimal logger so the core never depends on a framework. Defaults to silent. */
42
+ export interface BastionLogger {
43
+ warn(message: string): void;
44
+ error(message: string): void;
45
+ log(message: string): void;
46
+ }
47
+ export declare const silentLogger: BastionLogger;
48
+ export {};
@@ -0,0 +1,8 @@
1
+ export function isServiceClientToken(payload) {
2
+ return payload.type === 'service_client';
3
+ }
4
+ export const silentLogger = {
5
+ warn: () => undefined,
6
+ error: () => undefined,
7
+ log: () => undefined,
8
+ };
@@ -0,0 +1,29 @@
1
+ import { OnModuleInit } from '@nestjs/common';
2
+ import type { UserJwtPayload } from '../core/types.js';
3
+ import { BastionService } from './bastion.service.js';
4
+ import { type BastionModuleOptions } from './options.js';
5
+ /**
6
+ * Fire-and-forget audit writes to `POST /events` under this service's own
7
+ * machine token. Never throws: a failed write is logged, the request that
8
+ * produced it is not affected.
9
+ */
10
+ export declare class BastionAuditService implements OnModuleInit {
11
+ private readonly bastion;
12
+ private readonly logger;
13
+ private readonly tokens;
14
+ private tenantMismatchWarned;
15
+ constructor(bastion: BastionService, options: BastionModuleOptions);
16
+ onModuleInit(): Promise<void>;
17
+ /** Tenant of this service's machine token, once known. */
18
+ get tenantId(): string | null;
19
+ write(event: string, opts?: {
20
+ userId?: string;
21
+ metadata?: Record<string, unknown>;
22
+ }): Promise<void>;
23
+ /**
24
+ * Attribute an event to an admin user. `userId` is only set when the actor
25
+ * belongs to the same tenant as this service's token — Bastion rejects a
26
+ * foreign userId silently — otherwise the actor goes into `metadata`.
27
+ */
28
+ writeAsAdmin(event: string, actor: UserJwtPayload | undefined, metadata?: Record<string, unknown>): Promise<void>;
29
+ }
@@ -0,0 +1,94 @@
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
+ var BastionAuditService_1;
14
+ import { Inject, Injectable, Logger } from '@nestjs/common';
15
+ import { ServiceTokenProvider } from '../core/service-token-provider.js';
16
+ import { BastionService } from './bastion.service.js';
17
+ import { BASTION_OPTIONS } from './options.js';
18
+ import { nestLogger } from './nest-logger.js';
19
+ /**
20
+ * Fire-and-forget audit writes to `POST /events` under this service's own
21
+ * machine token. Never throws: a failed write is logged, the request that
22
+ * produced it is not affected.
23
+ */
24
+ let BastionAuditService = BastionAuditService_1 = class BastionAuditService {
25
+ bastion;
26
+ logger = new Logger(BastionAuditService_1.name);
27
+ tokens;
28
+ tenantMismatchWarned = false;
29
+ constructor(bastion, options) {
30
+ this.bastion = bastion;
31
+ this.tokens = options.apiKey
32
+ ? new ServiceTokenProvider(bastion.http, {
33
+ apiKey: options.apiKey,
34
+ serviceSlug: options.serviceSlug,
35
+ ...(options.tenantSlug ? { tenantSlug: options.tenantSlug } : {}),
36
+ }, nestLogger(BastionAuditService_1.name))
37
+ : null;
38
+ }
39
+ async onModuleInit() {
40
+ if (!this.tokens)
41
+ return;
42
+ await this.tokens.getToken().catch((err) => this.logger.error(`Bastion unreachable at startup, will retry on first write: ${err.message}`));
43
+ }
44
+ /** Tenant of this service's machine token, once known. */
45
+ get tenantId() {
46
+ return this.tokens?.tenantId ?? null;
47
+ }
48
+ async write(event, opts = {}) {
49
+ if (!this.tokens) {
50
+ this.logger.warn(`audit write skipped, no apiKey configured event=${event}`);
51
+ return;
52
+ }
53
+ let token;
54
+ try {
55
+ token = await this.tokens.getToken();
56
+ }
57
+ catch (err) {
58
+ this.logger.error(`audit write skipped, no service client token event=${event}: ${err.message}`);
59
+ return;
60
+ }
61
+ await this.bastion
62
+ .writeAuditEvent(token, { event, ...opts })
63
+ .catch((err) => this.logger.error(`audit write failed event=${event}: ${err.message}`));
64
+ }
65
+ /**
66
+ * Attribute an event to an admin user. `userId` is only set when the actor
67
+ * belongs to the same tenant as this service's token — Bastion rejects a
68
+ * foreign userId silently — otherwise the actor goes into `metadata`.
69
+ */
70
+ async writeAsAdmin(event, actor, metadata = {}) {
71
+ await this.tokens?.getToken().catch(() => undefined);
72
+ const sameTenant = Boolean(actor?.sub) && Boolean(this.tenantId) && actor?.tenantId === this.tenantId;
73
+ if (sameTenant && actor)
74
+ return this.write(event, { userId: actor.sub, metadata });
75
+ if (actor && !this.tenantMismatchWarned) {
76
+ this.tenantMismatchWarned = true;
77
+ this.logger.warn(`admin tenant (${actor.tenantSlug ?? actor.tenantId}) differs from this service's tenant (${this.tenantId ?? 'unknown'}) — audit events fall back to actor metadata instead of userId`);
78
+ }
79
+ return this.write(event, {
80
+ metadata: {
81
+ ...metadata,
82
+ actorId: actor?.sub ?? 'unknown',
83
+ actorRole: actor?.role ?? 'unknown',
84
+ actorAppSlug: actor?.appSlug ?? 'unknown',
85
+ },
86
+ });
87
+ }
88
+ };
89
+ BastionAuditService = BastionAuditService_1 = __decorate([
90
+ Injectable(),
91
+ __param(1, Inject(BASTION_OPTIONS)),
92
+ __metadata("design:paramtypes", [BastionService, Object])
93
+ ], BastionAuditService);
94
+ export { BastionAuditService };
@@ -0,0 +1,8 @@
1
+ import type { BastionJwtPayload } from '../core/types.js';
2
+ import { type BastionModuleOptions } from './options.js';
3
+ /** Verifies inbound Bastion tokens against the cached JWKS. Throws `UnauthorizedException` on any failure. */
4
+ export declare class BastionJwksService {
5
+ private readonly verifier;
6
+ constructor(options: BastionModuleOptions);
7
+ verify(token: string): Promise<BastionJwtPayload>;
8
+ }
@@ -0,0 +1,46 @@
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
+ var BastionJwksService_1;
14
+ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
15
+ import { JwksVerifier } from '../core/jwks-verifier.js';
16
+ import { BastionTokenError } from '../core/errors.js';
17
+ import { BASTION_OPTIONS } from './options.js';
18
+ import { nestLogger } from './nest-logger.js';
19
+ /** Verifies inbound Bastion tokens against the cached JWKS. Throws `UnauthorizedException` on any failure. */
20
+ let BastionJwksService = BastionJwksService_1 = class BastionJwksService {
21
+ verifier;
22
+ constructor(options) {
23
+ this.verifier = new JwksVerifier({
24
+ baseUrl: options.baseUrl,
25
+ ttlMs: options.jwksTtlMs,
26
+ issuer: options.issuer,
27
+ logger: nestLogger(BastionJwksService_1.name),
28
+ });
29
+ }
30
+ async verify(token) {
31
+ try {
32
+ return await this.verifier.verify(token);
33
+ }
34
+ catch (err) {
35
+ if (err instanceof BastionTokenError)
36
+ throw new UnauthorizedException(err.message);
37
+ throw err;
38
+ }
39
+ }
40
+ };
41
+ BastionJwksService = BastionJwksService_1 = __decorate([
42
+ Injectable(),
43
+ __param(0, Inject(BASTION_OPTIONS)),
44
+ __metadata("design:paramtypes", [Object])
45
+ ], BastionJwksService);
46
+ export { BastionJwksService };
@@ -0,0 +1,20 @@
1
+ import { DynamicModule } from '@nestjs/common';
2
+ import { type BastionModuleAsyncOptions, type BastionModuleOptions } from './options.js';
3
+ /**
4
+ * ```ts
5
+ * BastionModule.forRootAsync({
6
+ * inject: [ConfigService],
7
+ * useFactory: (config: ConfigService) => ({
8
+ * baseUrl: config.getOrThrow('BASTION_URL'),
9
+ * serviceSlug: config.getOrThrow('BASTION_APP_SLUG'),
10
+ * apiKey: config.get('BASTION_CLIENT_API_KEY'),
11
+ * }),
12
+ * })
13
+ * ```
14
+ * Global: import once in `AppModule`, inject anywhere.
15
+ */
16
+ export declare class BastionModule {
17
+ static forRoot(options: BastionModuleOptions): DynamicModule;
18
+ static forRootAsync(options: BastionModuleAsyncOptions): DynamicModule;
19
+ private static build;
20
+ }