@work-shape/catalyst-api-client 0.8.0-rc.1 → 0.8.0-rc.11

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/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { ApiErrorCode, Operation, OperationPathParamName, OperationRequestHeader, apiOperations, ApiOperationName } from '@work-shape/catalyst-api-contract';
2
+ import { ApiErrorCode, LocaleCode, Operation, OperationPathParamName, OperationRequestHeader, apiOperations, ApiOperationName } from '@work-shape/catalyst-api-contract';
3
3
  export * from '@work-shape/catalyst-api-contract';
4
4
 
5
5
  /**
@@ -22,14 +22,64 @@ declare const authSessionSchema: z.ZodNullable<z.ZodObject<{
22
22
  type AuthSession = NonNullable<z.infer<typeof authSessionSchema>>;
23
23
  interface AuthResult {
24
24
  ok: boolean;
25
+ /** The sign-in library's code for a refusal, such as `INVALID_EMAIL_OR_PASSWORD`. */
26
+ code?: string;
25
27
  message?: string;
28
+ /** The HTTP status of a refusal; 429 is the instance's limit on attempts. */
29
+ status?: number;
30
+ }
31
+ /** The second factor of a sign-in: a code from the authenticator app, or a backup code. */
32
+ type SecondFactorKind = "app" | "backup";
33
+ /** A sign-in: refused, signed in, or waiting for the second factor (TWO-FACTOR-1). */
34
+ interface SignInResult extends AuthResult {
35
+ /** The password was right and the account asks for a code before there is a session. */
36
+ secondFactor?: true;
37
+ }
38
+ /** What an authenticator app reads, and the ten backup codes, shown once. */
39
+ interface TwoFactorSetup {
40
+ totpUri: string;
41
+ backupCodes: string[];
26
42
  }
27
43
  declare function getAuthSession(apiBaseUrl: string): Promise<AuthSession | null>;
28
44
  declare function signInWithEmail(input: {
29
45
  apiBaseUrl: string;
30
46
  email: string;
31
47
  password: string;
48
+ }): Promise<SignInResult>;
49
+ /**
50
+ * The second sign-in step: a code from the authenticator app, or a backup code, which signs in
51
+ * once. `trustBrowser` skips the code in this browser for 30 days from now.
52
+ */
53
+ declare function verifySecondFactor(input: {
54
+ apiBaseUrl: string;
55
+ code: string;
56
+ kind: SecondFactorKind;
57
+ trustBrowser?: boolean;
58
+ }): Promise<AuthResult>;
59
+ /**
60
+ * Starts the setup of two-factor sign-in with the password. The factor is on only after the
61
+ * first code from the app is confirmed with `verifySecondFactor`.
62
+ */
63
+ declare function startTwoFactorSetup(input: {
64
+ apiBaseUrl: string;
65
+ password: string;
66
+ }): Promise<AuthResult & {
67
+ setup?: TwoFactorSetup;
68
+ }>;
69
+ /** Turns two-factor sign-in off with the password; every trusted browser is forgotten. */
70
+ declare function turnOffTwoFactor(input: {
71
+ apiBaseUrl: string;
72
+ password: string;
32
73
  }): Promise<AuthResult>;
74
+ /** Replaces all ten backup codes with the password. The new ones are shown once. */
75
+ declare function regenerateBackupCodes(input: {
76
+ apiBaseUrl: string;
77
+ password: string;
78
+ }): Promise<AuthResult & {
79
+ backupCodes?: string[];
80
+ }>;
81
+ /** Every browser of the person asks for the code again at its next sign-in. */
82
+ declare function forgetTrustedBrowsers(apiBaseUrl: string): Promise<AuthResult>;
33
83
  /**
34
84
  * For a tool outside a browser, which keeps no cookies: signs in and returns a fetch that
35
85
  * carries the session cookie and the origin the instance trusts on every request.
@@ -82,6 +132,13 @@ interface ApiClientOptions {
82
132
  */
83
133
  getToken?: () => string | undefined | Promise<string | undefined>;
84
134
  fetchImpl?: typeof fetch;
135
+ /** Cancels this client's requests when its owning session ends. */
136
+ signal?: AbortSignal;
137
+ /**
138
+ * The language every request asks the server to answer in, read at each request. A browser's
139
+ * own Accept-Language names its settings, not the language a person chose in the interface.
140
+ */
141
+ locale?: LocaleCode;
85
142
  /** Only available without a token source; browser navigation cannot send a bearer token. */
86
143
  browserManagedDownloads?: boolean;
87
144
  /**
@@ -127,7 +184,12 @@ type ControlPart<Op extends Operation> = {
127
184
  } ? {
128
185
  /** Called when the server has nothing after the given position and sends no stream. */
129
186
  onCaughtUp?: () => void;
130
- } : unknown);
187
+ } & (Op["response"] extends {
188
+ skip: infer Skip extends z.ZodType;
189
+ } ? {
190
+ /** Called for an event this client does not know, which the stream passes over. */
191
+ onSkipped?: (value: z.output<Skip>) => void;
192
+ } : unknown) : unknown);
131
193
  /**
132
194
  * An operation that takes an `Idempotency-Key` gets a new one with every call. A caller that
133
195
  * sends the same call again after an answer that never arrived passes the key of the first.
@@ -139,7 +201,18 @@ type IdempotencyPart<Op> = Op extends {
139
201
  } ? "Idempotency-Key" extends Header ? {
140
202
  idempotencyKey?: string;
141
203
  } : unknown : unknown;
142
- type InputOf<Op extends Operation> = ParamsPart<Op["path"]> & QueryPart<Op> & BodyPart<Op> & IdempotencyPart<Op> & ControlPart<Op>;
204
+ /**
205
+ * An operation that takes `Catalyst-Unattended` is sent with it when `unattended` is set: nobody
206
+ * watches the caller's terminal, so a call that needs a confirmation waits in the Inbox.
207
+ */
208
+ type UnattendedPart<Op> = Op extends {
209
+ headers: {
210
+ request: readonly (infer Header extends OperationRequestHeader)[];
211
+ };
212
+ } ? "Catalyst-Unattended" extends Header ? {
213
+ unattended?: boolean;
214
+ } : unknown : unknown;
215
+ type InputOf<Op extends Operation> = ParamsPart<Op["path"]> & QueryPart<Op> & BodyPart<Op> & IdempotencyPart<Op> & UnattendedPart<Op> & ControlPart<Op>;
143
216
  /**
144
217
  * How a call of an operation that may wait for an approval came out. A refusal and a failure
145
218
  * are thrown as `ApiError`, as for every operation.
@@ -153,7 +226,10 @@ type OperationOutcome<Output, Run = unknown> = {
153
226
  status: "done";
154
227
  output: Output;
155
228
  }
156
- /** Nothing ran yet: somebody else decides. `location` is where the run is read again. */
229
+ /**
230
+ * Nothing ran yet: somebody decides it, an approver or, for a run `pending_confirmation`, the
231
+ * caller in their Inbox. `location` is where the run is read again.
232
+ */
157
233
  | {
158
234
  status: "pending_approval";
159
235
  run: Run;
@@ -169,6 +245,8 @@ type OutputOf<Op extends Operation> = Op["response"] extends {
169
245
  kind: "sse";
170
246
  schema: infer Schema extends z.ZodType;
171
247
  } ? AsyncIterable<z.output<Schema>> : Op["response"] extends {
248
+ kind: "redirect";
249
+ } ? Promise<undefined> : Op["response"] extends {
172
250
  schema: infer Schema extends z.ZodType;
173
251
  } ? Op extends {
174
252
  accepted: infer Accepted extends z.ZodType;
@@ -218,4 +296,4 @@ declare function listAll<Item>(readPage: (paging: {
218
296
  nextCursor?: string;
219
297
  }>): Promise<Item[]>;
220
298
 
221
- export { AUTH_MOUNT_PATH, type ApiClient, type ApiClientFor, type ApiClientOptions, ApiError, type ApiOperationMethods, ApiResponseShapeError, type AuthResult, type AuthSession, type OperationInput, type OperationOutcome, completedOutput, createApiClient, createApiClientFor, getAuthSession, listAll, refusalError, signInForSessionFetch, signInWithEmail, signOut, transportError, unreadableResponseError };
299
+ export { AUTH_MOUNT_PATH, type ApiClient, type ApiClientFor, type ApiClientOptions, ApiError, type ApiOperationMethods, ApiResponseShapeError, type AuthResult, type AuthSession, type OperationInput, type OperationOutcome, type SecondFactorKind, type SignInResult, type TwoFactorSetup, completedOutput, createApiClient, createApiClientFor, forgetTrustedBrowsers, getAuthSession, listAll, refusalError, regenerateBackupCodes, signInForSessionFetch, signInWithEmail, signOut, startTwoFactorSetup, transportError, turnOffTwoFactor, unreadableResponseError, verifySecondFactor };
package/dist/index.js CHANGED
@@ -6,9 +6,15 @@ var authSessionSchema = z.object({
6
6
  user: z.object({ id: z.string(), email: z.string(), name: z.string() })
7
7
  }).nullable();
8
8
  var authFailureSchema = z.object({
9
+ code: z.string().optional(),
9
10
  message: z.string().optional(),
10
11
  error: z.object({ message: z.string().optional() }).optional()
11
12
  });
13
+ var signInAnswerSchema = z.object({ twoFactorRedirect: z.boolean().optional() });
14
+ var twoFactorSetupSchema = z.object({
15
+ totpURI: z.string(),
16
+ backupCodes: z.array(z.string())
17
+ });
12
18
  async function getAuthSession(apiBaseUrl) {
13
19
  const response = await fetch(authUrl(apiBaseUrl, "/get-session"), {
14
20
  credentials: "include"
@@ -32,12 +38,64 @@ async function signInWithEmail(input) {
32
38
  })
33
39
  });
34
40
  if (response.ok) {
35
- return { ok: true };
41
+ const answer = signInAnswerSchema.safeParse(await response.json().catch(() => void 0));
42
+ return answer.data?.twoFactorRedirect === true ? { ok: true, secondFactor: true } : { ok: true };
43
+ }
44
+ return failureOf(response, "Sign in failed");
45
+ }
46
+ async function verifySecondFactor(input) {
47
+ const path = input.kind === "app" ? "/two-factor/verify-totp" : "/two-factor/verify-backup-code";
48
+ const response = await postAuth(input.apiBaseUrl, path, {
49
+ code: input.code.replaceAll(/\s/gu, ""),
50
+ trustDevice: input.trustBrowser === true
51
+ });
52
+ return response.ok ? { ok: true } : failureOf(response, "The code was not accepted");
53
+ }
54
+ async function startTwoFactorSetup(input) {
55
+ const response = await postAuth(input.apiBaseUrl, "/two-factor/enable", {
56
+ password: input.password
57
+ });
58
+ if (!response.ok) {
59
+ return failureOf(response, "Two-factor sign-in could not be set up");
60
+ }
61
+ const setup = twoFactorSetupSchema.parse(await response.json());
62
+ return { ok: true, setup: { totpUri: setup.totpURI, backupCodes: setup.backupCodes } };
63
+ }
64
+ async function turnOffTwoFactor(input) {
65
+ const response = await postAuth(input.apiBaseUrl, "/two-factor/disable", {
66
+ password: input.password
67
+ });
68
+ return response.ok ? { ok: true } : failureOf(response, "Two-factor sign-in stayed on");
69
+ }
70
+ async function regenerateBackupCodes(input) {
71
+ const response = await postAuth(input.apiBaseUrl, "/two-factor/generate-backup-codes", {
72
+ password: input.password
73
+ });
74
+ if (!response.ok) {
75
+ return failureOf(response, "No new backup codes were made");
36
76
  }
77
+ const { backupCodes } = twoFactorSetupSchema.pick({ backupCodes: true }).parse(await response.json());
78
+ return { ok: true, backupCodes };
79
+ }
80
+ async function forgetTrustedBrowsers(apiBaseUrl) {
81
+ const response = await postAuth(apiBaseUrl, "/two-factor/forget-trusted-browsers", {});
82
+ return response.ok ? { ok: true } : failureOf(response, "The browsers are still trusted");
83
+ }
84
+ function postAuth(apiBaseUrl, path, body) {
85
+ return fetch(authUrl(apiBaseUrl, path), {
86
+ method: "POST",
87
+ credentials: "include",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify(body)
90
+ });
91
+ }
92
+ async function failureOf(response, fallback) {
37
93
  const failure = authFailureSchema.safeParse(await response.json().catch(() => void 0));
38
94
  return {
39
95
  ok: false,
40
- message: failure.data?.message ?? failure.data?.error?.message ?? "Sign in failed"
96
+ status: response.status,
97
+ ...failure.data?.code === void 0 ? {} : { code: failure.data.code },
98
+ message: failure.data?.message ?? failure.data?.error?.message ?? fallback
41
99
  };
42
100
  }
43
101
  async function signInForSessionFetch(input) {
@@ -50,6 +108,12 @@ async function signInForSessionFetch(input) {
50
108
  if (!response.ok) {
51
109
  throw new Error(`Sign-in as '${input.email}' was refused with status ${response.status}`);
52
110
  }
111
+ const answer = signInAnswerSchema.safeParse(
112
+ await response.clone().json().catch(() => void 0)
113
+ );
114
+ if (answer.data?.twoFactorRedirect === true) {
115
+ throw new Error(`'${input.email}' signs in with a second factor; use an API key for this tool`);
116
+ }
53
117
  const cookie = response.headers.getSetCookie().map((value) => value.split(";", 1)[0]).join("; ");
54
118
  return (resource, init) => {
55
119
  const request = new Request(resource, init);
@@ -180,7 +244,9 @@ function readEventData(block) {
180
244
  }
181
245
 
182
246
  // src/transport.ts
183
- import { isUnknownOperationResponse } from "@work-shape/catalyst-api-contract";
247
+ import {
248
+ isUnknownOperationResponse
249
+ } from "@work-shape/catalyst-api-contract";
184
250
  function createApiTransport(options) {
185
251
  const baseUrl = options.baseUrl.replace(/\/$/u, "");
186
252
  const cookieMode = options.getToken === void 0;
@@ -209,10 +275,20 @@ function createApiTransport(options) {
209
275
  if (operation.headers?.request?.includes("Idempotency-Key")) {
210
276
  headers.set("idempotency-key", request.idempotencyKey ?? createIdempotencyKey());
211
277
  }
278
+ if (request.unattended && operation.headers?.request?.includes("Catalyst-Unattended")) {
279
+ headers.set("catalyst-unattended", "1");
280
+ }
212
281
  const token = await options.getToken?.();
282
+ const sessionSignal = options.signal;
283
+ const signal = sessionSignal && request.signal ? AbortSignal.any([sessionSignal, request.signal]) : sessionSignal ?? request.signal;
213
284
  if (token) {
214
285
  headers.set("authorization", `Bearer ${token}`);
215
286
  }
287
+ const locale = options.locale;
288
+ if (locale !== void 0) {
289
+ headers.set("accept-language", locale);
290
+ }
291
+ signal?.throwIfAborted();
216
292
  let response;
217
293
  try {
218
294
  response = await (options.fetchImpl ?? fetch)(url, {
@@ -220,10 +296,10 @@ function createApiTransport(options) {
220
296
  headers,
221
297
  body,
222
298
  credentials: cookieMode ? "include" : "omit",
223
- signal: request.signal
299
+ signal
224
300
  });
225
301
  } catch (error) {
226
- throw request.signal?.aborted ? error : transportError(error);
302
+ throw signal?.aborted ? error : transportError(error);
227
303
  }
228
304
  if (response.ok) {
229
305
  return response;
@@ -311,7 +387,13 @@ function hasEveryOperation(operations, tree) {
311
387
  function callOperation(transport, operation, request) {
312
388
  const { response } = operation;
313
389
  if (response.kind === "sse") {
314
- return streamEvents(transport, operation, response.schema, request);
390
+ return streamEvents(transport, operation, response, request);
391
+ }
392
+ if (response.kind === "redirect") {
393
+ return transport.send(operation, request).then(async (answer) => {
394
+ await answer.body?.cancel();
395
+ return void 0;
396
+ });
315
397
  }
316
398
  if (response.kind === "blob") {
317
399
  return transport.send(operation, request).then((answer) => readBody(() => answer.blob(), request));
@@ -341,7 +423,7 @@ function callOperation(transport, operation, request) {
341
423
  };
342
424
  });
343
425
  }
344
- async function* streamEvents(transport, operation, schema, request) {
426
+ async function* streamEvents(transport, operation, response, request) {
345
427
  const answer = await transport.send(operation, request);
346
428
  if (answer.status === 204) {
347
429
  request.onCaughtUp?.();
@@ -352,7 +434,12 @@ async function* streamEvents(transport, operation, schema, request) {
352
434
  }
353
435
  try {
354
436
  for await (const data of readServerSentEvents(answer.body)) {
355
- yield parseJson(transport, data, schema, answer.status);
437
+ const event = readEvent(transport, data, response, answer.status);
438
+ if (event.skipped) {
439
+ request.onSkipped?.(event.value);
440
+ continue;
441
+ }
442
+ yield event.value;
356
443
  }
357
444
  } catch (error) {
358
445
  throw asCallFailure(error, request);
@@ -368,6 +455,22 @@ async function readBody(read, request) {
368
455
  function asCallFailure(error, request) {
369
456
  return error instanceof ApiError || request.signal?.aborted ? error : transportError(error);
370
457
  }
458
+ function readEvent(transport, text, response, status) {
459
+ const { skip } = response;
460
+ if (skip) {
461
+ let value;
462
+ try {
463
+ value = JSON.parse(text);
464
+ } catch (error) {
465
+ throw unreadableResponseError(status, error);
466
+ }
467
+ const parsed = response.schema.safeParse(value);
468
+ if (parsed.success) return { skipped: false, value: parsed.data };
469
+ const skipped = skip.safeParse(value);
470
+ if (skipped.success) return { skipped: true, value: skipped.data };
471
+ }
472
+ return { skipped: false, value: parseJson(transport, text, response.schema, status) };
473
+ }
371
474
  function parseJson(transport, text, schema, status) {
372
475
  let value;
373
476
  try {
@@ -392,12 +495,17 @@ export {
392
495
  completedOutput,
393
496
  createApiClient,
394
497
  createApiClientFor,
498
+ forgetTrustedBrowsers,
395
499
  getAuthSession,
396
500
  listAll,
397
501
  refusalError,
502
+ regenerateBackupCodes,
398
503
  signInForSessionFetch,
399
504
  signInWithEmail,
400
505
  signOut,
506
+ startTwoFactorSetup,
401
507
  transportError,
402
- unreadableResponseError
508
+ turnOffTwoFactor,
509
+ unreadableResponseError,
510
+ verifySecondFactor
403
511
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@work-shape/catalyst-api-client",
3
- "version": "0.8.0-rc.1",
3
+ "version": "0.8.0-rc.11",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "zod": "4.4.3",
32
- "@work-shape/catalyst-api-contract": "0.8.0-rc.1"
32
+ "@work-shape/catalyst-api-contract": "0.8.0-rc.11"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^24.13.1",