@heyatom/bastion-client 0.9.0 → 1.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ Reads both the current Bastion answers and the `{ data }` envelope Bastion 2.0 will send, so you can upgrade before Bastion does.
6
+
7
+ ### Breaking
8
+
9
+ - `BastionHttp.call` (and so every `BastionApiClient`, `BastionService.call`, `ServiceTokenProvider` and `BastionHttpException` call) unwraps a success body whose only key is `data` and returns `data`. A page (`{ data, meta }`), an array, any other object and an empty body are returned unchanged. Three Bastion routes answer `{ data }` alone today (`GET /auth/me/tenants`, `GET /admin/apps/:slug/permissions`, `PUT /admin/apps/:slug/roles/:roleId/permissions`): through `call` they now give you the inner array. Read them as `Array.isArray(res) ? res : res.data`, which also works with any later Bastion.
10
+ - `listMyEvents` returns `{ data, meta: { total, page, limit, totalPages } }` (new type `BastionPageMeta`). An older Bastion's `{ data, total, page, limit }` is converted to this shape.
11
+ - `err.code` is the domain code for `PROVIDER_TAKEN`, `ALREADY_LINKED`, `OAUTH_EMAIL_TAKEN`, `AGE_ALREADY_VERIFIED` (previously `CONFLICT`), `AGE_GATE_NOT_CONFIGURED`, `CURRENT_PASSWORD_REQUIRED`, `TWO_FACTOR_CODE_REQUIRED`, `OAUTH_USER_INACTIVE`, `OAUTH_UNDER_MINIMUM_AGE` (previously `UPSTREAM`). `BastionHttpException` forwards that code to your client instead of `CONFLICT` / `BAD_REQUEST` / `FORBIDDEN`. When Bastion sends these codes in `code`, `upstreamCode` is no longer set for them.
12
+
13
+ ### Added
14
+
15
+ - The error codes above in `BastionErrorCode`.
16
+ - Bastion's `code` is used whenever it is a `BastionErrorCode` and `message` is not itself one, so the code survives a reworded `message`. Message-based classification stays as the fallback for a Bastion that sends no domain `code`.
17
+
3
18
  ## 0.9.0
4
19
 
5
20
  ### Added
package/README.md CHANGED
@@ -1,3 +1,14 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/heyatomdev/bastion-client/main/docs/brand/banner.png" width="500" alt="HeyAtom @heyatom/bastion-client" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@heyatom/bastion-client"><img src="https://img.shields.io/npm/v/@heyatom/bastion-client" alt="npm version" /></a>
7
+ <a href="https://github.com/heyatomdev/bastion-client/actions/workflows/test.yml"><img src="https://github.com/heyatomdev/bastion-client/actions/workflows/test.yml/badge.svg?branch=main" alt="Test" /></a>
8
+ <img src="https://img.shields.io/node/v/@heyatom/bastion-client" alt="Node version" />
9
+ <img src="https://img.shields.io/npm/l/@heyatom/bastion-client" alt="License" />
10
+ </p>
11
+
1
12
  # @heyatom/bastion-client
2
13
 
3
14
  Client SDK for [Bastion](https://github.com/heyatomdev/bastion), the HeyAtom identity provider.
@@ -136,6 +147,11 @@ export class AuthController {
136
147
  from one browser must collapse into one call. Use one client instance per process.
137
148
  - `logout` revokes the latest token in the rotation chain and never throws.
138
149
 
150
+ ## Responses
151
+
152
+ Calls return Bastion's body. A body whose only key is `data` is unwrapped, so the same code reads
153
+ Bastion with and without the `{ data }` envelope; pages keep their `{ data, meta }` shape.
154
+
139
155
  ## Errors
140
156
 
141
157
  Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Nest) with a stable
@@ -144,9 +160,11 @@ Every failed call throws `BastionHttpError` (core) or `BastionHttpException` (Ne
144
160
  `INVALID_CREDENTIALS`, `EMAIL_NOT_VERIFIED`, `NO_APP_ACCESS`, `ACCOUNT_INACTIVE`,
145
161
  `INVALID_APP_CONTEXT` (your configuration, not the user), `INVALID_REFRESH_TOKEN`,
146
162
  `INVALID_OAUTH_CODE`, `VALIDATION_FAILED`, `STEP_UP_FAILED` (wrong current password or 2FA
147
- code on a step-up route: the session is fine), `CONFLICT`, `RATE_LIMITED`, the age-gate codes
148
- (`BIRTH_DATE_REQUIRED`, `BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`), the OAuth flow codes
149
- (`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
163
+ code on a step-up route: the session is fine), `CURRENT_PASSWORD_REQUIRED` and
164
+ `TWO_FACTOR_CODE_REQUIRED` (a step-up field is missing: ask for it), `CONFLICT`,
165
+ `PROVIDER_TAKEN`, `ALREADY_LINKED`, `RATE_LIMITED`, the age-gate codes (`BIRTH_DATE_REQUIRED`,
166
+ `BIRTH_DATE_INVALID`, `UNDER_MINIMUM_AGE`, `AGE_ALREADY_VERIFIED`, `AGE_GATE_NOT_CONFIGURED`),
167
+ the OAuth codes (`OAUTH_*`), `UNAVAILABLE` (Bastion unreachable or timed out) and `UPSTREAM`.
150
168
 
151
169
  - A 400 carries the field errors in `errors: string[]`. Show them: `message` is only
152
170
  "Validation failed".
@@ -225,14 +225,20 @@ class BastionApiClient {
225
225
  }
226
226
  // ── GDPR ─────────────────────────────────────────────────────────────────
227
227
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
228
- listMyEvents(accessToken, page = {}) {
228
+ async listMyEvents(accessToken, page = {}) {
229
229
  const params = new URLSearchParams();
230
230
  if (page.page !== undefined)
231
231
  params.set('page', String(page.page));
232
232
  if (page.limit !== undefined)
233
233
  params.set('limit', String(page.limit));
234
234
  const qs = params.toString();
235
- return this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
235
+ const res = await this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
236
+ // No body (a proxy in between): nothing to normalise, same as every other method.
237
+ if (!res || typeof res !== 'object' || 'meta' in res)
238
+ return res;
239
+ // Bastion before 2.0: the page fields sit next to `data`.
240
+ const { data, total, page: current, limit } = res;
241
+ return { data, meta: { total, page: current, limit, totalPages: limit > 0 ? Math.ceil(total / limit) : 0 } };
236
242
  }
237
243
  exportData(accessToken) {
238
244
  return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
@@ -136,9 +136,13 @@ export interface BastionUserAuditEvent {
136
136
  metadata: unknown;
137
137
  createdAt: string;
138
138
  }
139
- export interface BastionUserAuditEventsPage {
140
- data: BastionUserAuditEvent[];
139
+ export interface BastionPageMeta {
141
140
  total: number;
142
141
  page: number;
143
142
  limit: number;
143
+ totalPages: number;
144
+ }
145
+ export interface BastionUserAuditEventsPage {
146
+ data: BastionUserAuditEvent[];
147
+ meta: BastionPageMeta;
144
148
  }
@@ -11,6 +11,12 @@ export interface BastionHttpOptions {
11
11
  */
12
12
  requestId?: () => string | undefined;
13
13
  }
14
+ /**
15
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
16
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
17
+ * or no body passes through unchanged, so every caller reads the same shape from both.
18
+ */
19
+ export declare function unwrapData(body: unknown): unknown;
14
20
  export interface BastionRequestInit {
15
21
  token?: string;
16
22
  headers?: Record<string, string>;
@@ -24,9 +30,10 @@ export interface ClientAuthInput {
24
30
  /**
25
31
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
26
32
  * `JwksVerifier`. `call` is public so a service can reach any route with the
27
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
28
- * status and a stable `code`; Bastion unreachable or timed out is a 503
29
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
33
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
34
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
35
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
36
+ * IP as `X-Real-IP` on user-facing calls.
30
37
  */
31
38
  export declare class BastionHttp {
32
39
  private readonly baseUrl;
@@ -1,14 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BastionHttp = void 0;
4
+ exports.unwrapData = unwrapData;
4
5
  const errors_js_1 = require("./errors.js");
5
6
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
7
+ /**
8
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
9
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
10
+ * or no body passes through unchanged, so every caller reads the same shape from both.
11
+ */
12
+ function unwrapData(body) {
13
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
14
+ const keys = Object.keys(body);
15
+ if (keys.length === 1 && keys[0] === 'data')
16
+ return body.data;
17
+ }
18
+ return body;
19
+ }
6
20
  /**
7
21
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
8
22
  * `JwksVerifier`. `call` is public so a service can reach any route with the
9
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
10
- * status and a stable `code`; Bastion unreachable or timed out is a 503
11
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
23
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
24
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
25
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
26
+ * IP as `X-Real-IP` on user-facing calls.
12
27
  */
13
28
  class BastionHttp {
14
29
  baseUrl;
@@ -57,7 +72,9 @@ class BastionHttp {
57
72
  : typeof parsed.message === 'string'
58
73
  ? parsed.message
59
74
  : `Bastion error ${res.status}`;
60
- // A code Bastion still puts in `message` (UNDER_MINIMUM_AGE under `code: FORBIDDEN`) wins: classify reads it.
75
+ // A code in `message` wins: an older Bastion sends it under a generic `code` that is also one
76
+ // of ours (ALREADY_LINKED under `code: CONFLICT`); classify reads it. Otherwise Bastion's
77
+ // `code` wins when it is one of ours, so a cleaned-up `message` still carries its code.
61
78
  const code = (0, errors_js_1.isBastionErrorCode)(message)
62
79
  ? undefined
63
80
  : (0, errors_js_1.isBastionErrorCode)(parsed.code)
@@ -70,7 +87,7 @@ class BastionHttp {
70
87
  }
71
88
  if (res.status === 204)
72
89
  return undefined;
73
- return (await res.json().catch(() => undefined));
90
+ return unwrapData(await res.json().catch(() => undefined));
74
91
  }
75
92
  clientAuth(input) {
76
93
  return this.call('POST', '/auth/client', input);
@@ -6,9 +6,9 @@ export declare class BastionTokenError extends Error {
6
6
  }
7
7
  /**
8
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.
9
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
10
+ * it is one of these; for an older Bastion that sends none, the code is derived
11
+ * from status + message (`classifyBastionError`).
12
12
  */
13
13
  export declare const BastionErrorCode: {
14
14
  readonly EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED";
@@ -36,6 +36,20 @@ export declare const BastionErrorCode: {
36
36
  readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
37
37
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
38
38
  readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
39
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
40
+ readonly OAUTH_EMAIL_TAKEN: "OAUTH_EMAIL_TAKEN";
41
+ readonly OAUTH_USER_INACTIVE: "OAUTH_USER_INACTIVE";
42
+ readonly OAUTH_UNDER_MINIMUM_AGE: "OAUTH_UNDER_MINIMUM_AGE";
43
+ /** Linking a provider account that belongs to another user. */
44
+ readonly PROVIDER_TAKEN: "PROVIDER_TAKEN";
45
+ /** The user already has an account of this provider linked. */
46
+ readonly ALREADY_LINKED: "ALREADY_LINKED";
47
+ readonly AGE_ALREADY_VERIFIED: "AGE_ALREADY_VERIFIED";
48
+ readonly AGE_GATE_NOT_CONFIGURED: "AGE_GATE_NOT_CONFIGURED";
49
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
50
+ readonly CURRENT_PASSWORD_REQUIRED: "CURRENT_PASSWORD_REQUIRED";
51
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
52
+ readonly TWO_FACTOR_CODE_REQUIRED: "TWO_FACTOR_CODE_REQUIRED";
39
53
  readonly UNAVAILABLE: "UNAVAILABLE";
40
54
  readonly UPSTREAM: "UPSTREAM";
41
55
  };
@@ -15,9 +15,9 @@ class BastionTokenError extends Error {
15
15
  exports.BastionTokenError = BastionTokenError;
16
16
  /**
17
17
  * Stable classification of a Bastion error answer, so an app can branch on a
18
- * code instead of on Bastion's message text. The age-gate codes are Bastion's
19
- * own (it returns them verbatim); the rest are derived from status + message
20
- * until Bastion carries a `code` field on every error.
18
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
19
+ * it is one of these; for an older Bastion that sends none, the code is derived
20
+ * from status + message (`classifyBastionError`).
21
21
  */
22
22
  exports.BastionErrorCode = {
23
23
  EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
@@ -45,6 +45,20 @@ exports.BastionErrorCode = {
45
45
  OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
46
46
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
47
47
  OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
48
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
49
+ OAUTH_EMAIL_TAKEN: 'OAUTH_EMAIL_TAKEN',
50
+ OAUTH_USER_INACTIVE: 'OAUTH_USER_INACTIVE',
51
+ OAUTH_UNDER_MINIMUM_AGE: 'OAUTH_UNDER_MINIMUM_AGE',
52
+ /** Linking a provider account that belongs to another user. */
53
+ PROVIDER_TAKEN: 'PROVIDER_TAKEN',
54
+ /** The user already has an account of this provider linked. */
55
+ ALREADY_LINKED: 'ALREADY_LINKED',
56
+ AGE_ALREADY_VERIFIED: 'AGE_ALREADY_VERIFIED',
57
+ AGE_GATE_NOT_CONFIGURED: 'AGE_GATE_NOT_CONFIGURED',
58
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
59
+ CURRENT_PASSWORD_REQUIRED: 'CURRENT_PASSWORD_REQUIRED',
60
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
61
+ TWO_FACTOR_CODE_REQUIRED: 'TWO_FACTOR_CODE_REQUIRED',
48
62
  UNAVAILABLE: 'UNAVAILABLE',
49
63
  UPSTREAM: 'UPSTREAM',
50
64
  };
@@ -58,6 +72,15 @@ const CODE_MESSAGES = new Set([
58
72
  exports.BastionErrorCode.OAUTH_EMAIL_REQUIRED,
59
73
  exports.BastionErrorCode.OAUTH_NO_ACCOUNT,
60
74
  exports.BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
75
+ exports.BastionErrorCode.OAUTH_EMAIL_TAKEN,
76
+ exports.BastionErrorCode.OAUTH_USER_INACTIVE,
77
+ exports.BastionErrorCode.OAUTH_UNDER_MINIMUM_AGE,
78
+ exports.BastionErrorCode.PROVIDER_TAKEN,
79
+ exports.BastionErrorCode.ALREADY_LINKED,
80
+ exports.BastionErrorCode.AGE_ALREADY_VERIFIED,
81
+ exports.BastionErrorCode.AGE_GATE_NOT_CONFIGURED,
82
+ exports.BastionErrorCode.CURRENT_PASSWORD_REQUIRED,
83
+ exports.BastionErrorCode.TWO_FACTOR_CODE_REQUIRED,
61
84
  ]);
62
85
  const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
63
86
  const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
@@ -222,14 +222,20 @@ export class BastionApiClient {
222
222
  }
223
223
  // ── GDPR ─────────────────────────────────────────────────────────────────
224
224
  /** The user's own audit trail (`GET /auth/me/events`), paginated. */
225
- listMyEvents(accessToken, page = {}) {
225
+ async listMyEvents(accessToken, page = {}) {
226
226
  const params = new URLSearchParams();
227
227
  if (page.page !== undefined)
228
228
  params.set('page', String(page.page));
229
229
  if (page.limit !== undefined)
230
230
  params.set('limit', String(page.limit));
231
231
  const qs = params.toString();
232
- return this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
232
+ const res = await this.call('GET', `/auth/me/events${qs ? `?${qs}` : ''}`, undefined, { token: accessToken });
233
+ // No body (a proxy in between): nothing to normalise, same as every other method.
234
+ if (!res || typeof res !== 'object' || 'meta' in res)
235
+ return res;
236
+ // Bastion before 2.0: the page fields sit next to `data`.
237
+ const { data, total, page: current, limit } = res;
238
+ return { data, meta: { total, page: current, limit, totalPages: limit > 0 ? Math.ceil(total / limit) : 0 } };
233
239
  }
234
240
  exportData(accessToken) {
235
241
  return this.call('GET', '/auth/me/export', undefined, { token: accessToken });
@@ -136,9 +136,13 @@ export interface BastionUserAuditEvent {
136
136
  metadata: unknown;
137
137
  createdAt: string;
138
138
  }
139
- export interface BastionUserAuditEventsPage {
140
- data: BastionUserAuditEvent[];
139
+ export interface BastionPageMeta {
141
140
  total: number;
142
141
  page: number;
143
142
  limit: number;
143
+ totalPages: number;
144
+ }
145
+ export interface BastionUserAuditEventsPage {
146
+ data: BastionUserAuditEvent[];
147
+ meta: BastionPageMeta;
144
148
  }
@@ -11,6 +11,12 @@ export interface BastionHttpOptions {
11
11
  */
12
12
  requestId?: () => string | undefined;
13
13
  }
14
+ /**
15
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
16
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
17
+ * or no body passes through unchanged, so every caller reads the same shape from both.
18
+ */
19
+ export declare function unwrapData(body: unknown): unknown;
14
20
  export interface BastionRequestInit {
15
21
  token?: string;
16
22
  headers?: Record<string, string>;
@@ -24,9 +30,10 @@ export interface ClientAuthInput {
24
30
  /**
25
31
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
26
32
  * `JwksVerifier`. `call` is public so a service can reach any route with the
27
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
28
- * status and a stable `code`; Bastion unreachable or timed out is a 503
29
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
33
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
34
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
35
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
36
+ * IP as `X-Real-IP` on user-facing calls.
30
37
  */
31
38
  export declare class BastionHttp {
32
39
  private readonly baseUrl;
@@ -1,11 +1,25 @@
1
1
  import { BastionErrorCode, BastionHttpError, isBastionErrorCode } from './errors.js';
2
2
  const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
3
+ /**
4
+ * Bastion answers `{ data }` (or `{ data, meta }` for a page) from 2.0; earlier builds answer the
5
+ * bare body. An object whose only key is `data` gives back `data`; a page, an array, a bare body
6
+ * or no body passes through unchanged, so every caller reads the same shape from both.
7
+ */
8
+ export function unwrapData(body) {
9
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
10
+ const keys = Object.keys(body);
11
+ if (keys.length === 1 && keys[0] === 'data')
12
+ return body.data;
13
+ }
14
+ return body;
15
+ }
3
16
  /**
4
17
  * Outbound calls to Bastion. Nothing here verifies inbound tokens — that is
5
18
  * `JwksVerifier`. `call` is public so a service can reach any route with the
6
- * same error handling: a non-2xx answer becomes `BastionHttpError` with the
7
- * status and a stable `code`; Bastion unreachable or timed out is a 503
8
- * `UNAVAILABLE`. Pass the browser's IP as `X-Real-IP` on user-facing calls.
19
+ * same error handling and the same success shape (`unwrapData`): a non-2xx
20
+ * answer becomes `BastionHttpError` with the status and a stable `code`;
21
+ * Bastion unreachable or timed out is a 503 `UNAVAILABLE`. Pass the browser's
22
+ * IP as `X-Real-IP` on user-facing calls.
9
23
  */
10
24
  export class BastionHttp {
11
25
  baseUrl;
@@ -54,7 +68,9 @@ export class BastionHttp {
54
68
  : typeof parsed.message === 'string'
55
69
  ? parsed.message
56
70
  : `Bastion error ${res.status}`;
57
- // A code Bastion still puts in `message` (UNDER_MINIMUM_AGE under `code: FORBIDDEN`) wins: classify reads it.
71
+ // A code in `message` wins: an older Bastion sends it under a generic `code` that is also one
72
+ // of ours (ALREADY_LINKED under `code: CONFLICT`); classify reads it. Otherwise Bastion's
73
+ // `code` wins when it is one of ours, so a cleaned-up `message` still carries its code.
58
74
  const code = isBastionErrorCode(message)
59
75
  ? undefined
60
76
  : isBastionErrorCode(parsed.code)
@@ -67,7 +83,7 @@ export class BastionHttp {
67
83
  }
68
84
  if (res.status === 204)
69
85
  return undefined;
70
- return (await res.json().catch(() => undefined));
86
+ return unwrapData(await res.json().catch(() => undefined));
71
87
  }
72
88
  clientAuth(input) {
73
89
  return this.call('POST', '/auth/client', input);
@@ -6,9 +6,9 @@ export declare class BastionTokenError extends Error {
6
6
  }
7
7
  /**
8
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.
9
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
10
+ * it is one of these; for an older Bastion that sends none, the code is derived
11
+ * from status + message (`classifyBastionError`).
12
12
  */
13
13
  export declare const BastionErrorCode: {
14
14
  readonly EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED";
@@ -36,6 +36,20 @@ export declare const BastionErrorCode: {
36
36
  readonly OAUTH_NO_ACCOUNT: "OAUTH_NO_ACCOUNT";
37
37
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
38
38
  readonly OAUTH_LINK_USER_MISMATCH: "OAUTH_LINK_USER_MISMATCH";
39
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
40
+ readonly OAUTH_EMAIL_TAKEN: "OAUTH_EMAIL_TAKEN";
41
+ readonly OAUTH_USER_INACTIVE: "OAUTH_USER_INACTIVE";
42
+ readonly OAUTH_UNDER_MINIMUM_AGE: "OAUTH_UNDER_MINIMUM_AGE";
43
+ /** Linking a provider account that belongs to another user. */
44
+ readonly PROVIDER_TAKEN: "PROVIDER_TAKEN";
45
+ /** The user already has an account of this provider linked. */
46
+ readonly ALREADY_LINKED: "ALREADY_LINKED";
47
+ readonly AGE_ALREADY_VERIFIED: "AGE_ALREADY_VERIFIED";
48
+ readonly AGE_GATE_NOT_CONFIGURED: "AGE_GATE_NOT_CONFIGURED";
49
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
50
+ readonly CURRENT_PASSWORD_REQUIRED: "CURRENT_PASSWORD_REQUIRED";
51
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
52
+ readonly TWO_FACTOR_CODE_REQUIRED: "TWO_FACTOR_CODE_REQUIRED";
39
53
  readonly UNAVAILABLE: "UNAVAILABLE";
40
54
  readonly UPSTREAM: "UPSTREAM";
41
55
  };
@@ -9,9 +9,9 @@ export class BastionTokenError extends Error {
9
9
  }
10
10
  /**
11
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.
12
+ * code instead of on Bastion's message text. Bastion's own `code` is used when
13
+ * it is one of these; for an older Bastion that sends none, the code is derived
14
+ * from status + message (`classifyBastionError`).
15
15
  */
16
16
  export const BastionErrorCode = {
17
17
  EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
@@ -39,6 +39,20 @@ export const BastionErrorCode = {
39
39
  OAUTH_NO_ACCOUNT: 'OAUTH_NO_ACCOUNT',
40
40
  /** `completeSocialAccountLink` called with a token that is not the link ticket's owner. The code is spent; start the link again. */
41
41
  OAUTH_LINK_USER_MISMATCH: 'OAUTH_LINK_USER_MISMATCH',
42
+ /** OAuth signup with an email that a verified account of the tenant already owns. */
43
+ OAUTH_EMAIL_TAKEN: 'OAUTH_EMAIL_TAKEN',
44
+ OAUTH_USER_INACTIVE: 'OAUTH_USER_INACTIVE',
45
+ OAUTH_UNDER_MINIMUM_AGE: 'OAUTH_UNDER_MINIMUM_AGE',
46
+ /** Linking a provider account that belongs to another user. */
47
+ PROVIDER_TAKEN: 'PROVIDER_TAKEN',
48
+ /** The user already has an account of this provider linked. */
49
+ ALREADY_LINKED: 'ALREADY_LINKED',
50
+ AGE_ALREADY_VERIFIED: 'AGE_ALREADY_VERIFIED',
51
+ AGE_GATE_NOT_CONFIGURED: 'AGE_GATE_NOT_CONFIGURED',
52
+ /** A step-up route was called without `currentPassword`. Ask for it; the session is fine. */
53
+ CURRENT_PASSWORD_REQUIRED: 'CURRENT_PASSWORD_REQUIRED',
54
+ /** A step-up route was called without `twoFactorCode`. Ask for it; the session is fine. */
55
+ TWO_FACTOR_CODE_REQUIRED: 'TWO_FACTOR_CODE_REQUIRED',
42
56
  UNAVAILABLE: 'UNAVAILABLE',
43
57
  UPSTREAM: 'UPSTREAM',
44
58
  };
@@ -52,6 +66,15 @@ const CODE_MESSAGES = new Set([
52
66
  BastionErrorCode.OAUTH_EMAIL_REQUIRED,
53
67
  BastionErrorCode.OAUTH_NO_ACCOUNT,
54
68
  BastionErrorCode.OAUTH_LINK_USER_MISMATCH,
69
+ BastionErrorCode.OAUTH_EMAIL_TAKEN,
70
+ BastionErrorCode.OAUTH_USER_INACTIVE,
71
+ BastionErrorCode.OAUTH_UNDER_MINIMUM_AGE,
72
+ BastionErrorCode.PROVIDER_TAKEN,
73
+ BastionErrorCode.ALREADY_LINKED,
74
+ BastionErrorCode.AGE_ALREADY_VERIFIED,
75
+ BastionErrorCode.AGE_GATE_NOT_CONFIGURED,
76
+ BastionErrorCode.CURRENT_PASSWORD_REQUIRED,
77
+ BastionErrorCode.TWO_FACTOR_CODE_REQUIRED,
55
78
  ]);
56
79
  const APP_CONTEXT_RE = /app not found|tenant not found|tenant is inactive|no tenants configured|tenantslug is required/i;
57
80
  const OAUTH_CODE_RE = /already used code|code expired|code\/app mismatch/i;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyatom/bastion-client",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "description": "Consumer SDK for the Bastion identity provider: JWKS verification, service-client auth, audit writes, NestJS guards",
5
5
  "license": "MIT",
6
6
  "author": "Andrea Tombolato <hey@heyatom.dev> (https://heyatom.dev)",