@heyatom/bastion-client 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +31 -0
  2. package/dist/cjs/core/api-client.d.ts +98 -0
  3. package/dist/cjs/core/api-client.js +226 -0
  4. package/dist/cjs/core/api-types.d.ts +138 -0
  5. package/dist/cjs/core/api-types.js +10 -0
  6. package/dist/cjs/core/bastion-http.d.ts +13 -8
  7. package/dist/cjs/core/bastion-http.js +29 -17
  8. package/dist/cjs/core/errors.d.ts +23 -2
  9. package/dist/cjs/core/errors.js +45 -3
  10. package/dist/cjs/core/index.d.ts +4 -2
  11. package/dist/cjs/core/index.js +4 -1
  12. package/dist/cjs/core/jwks-verifier.js +2 -1
  13. package/dist/cjs/core/service-token-provider.d.ts +20 -12
  14. package/dist/cjs/core/service-token-provider.js +55 -27
  15. package/dist/cjs/nest/bastion-api-client.provider.d.ts +13 -0
  16. package/dist/cjs/nest/bastion-api-client.provider.js +43 -0
  17. package/dist/cjs/nest/bastion-http-exception.d.ts +19 -0
  18. package/dist/cjs/nest/bastion-http-exception.js +39 -0
  19. package/dist/cjs/nest/bastion.module.js +2 -0
  20. package/dist/cjs/nest/bastion.service.d.ts +5 -7
  21. package/dist/cjs/nest/bastion.service.js +5 -13
  22. package/dist/cjs/nest/index.d.ts +2 -0
  23. package/dist/cjs/nest/index.js +6 -1
  24. package/dist/cjs/nest/options.d.ts +2 -0
  25. package/dist/cjs/webhooks/index.d.ts +54 -0
  26. package/dist/cjs/webhooks/index.js +72 -0
  27. package/dist/esm/core/api-client.d.ts +98 -0
  28. package/dist/esm/core/api-client.js +222 -0
  29. package/dist/esm/core/api-types.d.ts +138 -0
  30. package/dist/esm/core/api-types.js +7 -0
  31. package/dist/esm/core/bastion-http.d.ts +13 -8
  32. package/dist/esm/core/bastion-http.js +30 -18
  33. package/dist/esm/core/errors.d.ts +23 -2
  34. package/dist/esm/core/errors.js +43 -2
  35. package/dist/esm/core/index.d.ts +4 -2
  36. package/dist/esm/core/index.js +2 -0
  37. package/dist/esm/core/jwks-verifier.js +2 -1
  38. package/dist/esm/core/service-token-provider.d.ts +20 -12
  39. package/dist/esm/core/service-token-provider.js +55 -27
  40. package/dist/esm/nest/bastion-api-client.provider.d.ts +13 -0
  41. package/dist/esm/nest/bastion-api-client.provider.js +40 -0
  42. package/dist/esm/nest/bastion-http-exception.d.ts +19 -0
  43. package/dist/esm/nest/bastion-http-exception.js +34 -0
  44. package/dist/esm/nest/bastion.module.js +2 -0
  45. package/dist/esm/nest/bastion.service.d.ts +5 -7
  46. package/dist/esm/nest/bastion.service.js +6 -14
  47. package/dist/esm/nest/index.d.ts +2 -0
  48. package/dist/esm/nest/index.js +2 -0
  49. package/dist/esm/nest/options.d.ts +2 -0
  50. package/dist/esm/webhooks/index.d.ts +54 -0
  51. package/dist/esm/webhooks/index.js +68 -0
  52. package/package.json +14 -1
@@ -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);
@@ -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
  }