@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
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ import type { BastionHttp } from './bastion-http.js';
2
+ import { type BastionLogger } from './types.js';
3
+ import type { BastionAgeVerificationResult, BastionAuthMethods, BastionClientContext, BastionEmailChangeRequest, BastionLinkTicket, BastionLoginResult, BastionMe, BastionProfile, BastionProfileUpdate, BastionRegisterInput, BastionSession, BastionSocialAccount, BastionTokenPair, BastionTwoFactorConfirmed, BastionTwoFactorSetup, BastionTwoFactorStatus, BastionUserAuditEventsPage } from './api-types.js';
4
+ export interface BastionApiClientOptions {
5
+ http: BastionHttp;
6
+ /** This app's slug — sent as `appSlug` on every user-facing call. */
7
+ appSlug: string;
8
+ /** Required by Bastion when the app is registered in more than one tenant, or is `isGlobal`. */
9
+ tenantSlug?: string;
10
+ logger?: BastionLogger;
11
+ }
12
+ /**
13
+ * The user-facing half of Bastion's API, for an app backend that proxies its
14
+ * users' auth: login, register, OAuth exchange, 2FA, refresh, logout,
15
+ * sessions, `/me`, profile, social accounts, email/password flows, export,
16
+ * self-deletion. Every call takes the user's `accessToken` where Bastion
17
+ * needs one; nothing here holds state about who the user is.
18
+ *
19
+ * `refresh` is single-flight per refresh token and remembers each answer for
20
+ * 60 s: Bastion rotates the token on every use and treats a replay as
21
+ * theft (the whole family is revoked), so two concurrent refreshes with the
22
+ * same token from a racing browser must collapse into one call. `logout`
23
+ * follows the rotation chain so it revokes the token the browser is actually
24
+ * holding, not the one it started with.
25
+ */
26
+ export declare class BastionApiClient {
27
+ private readonly http;
28
+ private readonly appSlug;
29
+ private readonly tenantSlug?;
30
+ private readonly logger;
31
+ private readonly inFlight;
32
+ private readonly recent;
33
+ private readonly successors;
34
+ private authMethods?;
35
+ constructor(options: BastionApiClientOptions);
36
+ private get appContext();
37
+ private static clientHeaders;
38
+ private call;
39
+ private static hash;
40
+ login(email: string, password: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
41
+ register(input: BastionRegisterInput, ctx?: BastionClientContext): Promise<{
42
+ message: string;
43
+ }>;
44
+ /** Trades the one-time OAuth code for tokens. */
45
+ exchange(code: string, ctx?: BastionClientContext): Promise<BastionLoginResult>;
46
+ completeTwoFactor(twoFactorToken: string, code: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
47
+ refresh(refreshToken: string, ctx?: BastionClientContext): Promise<BastionTokenPair>;
48
+ /** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
49
+ logout(refreshToken: string | null | undefined): Promise<void>;
50
+ listSessions(accessToken: string): Promise<BastionSession[]>;
51
+ revokeSession(accessToken: string, sessionId: string): Promise<void>;
52
+ revokeOtherSessions(accessToken: string): Promise<{
53
+ count: number;
54
+ }>;
55
+ me(accessToken: string): Promise<BastionMe>;
56
+ /** Answers the updated columns only (`BastionProfile`), not the full `BastionMe`. */
57
+ updateProfile(accessToken: string, update: BastionProfileUpdate): Promise<BastionProfile>;
58
+ completeAgeVerification(accessToken: string, birthDate: string): Promise<BastionAgeVerificationResult>;
59
+ /** Cached 5 min; a failed refetch serves the cached answer. */
60
+ getAuthMethods(): Promise<BastionAuthMethods>;
61
+ twoFactorStatus(accessToken: string): Promise<BastionTwoFactorStatus>;
62
+ twoFactorSetup(accessToken: string, currentPassword?: string): Promise<BastionTwoFactorSetup>;
63
+ twoFactorConfirm(accessToken: string, code: string, currentPassword?: string): Promise<BastionTwoFactorConfirmed>;
64
+ twoFactorDisable(accessToken: string, code: string, currentPassword?: string): Promise<{
65
+ message: string;
66
+ }>;
67
+ listSocialAccounts(accessToken: string): Promise<BastionSocialAccount[]>;
68
+ createSocialAccountLinkTicket(accessToken: string, provider: string): Promise<BastionLinkTicket>;
69
+ unlinkSocialAccount(accessToken: string, provider: string): Promise<void>;
70
+ updateSocialAccountSyncAvatar(accessToken: string, provider: string, syncAvatar: boolean): Promise<BastionSocialAccount>;
71
+ resendVerification(email: string): Promise<void>;
72
+ verifyEmail(token: string): Promise<void>;
73
+ forgotPassword(email: string): Promise<void>;
74
+ resetPassword(token: string, newPassword: string): Promise<void>;
75
+ changePassword(accessToken: string, currentPassword: string, newPassword: string): Promise<void>;
76
+ requestEmailChange(accessToken: string, input: BastionEmailChangeRequest): Promise<{
77
+ message: string;
78
+ }>;
79
+ confirmEmailChange(token: string): Promise<{
80
+ message: string;
81
+ }>;
82
+ revokeEmailChange(token: string): Promise<{
83
+ message: string;
84
+ }>;
85
+ /** The user's own audit trail (`GET /auth/me/events`), paginated. */
86
+ listMyEvents(accessToken: string, page?: {
87
+ page?: number;
88
+ limit?: number;
89
+ }): Promise<BastionUserAuditEventsPage>;
90
+ exportData(accessToken: string): Promise<Record<string, unknown>>;
91
+ /** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
92
+ deleteSelf(accessToken: string, stepUp?: {
93
+ currentPassword?: string;
94
+ twoFactorCode?: string;
95
+ }): Promise<void>;
96
+ private latestRefreshToken;
97
+ private prune;
98
+ }
@@ -0,0 +1,222 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { silentLogger } from './types.js';
3
+ const REFRESH_CACHE_MS = 60_000;
4
+ const AUTH_METHODS_CACHE_MS = 5 * 60_000;
5
+ const MAX_ROTATION_HOPS = 32;
6
+ /**
7
+ * The user-facing half of Bastion's API, for an app backend that proxies its
8
+ * users' auth: login, register, OAuth exchange, 2FA, refresh, logout,
9
+ * sessions, `/me`, profile, social accounts, email/password flows, export,
10
+ * self-deletion. Every call takes the user's `accessToken` where Bastion
11
+ * needs one; nothing here holds state about who the user is.
12
+ *
13
+ * `refresh` is single-flight per refresh token and remembers each answer for
14
+ * 60 s: Bastion rotates the token on every use and treats a replay as
15
+ * theft (the whole family is revoked), so two concurrent refreshes with the
16
+ * same token from a racing browser must collapse into one call. `logout`
17
+ * follows the rotation chain so it revokes the token the browser is actually
18
+ * holding, not the one it started with.
19
+ */
20
+ export class BastionApiClient {
21
+ http;
22
+ appSlug;
23
+ tenantSlug;
24
+ logger;
25
+ inFlight = new Map();
26
+ recent = new Map();
27
+ successors = new Map();
28
+ authMethods;
29
+ constructor(options) {
30
+ this.http = options.http;
31
+ this.appSlug = options.appSlug;
32
+ this.tenantSlug = options.tenantSlug || undefined;
33
+ this.logger = options.logger ?? silentLogger;
34
+ }
35
+ get appContext() {
36
+ return { appSlug: this.appSlug, ...(this.tenantSlug ? { tenantSlug: this.tenantSlug } : {}) };
37
+ }
38
+ static clientHeaders(ctx) {
39
+ const headers = {};
40
+ if (ctx?.userAgent)
41
+ headers['User-Agent'] = ctx.userAgent;
42
+ if (ctx?.ip)
43
+ headers['X-Real-IP'] = ctx.ip;
44
+ return headers;
45
+ }
46
+ call(method, path, body, init) {
47
+ return this.http.call(method, path, body, init);
48
+ }
49
+ static hash(token) {
50
+ return createHash('sha256').update(token).digest('hex');
51
+ }
52
+ // ── session ──────────────────────────────────────────────────────────────
53
+ login(email, password, ctx) {
54
+ return this.call('POST', '/auth/login', { email, password, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
55
+ }
56
+ register(input, ctx) {
57
+ return this.call('POST', '/auth/register', { ...input, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) });
58
+ }
59
+ /** Trades the one-time OAuth code for tokens. */
60
+ exchange(code, ctx) {
61
+ return this.call('POST', '/auth/exchange', { code, appSlug: this.appSlug }, { headers: BastionApiClient.clientHeaders(ctx) });
62
+ }
63
+ completeTwoFactor(twoFactorToken, code, ctx) {
64
+ return this.call('POST', '/auth/2fa/authenticate', { twoFactorToken, code }, { headers: BastionApiClient.clientHeaders(ctx) });
65
+ }
66
+ async refresh(refreshToken, ctx) {
67
+ const key = BastionApiClient.hash(refreshToken);
68
+ this.prune(Date.now());
69
+ const cached = this.recent.get(key);
70
+ if (cached)
71
+ return cached.value;
72
+ const running = this.inFlight.get(key);
73
+ if (running)
74
+ return running;
75
+ const pending = this.call('POST', '/auth/refresh', { refreshToken, ...this.appContext }, { headers: BastionApiClient.clientHeaders(ctx) })
76
+ .then((value) => {
77
+ const at = Date.now();
78
+ this.recent.set(key, { value, at });
79
+ this.successors.set(key, { next: value.refreshToken, at });
80
+ return value;
81
+ })
82
+ .finally(() => this.inFlight.delete(key));
83
+ this.inFlight.set(key, pending);
84
+ return pending;
85
+ }
86
+ /** Revokes the token and any successor issued through `refresh` in the last minute. Never throws. */
87
+ async logout(refreshToken) {
88
+ if (!refreshToken)
89
+ return;
90
+ const tokens = new Set([refreshToken, this.latestRefreshToken(refreshToken)]);
91
+ this.recent.delete(BastionApiClient.hash(refreshToken));
92
+ await Promise.all([...tokens].map((token) => this.call('POST', '/auth/logout', { refreshToken: token }).catch((err) => this.logger.warn(`bastion: logout failed: ${err.message}`))));
93
+ }
94
+ listSessions(accessToken) {
95
+ return this.call('GET', '/auth/sessions', undefined, { token: accessToken });
96
+ }
97
+ revokeSession(accessToken, sessionId) {
98
+ return this.call('DELETE', `/auth/sessions/${encodeURIComponent(sessionId)}`, undefined, { token: accessToken });
99
+ }
100
+ revokeOtherSessions(accessToken) {
101
+ return this.call('DELETE', '/auth/sessions', undefined, { token: accessToken });
102
+ }
103
+ // ── account ──────────────────────────────────────────────────────────────
104
+ me(accessToken) {
105
+ return this.call('GET', '/auth/me', undefined, { token: accessToken });
106
+ }
107
+ /** Answers the updated columns only (`BastionProfile`), not the full `BastionMe`. */
108
+ updateProfile(accessToken, update) {
109
+ return this.call('PATCH', '/auth/me', update, { token: accessToken });
110
+ }
111
+ completeAgeVerification(accessToken, birthDate) {
112
+ return this.call('POST', '/auth/age-verification', { birthDate }, { token: accessToken });
113
+ }
114
+ /** Cached 5 min; a failed refetch serves the cached answer. */
115
+ async getAuthMethods() {
116
+ const cached = this.authMethods;
117
+ if (cached && Date.now() - cached.at < AUTH_METHODS_CACHE_MS)
118
+ return cached.value;
119
+ const params = new URLSearchParams({ appSlug: this.appSlug });
120
+ if (this.tenantSlug)
121
+ params.set('tenantSlug', this.tenantSlug);
122
+ try {
123
+ const value = await this.call('GET', `/auth/methods?${params}`);
124
+ this.authMethods = { value, at: Date.now() };
125
+ return value;
126
+ }
127
+ catch (err) {
128
+ if (!cached)
129
+ throw err;
130
+ this.logger.warn(`bastion: /auth/methods failed (${err.message}), serving the cached answer`);
131
+ return cached.value;
132
+ }
133
+ }
134
+ // ── 2FA ──────────────────────────────────────────────────────────────────
135
+ twoFactorStatus(accessToken) {
136
+ return this.call('GET', '/auth/2fa/status', undefined, { token: accessToken });
137
+ }
138
+ twoFactorSetup(accessToken, currentPassword) {
139
+ return this.call('POST', '/auth/2fa/setup', currentPassword ? { currentPassword } : {}, { token: accessToken });
140
+ }
141
+ twoFactorConfirm(accessToken, code, currentPassword) {
142
+ return this.call('POST', '/auth/2fa/confirm', { code, ...(currentPassword ? { currentPassword } : {}) }, { token: accessToken });
143
+ }
144
+ twoFactorDisable(accessToken, code, currentPassword) {
145
+ return this.call('POST', '/auth/2fa/disable', { code, ...(currentPassword ? { currentPassword } : {}) }, { token: accessToken });
146
+ }
147
+ // ── social accounts ──────────────────────────────────────────────────────
148
+ listSocialAccounts(accessToken) {
149
+ return this.call('GET', '/auth/me/social-accounts', undefined, { token: accessToken });
150
+ }
151
+ createSocialAccountLinkTicket(accessToken, provider) {
152
+ return this.call('POST', `/auth/me/social-accounts/${encodeURIComponent(provider)}/link-ticket`, undefined, { token: accessToken });
153
+ }
154
+ unlinkSocialAccount(accessToken, provider) {
155
+ return this.call('DELETE', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, undefined, { token: accessToken });
156
+ }
157
+ updateSocialAccountSyncAvatar(accessToken, provider, syncAvatar) {
158
+ return this.call('PATCH', `/auth/me/social-accounts/${encodeURIComponent(provider)}`, { syncAvatar }, { token: accessToken });
159
+ }
160
+ // ── email & password ─────────────────────────────────────────────────────
161
+ resendVerification(email) {
162
+ return this.call('POST', '/auth/resend-verification', { email, ...this.appContext });
163
+ }
164
+ verifyEmail(token) {
165
+ return this.call('GET', `/auth/verify-email?token=${encodeURIComponent(token)}`);
166
+ }
167
+ forgotPassword(email) {
168
+ return this.call('POST', '/auth/forgot-password', { email, ...this.appContext });
169
+ }
170
+ resetPassword(token, newPassword) {
171
+ return this.call('POST', '/auth/reset-password', { token, newPassword });
172
+ }
173
+ changePassword(accessToken, currentPassword, newPassword) {
174
+ return this.call('PATCH', '/auth/me/password', { currentPassword, newPassword }, { token: accessToken });
175
+ }
176
+ requestEmailChange(accessToken, input) {
177
+ return this.call('PATCH', '/auth/me/email', input, { token: accessToken });
178
+ }
179
+ confirmEmailChange(token) {
180
+ return this.call('POST', '/auth/me/confirm-email', { token });
181
+ }
182
+ revokeEmailChange(token) {
183
+ return this.call('POST', '/auth/me/revoke-email-change', { token });
184
+ }
185
+ // ── GDPR ─────────────────────────────────────────────────────────────────
186
+ /** The user's own audit trail (`GET /auth/me/events`), paginated. */
187
+ listMyEvents(accessToken, page = {}) {
188
+ const params = new URLSearchParams();
189
+ if (page.page !== undefined)
190
+ params.set('page', String(page.page));
191
+ if (page.limit !== undefined)
192
+ params.set('limit', String(page.limit));
193
+ const qs = params.toString();
194
+ return this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
195
+ }
196
+ exportData(accessToken) {
197
+ return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
198
+ }
199
+ /** Step-up: `currentPassword` when the account has one, `twoFactorCode` when 2FA is on. */
200
+ deleteSelf(accessToken, stepUp = {}) {
201
+ return this.call('DELETE', '/auth/me', stepUp, { token: accessToken });
202
+ }
203
+ // ── internals ────────────────────────────────────────────────────────────
204
+ latestRefreshToken(refreshToken) {
205
+ let current = refreshToken;
206
+ for (let hop = 0; hop < MAX_ROTATION_HOPS; hop++) {
207
+ const next = this.successors.get(BastionApiClient.hash(current))?.next;
208
+ if (!next)
209
+ break;
210
+ current = next;
211
+ }
212
+ return current;
213
+ }
214
+ prune(now) {
215
+ for (const [key, entry] of this.recent)
216
+ if (now - entry.at >= REFRESH_CACHE_MS)
217
+ this.recent.delete(key);
218
+ for (const [key, entry] of this.successors)
219
+ if (now - entry.at >= REFRESH_CACHE_MS)
220
+ this.successors.delete(key);
221
+ }
222
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Bastion's public API contract as seen by an app backend. Field names and
3
+ * nullability mirror Bastion's DTOs; when Bastion changes one, it changes here.
4
+ */
5
+ export interface BastionTokenPair {
6
+ accessToken: string;
7
+ refreshToken: string;
8
+ /** ISO timestamp — the refresh token is opaque, this is the only way to learn its lifetime. */
9
+ refreshTokenExpiresAt?: string;
10
+ }
11
+ export interface BastionTwoFactorPending {
12
+ requiresTwoFactor: true;
13
+ twoFactorToken: string;
14
+ }
15
+ export type BastionLoginResult = BastionTokenPair | BastionTwoFactorPending;
16
+ export declare function isTwoFactorPending(result: BastionLoginResult): result is BastionTwoFactorPending;
17
+ /** `GET /auth/me`. */
18
+ export interface BastionMe {
19
+ id: string;
20
+ email: string;
21
+ username: string | null;
22
+ image: string | null;
23
+ preferredLocale: string | null;
24
+ emailVerified: boolean;
25
+ hasPassword: boolean;
26
+ role: string | null;
27
+ appSlug: string;
28
+ tenantId: string;
29
+ tenantSlug: string;
30
+ tenantName: string;
31
+ tenantActive: boolean;
32
+ apps: string[];
33
+ ageVerified: boolean;
34
+ ageVerificationRequired: boolean;
35
+ /** `null` = never screened, which is NOT "adult". */
36
+ isMinor: boolean | null;
37
+ }
38
+ export interface BastionSession {
39
+ id: string;
40
+ appSlug: string;
41
+ createdAt: string;
42
+ lastUsedAt: string | null;
43
+ userAgent: string | null;
44
+ ip: string | null;
45
+ isCurrent: boolean;
46
+ }
47
+ export interface BastionRegisterInput {
48
+ email: string;
49
+ password: string;
50
+ username: string;
51
+ preferredLocale?: string;
52
+ /** ISO `YYYY-MM-DD`, required only when the app has `minimumAge`. */
53
+ birthDate?: string;
54
+ }
55
+ export interface BastionAgeVerificationResult {
56
+ ageVerified: true;
57
+ isMinor: boolean;
58
+ }
59
+ /** What `PATCH /auth/me` answers: the updated columns, not the full `BastionMe`. */
60
+ export interface BastionProfile {
61
+ id: string;
62
+ email: string;
63
+ username: string | null;
64
+ image: string | null;
65
+ preferredLocale: string | null;
66
+ emailVerified: boolean;
67
+ }
68
+ export interface BastionProfileUpdate {
69
+ username?: string;
70
+ image?: string | null;
71
+ preferredLocale?: string;
72
+ }
73
+ export interface BastionEmailChangeRequest {
74
+ email: string;
75
+ currentPassword?: string;
76
+ twoFactorCode?: string;
77
+ }
78
+ export interface BastionTwoFactorStatus {
79
+ enabled: boolean;
80
+ }
81
+ export interface BastionTwoFactorSetup {
82
+ qrCodeDataUrl: string;
83
+ secret: string;
84
+ }
85
+ export interface BastionTwoFactorConfirmed {
86
+ recoveryCodes: string[];
87
+ }
88
+ export interface BastionTwoFactorRequest {
89
+ code?: string;
90
+ currentPassword?: string;
91
+ }
92
+ /** The end user behind a call: forwarded as `X-Real-IP` / `User-Agent` so Bastion's rate-limit and audit see the browser, not your container. */
93
+ export interface BastionClientContext {
94
+ ip?: string;
95
+ userAgent?: string;
96
+ }
97
+ export interface BastionSocialAccount {
98
+ provider: string;
99
+ username: string | null;
100
+ email: string | null;
101
+ avatar: string | null;
102
+ syncAvatar: boolean;
103
+ createdAt: string;
104
+ }
105
+ /** `GET /auth/methods`. */
106
+ export interface BastionAuthMethods {
107
+ tenantSlug: string;
108
+ appSlug: string;
109
+ password: {
110
+ login: boolean;
111
+ register: boolean;
112
+ };
113
+ oauth: {
114
+ provider: string;
115
+ authorizeUrl: string;
116
+ }[];
117
+ oauthSignup: boolean;
118
+ }
119
+ export interface BastionLinkTicket {
120
+ url: string;
121
+ }
122
+ /** One row of `GET /auth/me/events` — the user-facing projection, deliberately without `ip`/`userAgent`. */
123
+ export interface BastionUserAuditEvent {
124
+ id: string;
125
+ tenantId: string;
126
+ userId: string | null;
127
+ appSlug: string;
128
+ event: string;
129
+ source: 'BASTION' | 'APP';
130
+ metadata: unknown;
131
+ createdAt: string;
132
+ }
133
+ export interface BastionUserAuditEventsPage {
134
+ data: BastionUserAuditEvent[];
135
+ total: number;
136
+ page: number;
137
+ limit: number;
138
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Bastion's public API contract as seen by an app backend. Field names and
3
+ * nullability mirror Bastion's DTOs; when Bastion changes one, it changes here.
4
+ */
5
+ export function isTwoFactorPending(result) {
6
+ return result.requiresTwoFactor === true;
7
+ }
@@ -1,8 +1,15 @@
1
1
  import type { AuditEventInput, TokenResponse } from './types.js';
2
2
  export interface BastionHttpOptions {
3
3
  baseUrl: string;
4
+ /** Per-request timeout, default 8 000 ms. */
5
+ timeoutMs?: number;
4
6
  fetch?: typeof fetch;
5
7
  }
8
+ export interface BastionRequestInit {
9
+ token?: string;
10
+ headers?: Record<string, string>;
11
+ timeoutMs?: number;
12
+ }
6
13
  export interface ClientAuthInput {
7
14
  apiKey: string;
8
15
  serviceSlug: string;
@@ -10,19 +17,17 @@ export interface ClientAuthInput {
10
17
  }
11
18
  /**
12
19
  * 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.
20
+ * `JwksVerifier`. `call` is public so a service can reach any route with the
21
+ * same error handling: a non-2xx answer becomes `BastionHttpError` with the
22
+ * status and a stable `code`; Bastion unreachable or timed out is a 503
23
+ * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
17
24
  */
18
25
  export declare class BastionHttp {
19
26
  private readonly baseUrl;
27
+ private readonly timeoutMs;
20
28
  private readonly fetchImpl;
21
29
  constructor(options: BastionHttpOptions);
22
- call<T>(method: string, path: string, body?: unknown, init?: {
23
- token?: string;
24
- headers?: Record<string, string>;
25
- }): Promise<T>;
30
+ call<T>(method: string, path: string, body?: unknown, init?: BastionRequestInit): Promise<T>;
26
31
  clientAuth(input: ClientAuthInput): Promise<TokenResponse>;
27
32
  /** `POST /events`. Bastion stores the event as `<serviceSlug>.<event>`; send the bare `resource.action`. */
28
33
  writeAuditEvent(token: string, data: AuditEventInput): Promise<{
@@ -1,37 +1,49 @@
1
- import { BastionHttpError } from './errors.js';
1
+ import { BastionErrorCode, BastionHttpError } from './errors.js';
2
2
  /**
3
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.
4
+ * `JwksVerifier`. `call` is public so a service can reach any route with the
5
+ * same error handling: a non-2xx answer becomes `BastionHttpError` with the
6
+ * status and a stable `code`; Bastion unreachable or timed out is a 503
7
+ * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
8
8
  */
9
9
  export class BastionHttp {
10
10
  baseUrl;
11
+ timeoutMs;
11
12
  fetchImpl;
12
13
  constructor(options) {
13
14
  this.baseUrl = options.baseUrl.replace(/\/$/, '');
14
- this.fetchImpl = options.fetch ?? fetch;
15
+ this.timeoutMs = options.timeoutMs ?? 8_000;
16
+ // Late-bound on purpose: a consumer (or its tests) may replace global fetch after construction.
17
+ this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
15
18
  }
16
19
  async call(method, path, body, init = {}) {
17
- const headers = {
18
- 'Content-Type': 'application/json',
19
- ...init.headers,
20
- };
20
+ const headers = { ...init.headers };
21
+ if (body !== undefined)
22
+ headers['Content-Type'] = 'application/json';
21
23
  if (init.token)
22
24
  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
- });
25
+ let res;
26
+ try {
27
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
28
+ method,
29
+ headers,
30
+ body: body === undefined ? undefined : JSON.stringify(body),
31
+ signal: AbortSignal.timeout(init.timeoutMs ?? this.timeoutMs),
32
+ });
33
+ }
34
+ catch (err) {
35
+ throw new BastionHttpError(503, `Bastion unreachable: ${err.message}`, BastionErrorCode.UNAVAILABLE);
36
+ }
28
37
  if (!res.ok) {
29
- const err = (await res.json().catch(() => ({})));
30
- throw new BastionHttpError(res.status, err.message ?? 'Bastion error');
38
+ const parsed = (await res.json().catch(() => ({})));
39
+ const message = Array.isArray(parsed.message)
40
+ ? parsed.message.join(', ')
41
+ : (parsed.message ?? `Bastion error ${res.status}`);
42
+ throw new BastionHttpError(res.status, message);
31
43
  }
32
44
  if (res.status === 204)
33
45
  return undefined;
34
- return (await res.json());
46
+ return (await res.json().catch(() => undefined));
35
47
  }
36
48
  clientAuth(input) {
37
49
  return this.call('POST', '/auth/client', input);