@synqauth/js 0.0.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.
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { c as SynqError, l as isSynqError, r as paths, t as components } from "./management-y9-9AIc-.js";
1
+ import { A as SignInFlowStep, M as StartSignInOptions, O as SavedSignIn, c as SynqError, j as SignInStepResult, k as SignInFlow, l as isSynqError, m as IdTokenClaims, r as paths, t as components, z as LinkedAccount } from "./management-C4LYIC3S.js";
2
2
  import { JWTPayload, JWTVerifyGetKey } from "jose";
3
3
  //#region src/api.d.ts
4
4
  type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
@@ -38,7 +38,12 @@ type Success<O> = O extends {
38
38
  };
39
39
  } ? J : never : S extends 204 ? undefined : never; }[keyof R] : never;
40
40
  type Common = {
41
- /** Sent as `Idempotency-Key`: a POST with the same key runs once (the management API keeps the answer 24 hours). */
41
+ /**
42
+ * Sent as `Idempotency-Key`: a POST with the same key runs once (the
43
+ * management API keeps the answer 24 hours). The management client makes
44
+ * one for every POST you don't give one, unless `maxRetries` is 0, and
45
+ * sends the same key on each retry.
46
+ */
42
47
  idempotencyKey?: string;
43
48
  signal?: AbortSignal;
44
49
  };
@@ -60,6 +65,14 @@ type RequestOptions<O> = ([PathParams<O>] extends [never] ? {
60
65
  }) & Common;
61
66
  type Args<O> = object extends RequestOptions<O> ? [options?: RequestOptions<O>] : [options: RequestOptions<O>];
62
67
  type Call<Paths, M extends HttpMethod> = <P extends PathsWith<Paths, M>>(path: P, ...args: Args<Operation<Paths, P, M>>) => Promise<Success<Operation<Paths, P, M>>>;
68
+ /** One item of a list's page: what `paginate` yields. */
69
+ type PageItem<O> = Success<O> extends {
70
+ readonly data: readonly (infer T)[];
71
+ readonly next_cursor: string | null;
72
+ } ? T : never;
73
+ /** The paths of a contract whose GET answers a page (`{ data, next_cursor }`). */
74
+ type PagedPaths<Paths> = { [P in PathsWith<Paths, 'get'>]: [PageItem<Operation<Paths, P, 'get'>>] extends [never] ? never : P; }[PathsWith<Paths, 'get'>] & string;
75
+ type Paginate<Paths> = <P extends PagedPaths<Paths>>(path: P, ...args: Args<Operation<Paths, P, 'get'>>) => AsyncIterable<PageItem<Operation<Paths, P, 'get'>>>;
63
76
  /** A client for one Synq contract: one method per HTTP method, each typed by the contract. */
64
77
  interface ApiClient<Paths> {
65
78
  get: Call<Paths, 'get'>;
@@ -67,6 +80,14 @@ interface ApiClient<Paths> {
67
80
  put: Call<Paths, 'put'>;
68
81
  patch: Call<Paths, 'patch'>;
69
82
  delete: Call<Paths, 'delete'>;
83
+ /**
84
+ * Every item of a list, page by page: `for await (const user of
85
+ * client.paginate('/v1/orgs/{org}/brands/{brand}/users', { path }))`. It
86
+ * asks for the next page (`cursor`, from the last page's `next_cursor`)
87
+ * only when the loop reaches it, so `break` stops asking. `query.limit`
88
+ * is the size of each page.
89
+ */
90
+ paginate: Paginate<Paths>;
70
91
  }
71
92
  //#endregion
72
93
  //#region src/server.d.ts
@@ -112,6 +133,12 @@ export interface AccessTokenClaims {
112
133
  readonly type?: string;
113
134
  readonly client_id?: string;
114
135
  } | undefined;
136
+ /** The person's email, when your API asks for it (`identity_claims`) and they have one. */
137
+ readonly email: string | undefined;
138
+ /** Whether Synq verified it, when your API asks for it. */
139
+ readonly email_verified: boolean | undefined;
140
+ /** Their accounts at upstream providers (GitHub, Google…), when your API asks for them. */
141
+ readonly linked_accounts: readonly LinkedAccount[] | undefined;
115
142
  /** Every claim, as the token carries it. */
116
143
  readonly payload: JWTPayload;
117
144
  }
@@ -129,6 +156,67 @@ export type AccessTokenVerifier = (token: string, options?: VerifyAccessTokenOpt
129
156
  * request. The issuer and its JWKS must be https (http only on this machine).
130
157
  */
131
158
  export declare function createAccessTokenVerifier(options: AccessTokenVerifierOptions): AccessTokenVerifier;
159
+ /**
160
+ * Whether `credential` is one of your users' API keys (`ak_live_…` or
161
+ * `ak_test_…`, AERO-1693) rather than an access token: what tells an API
162
+ * which of `createApiKeyVerifier` and `createAccessTokenVerifier` to check
163
+ * a bearer credential with.
164
+ */
165
+ export declare function isApiKey(credential: unknown): credential is string;
166
+ /** What an API checks its users' API keys with: the brand's issuer, and the API's own app. */
167
+ export interface ApiKeyVerifierOptions {
168
+ /** The brand's issuer URL, as the dashboard shows it. */
169
+ issuer: string;
170
+ /**
171
+ * The API's own app (its `client_id` in Synq), the one linked to the API:
172
+ * only it may check the API's keys.
173
+ */
174
+ clientId: string;
175
+ clientSecret: string;
176
+ fetch?: typeof fetch;
177
+ }
178
+ /** What one route wants of a key, on top of the verifier's checks. */
179
+ export interface VerifyApiKeyOptions {
180
+ /** Scopes the key must carry, every one. */
181
+ scopes?: readonly string[];
182
+ }
183
+ /** One of your users' API keys, once Synq said it works. */
184
+ export interface ApiKeyClaims {
185
+ readonly iss: string;
186
+ /** The person who made it (`usr_…`), as in their access tokens' `sub`. */
187
+ readonly sub: string;
188
+ /** Your API's identifier: the API it is for. */
189
+ readonly aud: string;
190
+ readonly scopes: readonly string[];
191
+ /** The key's id (`uak_…`), never the key. */
192
+ readonly keyId: string;
193
+ /** The name the person gave it. */
194
+ readonly name: string;
195
+ /** When it was made, in seconds. */
196
+ readonly iat: number;
197
+ /** When it stops working, in seconds; undefined for a key that doesn't expire. */
198
+ readonly exp: number | undefined;
199
+ /** The person's email, when your API asks for it (`identity_claims`) and they have one. */
200
+ readonly email: string | undefined;
201
+ /** Whether Synq verified it, when your API asks for it. */
202
+ readonly email_verified: boolean | undefined;
203
+ /** Their accounts at upstream providers (GitHub, Google…), when your API asks for them. */
204
+ readonly linked_accounts: readonly LinkedAccount[] | undefined;
205
+ }
206
+ /**
207
+ * Checks one of your users' API keys, as `createApiKeyVerifier` makes it:
208
+ * the claims when the key works, else a `SynqError` (`invalid_token` for a
209
+ * key that doesn't, `insufficient_scope` for one without a scope asked for).
210
+ */
211
+ export type ApiKeyVerifier = (key: string, options?: VerifyApiKeyOptions) => Promise<ApiKeyClaims>;
212
+ /**
213
+ * A verifier for the API keys your users make for your API (AERO-1693). It
214
+ * asks the brand's issuer about every key (`POST /api-keys/verify`, as the
215
+ * API's own app), and keeps nothing: revoking a key works from its next
216
+ * check. A key Synq says doesn't work is `invalid_token` (401), never why;
217
+ * your app's credentials refused are `invalid_client`.
218
+ */
219
+ export declare function createApiKeyVerifier(options: ApiKeyVerifierOptions): ApiKeyVerifier;
132
220
  /** A Synq webhook's event: one row of the org's audit log (the management API's `WebhookEvent`). */
133
221
  export type WebhookEvent = components['schemas']['WebhookEvent'];
134
222
  /** A delivery that passed its check. */
@@ -162,14 +250,87 @@ export interface ManagementClientOptions {
162
250
  /** Or a Synq access token, acting as that person: a function so it can be refreshed. */
163
251
  accessToken?: () => string | Promise<string>;
164
252
  fetch?: typeof fetch;
253
+ /**
254
+ * How many times a call is tried again when Synq's answer says it may
255
+ * work later: 2 by default, 0 for never (and then a POST carries only
256
+ * the `idempotencyKey` you give it). Every method retries a 429, a
257
+ * 503 and `idempotency_key_in_flight`; a GET, PUT, PATCH or POST (with
258
+ * its key) also retries a 502, a 504 and a dropped connection; a GET, PUT
259
+ * or PATCH a 500. A DELETE retries only refusals, since a second try
260
+ * could answer 404 for a delete that worked. Each try waits the
261
+ * `Retry-After` Synq names, up to 60 seconds (a longer one is thrown at
262
+ * once, with `retryAfter`), or else backs off from half a second, and an
263
+ * aborted `signal` stops the waiting.
264
+ */
265
+ maxRetries?: number;
165
266
  }
267
+ /** How many times the management client tries a call again by default. */
268
+ export declare const DEFAULT_MAX_RETRIES = 2;
166
269
  /** The management API's typed client: every path, parameter, body and answer from its contract. */
167
270
  export type ManagementClient = ApiClient<paths>;
168
271
  /**
169
272
  * A client for Synq's management API (`/v1`), typed by its contract. Server
170
273
  * only: an API key never reaches a browser. Failures throw a `SynqError`
171
274
  * with the problem's `code`, such as `insufficient_permissions`.
275
+ *
276
+ * Every POST carries an `Idempotency-Key` (yours, or a new one unless
277
+ * `maxRetries` is 0), the same on each try, so a retry never makes two of
278
+ * anything. An answer that holds a secret or a link only its holder may use
279
+ * isn't kept with the key: a retry of it is `conflict`. A call is tried
280
+ * again, up to `maxRetries` times, when Synq's answer says it may work
281
+ * later, and `paginate` walks a list's pages with `for await`.
172
282
  */
173
283
  export declare function createManagementClient(options: ManagementClientOptions): ManagementClient;
284
+ /** What a server needs to sign people in on its app's own screens (AERO-1411). */
285
+ export interface OwnUiSignInOptions {
286
+ /** The brand's issuer URL, as the dashboard shows it. */
287
+ issuer: string;
288
+ /** The app's client_id: one of the brand's own web apps, with own-UI sign-in on. */
289
+ clientId: string;
290
+ /** The app's secret, sent with HTTP Basic, as at the token endpoint. */
291
+ clientSecret?: string;
292
+ /** Or the key it signs its client assertions with (`private_key_jwt`). */
293
+ privateKey?: CryptoKey | {
294
+ key: CryptoKey;
295
+ kid?: string;
296
+ };
297
+ fetch?: typeof fetch;
298
+ }
299
+ /** What a server's own-UI sign-in ends with: the app keeps them as its session. */
300
+ export interface OwnUiTokens {
301
+ readonly accessToken: string;
302
+ readonly tokenType: string;
303
+ /** When the access token expires, in seconds since the epoch, when Synq said. */
304
+ readonly expiresAt: number | undefined;
305
+ readonly scope: string | undefined;
306
+ readonly idToken: string;
307
+ readonly refreshToken: string | undefined;
308
+ /** The ID token's claims, validated: who signed in, and how. */
309
+ readonly claims: IdTokenClaims;
310
+ }
311
+ /** A server's own-UI sign-ins: one flow per sign-in, kept between requests with `toJSON`. */
312
+ export interface OwnUiSignIn {
313
+ /**
314
+ * Starts a sign-in. `endUserIp` is the address the person is at, as your
315
+ * server saw it: Synq counts per-address limits for your app with it, and
316
+ * without it counts none.
317
+ */
318
+ start(options?: StartSignInOptions & {
319
+ endUserIp?: string;
320
+ }): SignInFlow<OwnUiTokens>;
321
+ /** Picks up the flow `toJSON()` saved, for the next request of the same sign-in. */
322
+ resume(saved: SavedSignIn | string, options?: {
323
+ endUserIp?: string;
324
+ }): SignInFlow<OwnUiTokens>;
325
+ }
326
+ /**
327
+ * Own-UI sign-in for a server (AERO-1411): its forms post to it, it calls
328
+ * Synq's Authorization Challenge Endpoint as its app, and a sign-in ends
329
+ * with tokens, not a cookie at Synq. The app keeps them, and the person's
330
+ * password or code never reaches a browser but the form they typed it in.
331
+ * `hosted` results can't `continue()` here: send the browser to an
332
+ * ordinary authorization request with the `method` and `loginHint` given.
333
+ */
334
+ export declare function createOwnUiSignIn(options: OwnUiSignInOptions): OwnUiSignIn;
174
335
  //#endregion
175
- export { type components as ManagementComponents, type paths as ManagementPaths, SynqError, isSynqError };
336
+ export { type LinkedAccount, type components as ManagementComponents, type paths as ManagementPaths, type SavedSignIn, SignInFlow, type SignInFlowStep, type SignInStepResult, type StartSignInOptions, SynqError, isSynqError };
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
- import { i as isSynqError, n as secureUrl, r as SynqError, t as createApiClient } from "./api-Cf9K1Rc9.js";
1
+ import { a as readSavedSignIn, c as SynqError, i as newSignIn, l as isSynqError, n as secureUrl, o as linkedAccountsOf, r as SignInFlow, t as createApiClient } from "./api-BGeXhXo6.js";
2
+ import * as oauth from "oauth4webapi";
2
3
  import { createRemoteJWKSet, customFetch, errors, jwtVerify } from "jose";
3
4
  //#region src/server.ts
4
5
  /**
@@ -104,6 +105,9 @@ function accessTokenClaims(payload) {
104
105
  acr: string(payload.acr),
105
106
  amr,
106
107
  act,
108
+ email: string(payload.email),
109
+ email_verified: typeof payload.email_verified === "boolean" ? payload.email_verified : void 0,
110
+ linked_accounts: linkedAccountsOf(payload.linked_accounts),
107
111
  payload
108
112
  };
109
113
  }
@@ -123,6 +127,77 @@ function tokenError(error) {
123
127
  });
124
128
  }
125
129
  /**
130
+ * Whether `credential` is one of your users' API keys (`ak_live_…` or
131
+ * `ak_test_…`, AERO-1693) rather than an access token: what tells an API
132
+ * which of `createApiKeyVerifier` and `createAccessTokenVerifier` to check
133
+ * a bearer credential with.
134
+ */
135
+ function isApiKey(credential) {
136
+ return typeof credential === "string" && /^ak_(live|test)_[A-Za-z0-9_-]{43}$/.test(credential);
137
+ }
138
+ /**
139
+ * A verifier for the API keys your users make for your API (AERO-1693). It
140
+ * asks the brand's issuer about every key (`POST /api-keys/verify`, as the
141
+ * API's own app), and keeps nothing: revoking a key works from its next
142
+ * check. A key Synq says doesn't work is `invalid_token` (401), never why;
143
+ * your app's credentials refused are `invalid_client`.
144
+ */
145
+ function createApiKeyVerifier(options) {
146
+ const issuer = secureUrl(options.issuer, "The issuer").href.replace(/\/+$/, "");
147
+ if (!options.clientId || !options.clientSecret) throw new SynqError("invalid_options", "Give the API key verifier your API's own app: its clientId and clientSecret");
148
+ const form = (value) => encodeURIComponent(value).replace(/%20/g, "+");
149
+ const basic = btoa(`${form(options.clientId)}:${form(options.clientSecret)}`);
150
+ const fetcher = options.fetch ?? fetch;
151
+ return async (key, verify = {}) => {
152
+ if (!isApiKey(key)) throw new SynqError("invalid_token", "The API key is not valid for this API", { status: 401 });
153
+ let response;
154
+ try {
155
+ response = await fetcher(`${issuer}/api-keys/verify`, {
156
+ method: "POST",
157
+ headers: {
158
+ authorization: `Basic ${basic}`,
159
+ "content-type": "application/x-www-form-urlencoded"
160
+ },
161
+ body: new URLSearchParams({ token: key }).toString()
162
+ });
163
+ } catch (error) {
164
+ throw new SynqError("network_error", "Synq couldn't be reached to check the API key", {
165
+ status: 503,
166
+ cause: error
167
+ });
168
+ }
169
+ const body = await response.json().catch(() => void 0);
170
+ if (!response.ok) {
171
+ const error = typeof body?.error === "string" ? body.error : void 0;
172
+ const retryAfter = Number(response.headers.get("retry-after"));
173
+ throw new SynqError(error === "invalid_client" ? "invalid_client" : response.status === 429 ? "rate_limited" : "server_error", `Synq refused to check the API key (${response.status})`, {
174
+ status: response.status,
175
+ description: typeof body?.error_description === "string" ? body.error_description : void 0,
176
+ retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : void 0
177
+ });
178
+ }
179
+ if (body?.active !== true) throw new SynqError("invalid_token", "The API key is not valid for this API", { status: 401 });
180
+ const string = (value) => typeof value === "string" ? value : "";
181
+ const claims = {
182
+ iss: string(body.iss),
183
+ sub: string(body.sub),
184
+ aud: string(body.aud),
185
+ scopes: string(body.scope).split(" ").filter(Boolean),
186
+ keyId: string(body.key_id),
187
+ name: string(body.name),
188
+ iat: typeof body.iat === "number" ? body.iat : 0,
189
+ exp: typeof body.exp === "number" ? body.exp : void 0,
190
+ email: typeof body.email === "string" ? body.email : void 0,
191
+ email_verified: typeof body.email_verified === "boolean" ? body.email_verified : void 0,
192
+ linked_accounts: linkedAccountsOf(body.linked_accounts)
193
+ };
194
+ if (claims.iss !== issuer) throw new SynqError("invalid_response", "Synq's answer names another issuer", { status: 502 });
195
+ const missing = (verify.scopes ?? []).filter((scope) => !claims.scopes.includes(scope));
196
+ if (missing.length > 0) throw new SynqError("insufficient_scope", `The API key lacks ${missing.join(", ")}`, { status: 403 });
197
+ return claims;
198
+ };
199
+ }
200
+ /**
126
201
  * Checks a delivery's Standard Webhooks signature (`webhook-id`,
127
202
  * `webhook-timestamp`, `webhook-signature`, HMAC-SHA256 over
128
203
  * `<id>.<timestamp>.<body>`) against the subscription's `whsec_` secret, and
@@ -179,10 +254,19 @@ function fromBase64(value) {
179
254
  return;
180
255
  }
181
256
  }
257
+ /** How many times the management client tries a call again by default. */
258
+ const DEFAULT_MAX_RETRIES = 2;
182
259
  /**
183
260
  * A client for Synq's management API (`/v1`), typed by its contract. Server
184
261
  * only: an API key never reaches a browser. Failures throw a `SynqError`
185
262
  * with the problem's `code`, such as `insufficient_permissions`.
263
+ *
264
+ * Every POST carries an `Idempotency-Key` (yours, or a new one unless
265
+ * `maxRetries` is 0), the same on each try, so a retry never makes two of
266
+ * anything. An answer that holds a secret or a link only its holder may use
267
+ * isn't kept with the key: a retry of it is `conflict`. A call is tried
268
+ * again, up to `maxRetries` times, when Synq's answer says it may work
269
+ * later, and `paginate` walks a list's pages with `for await`.
186
270
  */
187
271
  function createManagementClient(options) {
188
272
  const { apiKey, accessToken } = options;
@@ -190,8 +274,61 @@ function createManagementClient(options) {
190
274
  return createApiClient({
191
275
  baseUrl: options.baseUrl,
192
276
  credential: apiKey ? () => apiKey : accessToken,
193
- fetch: options.fetch
277
+ fetch: options.fetch,
278
+ maxRetries: options.maxRetries ?? 2,
279
+ idempotencyKeys: true
194
280
  });
195
281
  }
282
+ /**
283
+ * Own-UI sign-in for a server (AERO-1411): its forms post to it, it calls
284
+ * Synq's Authorization Challenge Endpoint as its app, and a sign-in ends
285
+ * with tokens, not a cookie at Synq. The app keeps them, and the person's
286
+ * password or code never reaches a browser but the form they typed it in.
287
+ * `hosted` results can't `continue()` here: send the browser to an
288
+ * ordinary authorization request with the `method` and `loginHint` given.
289
+ */
290
+ function createOwnUiSignIn(options) {
291
+ const issuer = secureUrl(options.issuer, "The issuer");
292
+ if (!options.clientId) throw new SynqError("invalid_options", "The clientId is missing");
293
+ if (!options.clientSecret === !options.privateKey) throw new SynqError("invalid_options", "Give createOwnUiSignIn a clientSecret or a privateKey");
294
+ const auth = options.clientSecret ? oauth.ClientSecretBasic(options.clientSecret) : oauth.PrivateKeyJwt(options.privateKey);
295
+ const http = {};
296
+ if (options.fetch) http[oauth.customFetch] = options.fetch;
297
+ if (issuer.protocol === "http:") http[oauth.allowInsecureRequests] = true;
298
+ let server;
299
+ const discover = () => server ??= (async () => {
300
+ try {
301
+ const response = await oauth.discoveryRequest(issuer, {
302
+ ...http,
303
+ algorithm: "oidc"
304
+ });
305
+ return await oauth.processDiscoveryResponse(issuer, response);
306
+ } catch (error) {
307
+ server = void 0;
308
+ throw new SynqError("discovery_failed", `Synq's discovery document at ${issuer.href} couldn't be read`, { cause: error });
309
+ }
310
+ })();
311
+ const port = (endUserIp) => ({
312
+ server: discover,
313
+ client: { client_id: options.clientId },
314
+ auth,
315
+ fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
316
+ ...endUserIp ? { headers: { "synq-end-user-ip": endUserIp } } : {},
317
+ finish: (result, claims) => Promise.resolve({
318
+ accessToken: result.access_token,
319
+ tokenType: result.token_type,
320
+ expiresAt: result.expires_in === void 0 ? void 0 : Math.floor(Date.now() / 1e3) + result.expires_in,
321
+ scope: result.scope,
322
+ idToken: result.id_token,
323
+ refreshToken: result.refresh_token,
324
+ claims
325
+ }),
326
+ hosted: () => Promise.reject(new SynqError("redirect_to_web", "Only the hosted page can go on: send the browser to an authorization request with this result's method and loginHint"))
327
+ });
328
+ return {
329
+ start: ({ endUserIp, ...rest } = {}) => new SignInFlow(port(endUserIp), newSignIn(rest.scope ?? "openid", rest)),
330
+ resume: (saved, { endUserIp } = {}) => new SignInFlow(port(endUserIp), readSavedSignIn(saved))
331
+ };
332
+ }
196
333
  //#endregion
197
- export { SynqError, createAccessTokenVerifier, createManagementClient, isSynqError, verifyWebhook };
334
+ export { DEFAULT_MAX_RETRIES, SignInFlow, SynqError, createAccessTokenVerifier, createApiKeyVerifier, createManagementClient, createOwnUiSignIn, isApiKey, isSynqError, verifyWebhook };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synqauth/js",
3
- "version": "0.0.0",
3
+ "version": "0.3.0",
4
4
  "description": "Synq's framework-agnostic client: sign-in and sessions in the browser, the account API, and token and webhook verification on your server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,163 +0,0 @@
1
- import { AuthorizationResponseError, OperationProcessingError, ResponseBodyError, WWWAuthenticateChallengeError } from "oauth4webapi";
2
- //#region src/errors.ts
3
- /**
4
- * Every error the SDK throws. Branch on `code`, which is stable: the API's
5
- * problem `code`, OAuth's `error`, or one of the SDK's own; never on the
6
- * message.
7
- */
8
- var SynqError = class extends Error {
9
- name = "SynqError";
10
- code;
11
- /** The HTTP status Synq answered with, when it answered. */
12
- status;
13
- /** What Synq said about it: OAuth's `error_description` or a problem's `detail`. */
14
- description;
15
- /**
16
- * How many seconds to wait before trying again, from Synq's `Retry-After`:
17
- * set on `rate_limited` (429), and wherever else Synq sends one.
18
- */
19
- retryAfter;
20
- constructor(code, message, options = {}) {
21
- super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
22
- this.code = code;
23
- this.status = options.status;
24
- this.description = options.description;
25
- this.retryAfter = options.retryAfter;
26
- }
27
- };
28
- /** Whether `error` is a `SynqError`, with `code` when given. */
29
- function isSynqError(error, code) {
30
- return error instanceof SynqError && (code === void 0 || error.code === code);
31
- }
32
- /** A `SynqError` for a problem document a Synq API answered with. */
33
- function problemError(status, problem, retryAfter) {
34
- const code = typeof problem.code === "string" ? problem.code : `http_${status}`;
35
- const description = typeof problem.detail === "string" ? problem.detail : void 0;
36
- return new SynqError(code, description ?? problem.title ?? `Synq answered ${status}`, {
37
- status,
38
- description,
39
- retryAfter,
40
- cause: problem
41
- });
42
- }
43
- /**
44
- * The seconds a `Retry-After` header asks for: a number of seconds, or an
45
- * HTTP date, counted from `now`. Undefined when there is none, or it can't
46
- * be read.
47
- */
48
- function retryAfterSeconds(header, now = Date.now()) {
49
- const value = header?.trim();
50
- if (!value) return void 0;
51
- if (/^[0-9]+$/.test(value)) return Number(value);
52
- if (!/[A-Za-z]/.test(value)) return void 0;
53
- const at = Date.parse(value);
54
- return Number.isNaN(at) ? void 0 : Math.max(0, Math.ceil((at - now) / 1e3));
55
- }
56
- /**
57
- * A `SynqError` for whatever went wrong talking to Synq's issuer: OAuth's
58
- * error responses keep their `error`, a failed check of Synq's answer is
59
- * `invalid_response`, and a request that never got an answer is
60
- * `network_error`.
61
- */
62
- function toSynqError(error) {
63
- if (error instanceof SynqError) return error;
64
- if (error instanceof ResponseBodyError) return new SynqError(error.error, error.error_description ?? error.error, {
65
- status: error.status,
66
- description: error.error_description,
67
- cause: error
68
- });
69
- if (error instanceof AuthorizationResponseError) return new SynqError(error.error, error.error_description ?? error.error, {
70
- description: error.error_description,
71
- cause: error
72
- });
73
- if (error instanceof WWWAuthenticateChallengeError) {
74
- const challenge = error.cause[0];
75
- const code = challenge?.parameters.error ?? "invalid_token";
76
- const description = challenge?.parameters.error_description;
77
- return new SynqError(code, description ?? code, {
78
- status: error.status,
79
- description,
80
- cause: error
81
- });
82
- }
83
- if (error instanceof OperationProcessingError) return new SynqError("invalid_response", error.message, { cause: error });
84
- if (error instanceof TypeError) return new SynqError("network_error", `Synq couldn't be reached: ${error.message}`, { cause: error });
85
- return new SynqError("invalid_response", error instanceof Error ? error.message : "Synq answered with something unexpected", { cause: error });
86
- }
87
- //#endregion
88
- //#region src/urls.ts
89
- /** Whether a host is this machine: a Synq stack under `localhost`, or a loopback address. */
90
- function isLoopback(hostname) {
91
- return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "[::1]";
92
- }
93
- /**
94
- * `value` as a URL, which must be https: Synq's hosts always are. Plain http
95
- * only on this machine, for a local Synq stack. `what` names it in the error.
96
- */
97
- function secureUrl(value, what) {
98
- let url;
99
- try {
100
- url = new URL(value);
101
- } catch {
102
- throw new SynqError("invalid_options", `${what} isn't a URL: ${String(value)}`);
103
- }
104
- if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) throw new SynqError("invalid_options", `${what} must be https: (http: only for a Synq stack on this machine, under 'localhost')`);
105
- return url;
106
- }
107
- //#endregion
108
- //#region src/api.ts
109
- function createApiClient(options) {
110
- const base = secureUrl(options.baseUrl, "The API base URL").href.replace(/\/+$/, "");
111
- const call = (method) => ((path, request = {}) => send(method, path, request));
112
- async function send(method, path, request) {
113
- const url = new URL(base + fill(path, request.path));
114
- for (const [name, value] of Object.entries(request.query ?? {})) {
115
- if (value === void 0) continue;
116
- for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(name, String(item));
117
- }
118
- const headers = new Headers({
119
- accept: "application/json, application/problem+json",
120
- authorization: `Bearer ${await options.credential()}`
121
- });
122
- if (request.body !== void 0) headers.set("content-type", "application/json");
123
- if (request.idempotencyKey) headers.set("idempotency-key", request.idempotencyKey);
124
- let response;
125
- try {
126
- response = await (options.fetch ?? fetch)(url, {
127
- method: method.toUpperCase(),
128
- headers,
129
- body: request.body === void 0 ? void 0 : JSON.stringify(request.body),
130
- signal: request.signal
131
- });
132
- } catch (error) {
133
- throw new SynqError("network_error", `Synq couldn't be reached: ${String(error)}`, { cause: error });
134
- }
135
- const text = await response.text();
136
- let json;
137
- try {
138
- json = text ? JSON.parse(text) : void 0;
139
- } catch {
140
- json = void 0;
141
- }
142
- if (!response.ok) throw problemError(response.status, json !== null && typeof json === "object" ? json : {}, retryAfterSeconds(response.headers.get("retry-after")));
143
- return json;
144
- }
145
- return {
146
- get: call("get"),
147
- post: call("post"),
148
- put: call("put"),
149
- patch: call("patch"),
150
- delete: call("delete")
151
- };
152
- }
153
- /** The path with each `{name}` replaced by its parameter, encoded. */
154
- function fill(path, params = {}) {
155
- return path.replace(/\{([^}]+)\}/g, (_, name) => {
156
- const value = params[name];
157
- if (value === void 0 || value === "") throw new SynqError("invalid_options", `The path ${path} needs ${name}`);
158
- if (/^\.+$/.test(String(value))) throw new SynqError("invalid_options", `${name} can't be ${String(value)}`);
159
- return encodeURIComponent(String(value));
160
- });
161
- }
162
- //#endregion
163
- export { problemError as a, isSynqError as i, secureUrl as n, toSynqError as o, SynqError as r, createApiClient as t };