@recursiv/sdk 0.7.50 → 0.7.51

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.
@@ -1,4 +1,4 @@
1
- import { AuthenticationError, RecursivError, ValidationError } from '../errors.js';
1
+ import { AuthenticationError, RateLimitError, RecursivError, ValidationError } from '../errors.js';
2
2
  /**
3
3
  * Better Auth's anti-CSRF middleware requires an Origin header when the
4
4
  * request carries cookies or otherwise looks browser-initiated. We only
@@ -21,6 +21,42 @@ const needsExplicitOrigin = !isBrowser && !isReactNative;
21
21
  function originHeader(baseOrigin) {
22
22
  return needsExplicitOrigin ? { Origin: baseOrigin } : {};
23
23
  }
24
+ /**
25
+ * The customer-app headers a keyless auth request carries so the server
26
+ * resolves the right network (user lookup) and brands emails for the app.
27
+ */
28
+ function appContextHeaders(appContext) {
29
+ return {
30
+ ...(appContext?.projectId ? { 'x-recursiv-app-project': appContext.projectId } : {}),
31
+ ...(appContext?.organizationId ? { 'x-recursiv-app-organization': appContext.organizationId } : {}),
32
+ };
33
+ }
34
+ /**
35
+ * Better Auth answers a password sign-in for a two-factor account with
36
+ * `{ twoFactorRedirect: true }` and no session. Say so, instead of the
37
+ * generic "no session token" the caller would otherwise get, and hand over
38
+ * the challenge: the server returns it as `twoFactorToken` (Better Auth
39
+ * otherwise keeps it only in a cookie this SDK cannot carry). The error's
40
+ * `details` is a `TwoFactorChallenge`; pass its `twoFactorToken` and the code
41
+ * to `completeTwoFactorSignIn`.
42
+ */
43
+ function throwIfTwoFactorRequired(data) {
44
+ const body = data;
45
+ if (body?.twoFactorRedirect === true) {
46
+ const details = {
47
+ twoFactorToken: typeof body.twoFactorToken === 'string' ? body.twoFactorToken : null,
48
+ methods: Array.isArray(body.twoFactorMethods)
49
+ ? body.twoFactorMethods.filter((m) => typeof m === 'string')
50
+ : [],
51
+ };
52
+ throw new AuthenticationError({
53
+ type: 'auth_error',
54
+ message: 'This account has two-factor authentication on. Complete the second step to sign in.',
55
+ code: 'two_factor_required',
56
+ details,
57
+ });
58
+ }
59
+ }
24
60
  /**
25
61
  * Extract the server's actual error code + message from an error body.
26
62
  *
@@ -257,6 +293,69 @@ export class AuthResource {
257
293
  }));
258
294
  }
259
295
  const data = await res.json();
296
+ throwIfTwoFactorRequired(data);
297
+ const token = this.extractToken(res, data);
298
+ if (!token) {
299
+ throw new RecursivError(0, {
300
+ type: 'auth_error',
301
+ message: 'No session token returned from sign in',
302
+ code: 'no_token',
303
+ });
304
+ }
305
+ return {
306
+ token,
307
+ user: {
308
+ id: data.user?.id,
309
+ name: data.user?.name,
310
+ email: data.user?.email,
311
+ image: data.user?.image ?? null,
312
+ },
313
+ };
314
+ }
315
+ /**
316
+ * Sign in with a username (handle) and password.
317
+ *
318
+ * Handles are unique per network, not globally, so pass the customer app's
319
+ * `appContext` when the API is reached through a shared hostname: the server
320
+ * resolves the handle on that app's network and never on another. Returns a
321
+ * session token, like `signIn`. A two-factor account throws
322
+ * `two_factor_required`; finish with `completeTwoFactorSignIn`.
323
+ */
324
+ async signInWithUsername(input, appContext) {
325
+ const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/sign-in/username`, {
326
+ method: 'POST',
327
+ headers: {
328
+ 'Content-Type': 'application/json',
329
+ ...originHeader(this.baseOrigin),
330
+ ...appContextHeaders(appContext),
331
+ },
332
+ body: JSON.stringify({ username: input.username.trim().replace(/^@/, ''), password: input.password }),
333
+ });
334
+ let data = null;
335
+ try {
336
+ data = await res.json();
337
+ }
338
+ catch {
339
+ data = null;
340
+ }
341
+ if (!res.ok) {
342
+ // 401 is a wrong handle or password; 422 is a handle that cannot exist.
343
+ // Both mean the same thing to the person typing, and neither may say
344
+ // which half was wrong.
345
+ if (res.status === 401 || res.status === 403 || res.status === 422) {
346
+ throw new AuthenticationError(serverError(data, {
347
+ type: 'auth_error',
348
+ message: 'Invalid username or password',
349
+ code: 'invalid_credentials',
350
+ }));
351
+ }
352
+ throw new RecursivError(res.status, serverError(data, {
353
+ type: 'auth_error',
354
+ message: `Sign in failed: HTTP ${res.status}`,
355
+ code: 'signin_failed',
356
+ }));
357
+ }
358
+ throwIfTwoFactorRequired(data);
260
359
  const token = this.extractToken(res, data);
261
360
  if (!token) {
262
361
  throw new RecursivError(0, {
@@ -418,15 +517,20 @@ export class AuthResource {
418
517
  * whether the email exists, to avoid leaking account existence.
419
518
  *
420
519
  * Sends the client's API key (when configured) so the server can brand the
421
- * reset email for YOUR org — same surface-scoped rule as `sendOtp`.
520
+ * reset email for YOUR org — same surface-scoped rule as `sendOtp`. Keyless
521
+ * apps pass `appContext` (same shape as `sendOtp`) for the same effect.
422
522
  */
423
- async forgetPassword(input) {
523
+ async forgetPassword(input, appContext) {
424
524
  const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/request-password-reset`, {
425
525
  method: 'POST',
426
526
  headers: {
427
527
  'Content-Type': 'application/json',
428
528
  ...originHeader(this.baseOrigin),
429
529
  ...this.client.apiKeyAuthHeader(),
530
+ // Same as sendOtp: a keyless app names itself so the reset email is
531
+ // branded for it and the address is looked up on its network, not the
532
+ // platform default.
533
+ ...appContextHeaders(appContext),
430
534
  },
431
535
  body: JSON.stringify({
432
536
  email: input.email,
@@ -447,6 +551,50 @@ export class AuthResource {
447
551
  }
448
552
  return { success: true };
449
553
  }
554
+ /**
555
+ * Set a new password with the token from a password reset email (the
556
+ * `token` your reset screen receives). Every existing session and API key
557
+ * for the account is revoked by the server, so sign in again afterwards.
558
+ */
559
+ async resetPassword(input, appContext) {
560
+ const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/reset-password`, {
561
+ method: 'POST',
562
+ headers: {
563
+ 'Content-Type': 'application/json',
564
+ ...originHeader(this.baseOrigin),
565
+ ...appContextHeaders(appContext),
566
+ },
567
+ body: JSON.stringify({ newPassword: input.newPassword, token: input.token }),
568
+ });
569
+ if (!res.ok) {
570
+ let body = {};
571
+ try {
572
+ body = await res.json();
573
+ }
574
+ catch { }
575
+ const info = serverErrorInfo(body);
576
+ if (res.status === 400 && (info.code === 'INVALID_TOKEN' || info.code === 'INVALID_TOKEN_OR_EXPIRED')) {
577
+ throw new ValidationError(serverError(body, {
578
+ type: 'validation_error',
579
+ message: 'This reset link is invalid or has expired. Request a new one.',
580
+ code: 'invalid_reset_token',
581
+ }));
582
+ }
583
+ if (res.status === 400 || res.status === 422) {
584
+ throw new ValidationError(serverError(body, {
585
+ type: 'validation_error',
586
+ message: 'The new password was not accepted.',
587
+ code: 'invalid_password',
588
+ }));
589
+ }
590
+ throw new RecursivError(res.status, serverError(body, {
591
+ type: 'auth_error',
592
+ message: `Password reset failed: HTTP ${res.status}`,
593
+ code: 'reset_password_failed',
594
+ }));
595
+ }
596
+ return { success: true };
597
+ }
450
598
  /**
451
599
  * Get current session info using a session token.
452
600
  * Returns null if the session is invalid or expired.
@@ -577,6 +725,19 @@ export class AuthResource {
577
725
  const key = await this.createApiKeyOrRetireSession(keyInput, session);
578
726
  return { apiKey: key.key, apiKeyId: key.id, user: session.user, session };
579
727
  }
728
+ /**
729
+ * `signInWithUsername`, then the app API key, exactly like `signInAndCreateKey`.
730
+ * The key's project/organization is forwarded as the sign-in's app context,
731
+ * so the handle is resolved on the app's network.
732
+ */
733
+ async signInWithUsernameAndCreateKey(credentials, keyInput) {
734
+ const session = await this.signInWithUsername(credentials, {
735
+ projectId: keyInput.projectId,
736
+ organizationId: keyInput.organizationId,
737
+ });
738
+ const key = await this.createApiKeyOrRetireSession(keyInput, session);
739
+ return { apiKey: key.key, apiKeyId: key.id, user: session.user, session };
740
+ }
580
741
  /**
581
742
  * Complete mobile auth flow: sign up → create API key → return key.
582
743
  *
@@ -597,6 +758,10 @@ export class AuthResource {
597
758
  * Exchange a legacy Minds session (the OAuth access token or minds_sess JWT
598
759
  * the legacy mobile app stored) for a session on this stack. Only answers
599
760
  * during the cutover window; every failure means "show the OTP screen".
761
+ *
762
+ * A legacy token is single-use: after one successful exchange the server
763
+ * answers `legacy_token_used`, so erase the stored legacy token as soon as
764
+ * this resolves and never retry it.
600
765
  */
601
766
  async legacyHandoff(legacyToken, appContext) {
602
767
  const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/legacy-handoff`, {
@@ -660,6 +825,235 @@ export class AuthResource {
660
825
  const key = await this.createApiKeyOrRetireSession(keyInput, session);
661
826
  return { apiKey: key.key, apiKeyId: key.id, user: session.user, session };
662
827
  }
828
+ // ── OAuth consent (MCP bridge) ──
829
+ /**
830
+ * Set the API-origin session cookie from a session token this client
831
+ * already holds, so a browser can continue an OAuth authorize request
832
+ * (`/api/auth/mcp/authorize`) without signing in again: the one-tap
833
+ * "Continue as @handle". Browser only in effect: the cookie is stored by the
834
+ * browser because the request is sent with `credentials: 'include'`.
835
+ * Returns the account the cookie now signs in.
836
+ */
837
+ async exchangeSessionForCookie(sessionToken) {
838
+ const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/session-cookie`, {
839
+ method: 'POST',
840
+ credentials: 'include',
841
+ headers: {
842
+ ...originHeader(this.baseOrigin),
843
+ Authorization: `Bearer ${sessionToken}`,
844
+ },
845
+ });
846
+ let data = null;
847
+ try {
848
+ data = await res.json();
849
+ }
850
+ catch {
851
+ data = null;
852
+ }
853
+ if (!res.ok) {
854
+ const error = serverError(data, {
855
+ type: 'auth_error',
856
+ message: `Could not continue with this session: HTTP ${res.status}`,
857
+ code: 'session_cookie_failed',
858
+ });
859
+ if (res.status === 401 || res.status === 403)
860
+ throw new AuthenticationError(error);
861
+ throw new RecursivError(res.status, error);
862
+ }
863
+ return { user: data.user };
864
+ }
865
+ /**
866
+ * Public facts about the OAuth client behind an authorize request: its name,
867
+ * icon, redirect hosts, whether `redirectUri` is one it registered, and which
868
+ * requested scopes are granted. No authentication needed.
869
+ */
870
+ async getOAuthClientInfo(input) {
871
+ const query = new URLSearchParams({ client_id: input.clientId });
872
+ if (input.redirectUri !== undefined)
873
+ query.set('redirect_uri', input.redirectUri);
874
+ if (input.scope !== undefined)
875
+ query.set('scope', input.scope);
876
+ const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/mcp/client-info?${query}`, { method: 'GET' });
877
+ let data = null;
878
+ try {
879
+ data = await res.json();
880
+ }
881
+ catch {
882
+ data = null;
883
+ }
884
+ if (!res.ok) {
885
+ throw new RecursivError(res.status, serverError(data, {
886
+ type: res.status === 404 ? 'not_found' : 'api_error',
887
+ message: res.status === 404 ? 'No OAuth client is registered with that client_id' : `Client lookup failed: HTTP ${res.status}`,
888
+ code: res.status === 404 ? 'client_not_found' : 'client_info_failed',
889
+ }));
890
+ }
891
+ return data;
892
+ }
893
+ // ── Two-factor sign-in ──
894
+ /**
895
+ * Finish a password sign-in on a two-factor account. `signIn`,
896
+ * `signInWithUsername` and their `*AndCreateKey` forms throw
897
+ * `AuthenticationError` with code `two_factor_required`; its `details` is a
898
+ * `TwoFactorChallenge`. Send its `twoFactorToken` with the code the member
899
+ * types. Returns the session, like `signIn`.
900
+ *
901
+ * Errors keep the server's code: `INVALID_CODE` / `INVALID_BACKUP_CODE` (a
902
+ * wrong code: ask again), `INVALID_TWO_FACTOR_COOKIE` (the challenge expired
903
+ * or was used: sign in again), `TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE` (five
904
+ * wrong codes on this challenge: sign in again), and
905
+ * `ACCOUNT_TEMPORARILY_LOCKED` (too many wrong codes on the account: wait
906
+ * `retryAfter` seconds).
907
+ */
908
+ async completeTwoFactorSignIn(input) {
909
+ const path = input.method === 'backup_code' ? '/two-factor/verify-backup-code' : '/two-factor/verify-totp';
910
+ const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth${path}`, {
911
+ method: 'POST',
912
+ headers: {
913
+ 'Content-Type': 'application/json',
914
+ ...originHeader(this.baseOrigin),
915
+ },
916
+ body: JSON.stringify({ code: input.code.trim(), twoFactorToken: input.twoFactorToken }),
917
+ });
918
+ let data = null;
919
+ try {
920
+ data = await res.json();
921
+ }
922
+ catch {
923
+ data = null;
924
+ }
925
+ if (!res.ok) {
926
+ const retryAfter = Number(res.headers.get('retry-after'));
927
+ const error = serverError(data, {
928
+ type: 'auth_error',
929
+ message: res.status === 401 ? 'That code is not right' : `Two-factor sign-in failed: HTTP ${res.status}`,
930
+ code: res.status === 401 ? 'invalid_two_factor_code' : 'two_factor_sign_in_failed',
931
+ ...(Number.isFinite(retryAfter) && retryAfter > 0 ? { retry_after: retryAfter } : {}),
932
+ });
933
+ if (res.status === 401)
934
+ throw new AuthenticationError(error);
935
+ if (res.status === 429)
936
+ throw new RateLimitError(error);
937
+ if (res.status === 400 || res.status === 422)
938
+ throw new ValidationError(error);
939
+ throw new RecursivError(res.status, error);
940
+ }
941
+ const token = this.extractToken(res, data);
942
+ if (!token) {
943
+ throw new RecursivError(0, {
944
+ type: 'auth_error',
945
+ message: 'No session token returned from two-factor sign in',
946
+ code: 'no_token',
947
+ });
948
+ }
949
+ return {
950
+ token,
951
+ user: {
952
+ id: data.user?.id,
953
+ name: data.user?.name,
954
+ email: data.user?.email,
955
+ image: data.user?.image ?? null,
956
+ },
957
+ };
958
+ }
959
+ /**
960
+ * `completeTwoFactorSignIn`, then the app API key, exactly like
961
+ * `signInAndCreateKey`: the second half of a `*AndCreateKey` sign-in that
962
+ * threw `two_factor_required`.
963
+ */
964
+ async completeTwoFactorSignInAndCreateKey(input, keyInput) {
965
+ const session = await this.completeTwoFactorSignIn(input);
966
+ const key = await this.createApiKeyOrRetireSession(keyInput, session);
967
+ return { apiKey: key.key, apiKeyId: key.id, user: session.user, session };
968
+ }
969
+ // ── Two-factor (TOTP) ──
970
+ //
971
+ // These run against Better Auth with the member's SESSION token (the
972
+ // `session.token` from sign-in), not the API key. Verify (the call that
973
+ // turns two-factor on) and disable replace the session server-side, and the
974
+ // token passed in stops working. Every method that can rotate it returns
975
+ // the live `sessionToken`: store it.
976
+ async sessionRequest(path, body, sessionToken, fallback, appContext) {
977
+ const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth${path}`, {
978
+ method: 'POST',
979
+ headers: {
980
+ 'Content-Type': 'application/json',
981
+ ...originHeader(this.baseOrigin),
982
+ ...appContextHeaders(appContext),
983
+ Authorization: `Bearer ${sessionToken}`,
984
+ Cookie: `better-auth.session_token=${sessionToken}`,
985
+ },
986
+ body: JSON.stringify(body ?? {}),
987
+ });
988
+ let data = null;
989
+ try {
990
+ data = await res.json();
991
+ }
992
+ catch {
993
+ data = null;
994
+ }
995
+ if (!res.ok) {
996
+ const error = serverError(data, { type: 'auth_error', message: fallback.message, code: fallback.code });
997
+ if (res.status === 401)
998
+ throw new AuthenticationError({ ...error, code: error.code || 'session_required' });
999
+ if (res.status === 400 || res.status === 422)
1000
+ throw new ValidationError(error);
1001
+ throw new RecursivError(res.status, error);
1002
+ }
1003
+ return data;
1004
+ }
1005
+ static confirmationBody(input) {
1006
+ return {
1007
+ ...(input.password ? { password: input.password } : {}),
1008
+ ...(input.emailCode ? { emailCode: input.emailCode.trim() } : {}),
1009
+ };
1010
+ }
1011
+ /**
1012
+ * Email a two-factor confirmation code to an account that has no password.
1013
+ * An account with a password answers `PASSWORD_REQUIRED`: it confirms with
1014
+ * the password instead. Pass the app's `appContext` (as for `sendOtp`) when
1015
+ * the API is reached through a shared hostname, so the email carries the
1016
+ * app's brand rather than the platform's.
1017
+ */
1018
+ async requestTwoFactorEmailCode(sessionToken, appContext) {
1019
+ const data = await this.sessionRequest('/two-factor/request-email-code', {}, sessionToken, { message: 'Could not send a confirmation code', code: 'two_factor_email_code_failed' }, appContext);
1020
+ return { expiresAt: data.expiresAt };
1021
+ }
1022
+ /**
1023
+ * Start two-factor enrollment. Returns the authenticator URI and backup
1024
+ * codes; two-factor is not on until `verifyTwoFactor` accepts a code from
1025
+ * the authenticator.
1026
+ *
1027
+ * An account with no password answers `PASSWORD_REQUIRED_FOR_TWO_FACTOR`
1028
+ * unless the server enables code-only enrollment: its sign-ins (email
1029
+ * code, passkey, social) do not ask for the second factor yet.
1030
+ */
1031
+ async enableTwoFactor(input, sessionToken) {
1032
+ const data = await this.sessionRequest('/two-factor/enable', { ...AuthResource.confirmationBody(input), ...(input.issuer ? { issuer: input.issuer } : {}) }, sessionToken, { message: 'Could not start two-factor setup', code: 'two_factor_enable_failed' });
1033
+ return {
1034
+ totpURI: data.totpURI,
1035
+ backupCodes: Array.isArray(data.backupCodes) ? data.backupCodes : [],
1036
+ sessionToken: data?.session?.token || sessionToken,
1037
+ };
1038
+ }
1039
+ /**
1040
+ * Confirm enrollment with a code from the authenticator app. This is the
1041
+ * call that turns two-factor on.
1042
+ */
1043
+ async verifyTwoFactor(input, sessionToken) {
1044
+ const data = await this.sessionRequest('/two-factor/verify-totp', { code: input.code.trim() }, sessionToken, { message: 'That code is not right', code: 'invalid_two_factor_code' });
1045
+ return { sessionToken: data?.session?.token || data?.token || sessionToken };
1046
+ }
1047
+ /** Turn two-factor off. Confirmed with the password, or an email code for accounts without one. */
1048
+ async disableTwoFactor(input, sessionToken) {
1049
+ const data = await this.sessionRequest('/two-factor/disable', AuthResource.confirmationBody(input), sessionToken, { message: 'Could not turn off two-factor', code: 'two_factor_disable_failed' });
1050
+ return { sessionToken: data?.session?.token || sessionToken };
1051
+ }
1052
+ /** Replace the backup codes. The old ones stop working. */
1053
+ async regenerateTwoFactorBackupCodes(input, sessionToken) {
1054
+ const data = await this.sessionRequest('/two-factor/generate-backup-codes', AuthResource.confirmationBody(input), sessionToken, { message: 'Could not create new backup codes', code: 'two_factor_backup_codes_failed' });
1055
+ return { backupCodes: Array.isArray(data?.backupCodes) ? data.backupCodes : [] };
1056
+ }
663
1057
  /**
664
1058
  * The key-mint leg of the `*AndCreateKey` flows. When the mint fails, the
665
1059
  * caller receives only the thrown error — the session created one step
@@ -0,0 +1,148 @@
1
+ import type { HttpClient } from '../client.js';
2
+ import type { PaginatedResponse, SingleResponse } from '../types.js';
3
+ /** Campaign lifecycle axis. */
4
+ export type BoostStatus = 'active' | 'paused' | 'completed' | 'refunded';
5
+ /** Campaign moderation axis. A campaign delivers only when `active` AND `approved`. */
6
+ export type BoostReviewStatus = 'pending' | 'approved' | 'rejected';
7
+ export type BoostRejectionReason = 'against_policy' | 'wrong_audience' | 'payment' | 'reported' | 'post_removed';
8
+ export type BoostLedgerKind = 'grant' | 'topup' | 'spend' | 'refund';
9
+ /** A promoted-post campaign. Money is micro-USD (1 USD = 1,000,000). */
10
+ export interface BoostCampaign {
11
+ id: string;
12
+ post_id: string;
13
+ booster_user_id: string;
14
+ /** Bid: micro-USD per 1000 impressions. */
15
+ cpm_micros: number;
16
+ budget_micros: number;
17
+ spent_micros: number;
18
+ impressions_target: number;
19
+ impressions_served: number;
20
+ status: BoostStatus;
21
+ review_status: BoostReviewStatus;
22
+ rejection_reason: BoostRejectionReason | null;
23
+ created_at: string;
24
+ updated_at: string;
25
+ }
26
+ export interface BoostLedgerEntry {
27
+ id: string;
28
+ campaign_id: string | null;
29
+ /** Positive credits the balance, negative debits it. */
30
+ delta_micros: number;
31
+ kind: BoostLedgerKind;
32
+ note: string | null;
33
+ created_at: string;
34
+ }
35
+ export interface BoostTopupLimits {
36
+ /** Whether this app sells boost credit (always true on a `topup` response). */
37
+ available: boolean;
38
+ min_cents: number;
39
+ max_cents: number;
40
+ currency: 'usd';
41
+ }
42
+ export interface BoostBalance {
43
+ balance_micros: number;
44
+ /** The 50 most recent balance movements, newest first. */
45
+ ledger: BoostLedgerEntry[];
46
+ }
47
+ export interface BoostBalanceResponse extends SingleResponse<BoostBalance> {
48
+ meta: {
49
+ min_cpm_micros: number;
50
+ topup?: BoostTopupLimits;
51
+ };
52
+ }
53
+ export interface BoostCampaignResponse extends SingleResponse<BoostCampaign> {
54
+ meta?: {
55
+ min_cpm_micros?: number;
56
+ refunded_micros?: number;
57
+ };
58
+ }
59
+ export interface BoostEventResult {
60
+ recorded: boolean;
61
+ duplicate: boolean;
62
+ campaign_status: BoostStatus;
63
+ spent_micros: number;
64
+ impressions_served: number;
65
+ }
66
+ export interface BoostTopupCheckout {
67
+ /** Stripe Checkout URL to open. */
68
+ url: string;
69
+ /** Pass to `confirmTopup` when the member returns (the return URL also carries it as `session_id`). */
70
+ session_id: string;
71
+ }
72
+ export interface BoostTopupConfirmation {
73
+ /** `credited` now, `already_credited` earlier (webhook or a previous confirm), or `unpaid` so far. */
74
+ status: 'credited' | 'already_credited' | 'unpaid';
75
+ credited_micros: number;
76
+ balance_micros: number | null;
77
+ }
78
+ /**
79
+ * Boost: pay to promote your own post into the For You feed. CPM campaigns
80
+ * funded from a prepaid balance with an append-only ledger. Top-ups run on the
81
+ * app's own Stripe account and need a project-scoped key.
82
+ */
83
+ export declare class BoostsResource {
84
+ private client;
85
+ constructor(client: HttpClient);
86
+ /**
87
+ * Create a campaign for one of your own posts. Debits `budget_micros` from your balance
88
+ * (402 `insufficient_balance` when it cannot). Starts `pending` review.
89
+ * Not retried automatically, so a slow response cannot fund two campaigns.
90
+ */
91
+ create(input: {
92
+ post_id: string;
93
+ cpm_micros: number;
94
+ budget_micros: number;
95
+ }): Promise<BoostCampaignResponse>;
96
+ /** Your campaigns, newest first, with spend and impressions. */
97
+ mine(params?: {
98
+ limit?: number;
99
+ offset?: number;
100
+ }): Promise<PaginatedResponse<BoostCampaign>>;
101
+ /** Your balance, recent ledger, the CPM floor, and the top-up limits. */
102
+ balance(): Promise<BoostBalanceResponse>;
103
+ /** Pause one of your active campaigns. */
104
+ pause(campaignId: string): Promise<BoostCampaignResponse>;
105
+ /** Resume one of your paused campaigns. */
106
+ resume(campaignId: string): Promise<BoostCampaignResponse>;
107
+ /** Cancel a campaign and refund its unspent budget to your balance (`meta.refunded_micros`). */
108
+ cancel(campaignId: string): Promise<BoostCampaignResponse>;
109
+ /** Report an impression or click on a boosted feed entry. Idempotent per viewer per UTC day. */
110
+ recordEvent(campaignId: string, kind: 'impression' | 'click'): Promise<SingleResponse<BoostEventResult>>;
111
+ /**
112
+ * Buy boost credit: opens a one-time Stripe Checkout for `amount_cents` (USD) on the app's
113
+ * own Stripe account. The balance is credited once the payment succeeds. Limits are in
114
+ * `balance().meta.topup`. Requires a project-scoped key with `billing:write`.
115
+ */
116
+ topup(input: {
117
+ amount_cents: number;
118
+ return_url: string;
119
+ }): Promise<SingleResponse<BoostTopupCheckout> & {
120
+ meta: {
121
+ topup: BoostTopupLimits;
122
+ };
123
+ }>;
124
+ /**
125
+ * Credit a paid top-up when the member returns from Checkout. Safe to call repeatedly
126
+ * and alongside the webhook: a session credits the balance once.
127
+ */
128
+ confirmTopup(sessionId: string): Promise<SingleResponse<BoostTopupConfirmation>>;
129
+ /** Campaigns waiting for review, oldest first (admin). */
130
+ listPendingReview(params?: {
131
+ limit?: number;
132
+ offset?: number;
133
+ }): Promise<PaginatedResponse<BoostCampaign>>;
134
+ /** Approve or reject a campaign (admin). Rejecting refunds the unspent budget. */
135
+ review(campaignId: string, input: {
136
+ action: 'approve' | 'reject';
137
+ reason?: BoostRejectionReason;
138
+ }): Promise<BoostCampaignResponse>;
139
+ /** Grant balance to a user (admin). */
140
+ grant(input: {
141
+ user_id: string;
142
+ amount_micros: number;
143
+ note?: string;
144
+ }): Promise<SingleResponse<{
145
+ user_id: string;
146
+ balance_micros: number;
147
+ }>>;
148
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Boost: pay to promote your own post into the For You feed. CPM campaigns
3
+ * funded from a prepaid balance with an append-only ledger. Top-ups run on the
4
+ * app's own Stripe account and need a project-scoped key.
5
+ */
6
+ export class BoostsResource {
7
+ client;
8
+ constructor(client) {
9
+ this.client = client;
10
+ }
11
+ /**
12
+ * Create a campaign for one of your own posts. Debits `budget_micros` from your balance
13
+ * (402 `insufficient_balance` when it cannot). Starts `pending` review.
14
+ * Not retried automatically, so a slow response cannot fund two campaigns.
15
+ */
16
+ create(input) {
17
+ return this.client.post('/boosts', input, { retry: false });
18
+ }
19
+ /** Your campaigns, newest first, with spend and impressions. */
20
+ mine(params) {
21
+ return this.client.get('/boosts/mine', params);
22
+ }
23
+ /** Your balance, recent ledger, the CPM floor, and the top-up limits. */
24
+ balance() {
25
+ return this.client.get('/boosts/balance');
26
+ }
27
+ /** Pause one of your active campaigns. */
28
+ pause(campaignId) {
29
+ return this.client.post(`/boosts/${encodeURIComponent(campaignId)}/pause`, {});
30
+ }
31
+ /** Resume one of your paused campaigns. */
32
+ resume(campaignId) {
33
+ return this.client.post(`/boosts/${encodeURIComponent(campaignId)}/resume`, {});
34
+ }
35
+ /** Cancel a campaign and refund its unspent budget to your balance (`meta.refunded_micros`). */
36
+ cancel(campaignId) {
37
+ return this.client.post(`/boosts/${encodeURIComponent(campaignId)}/cancel`, {});
38
+ }
39
+ /** Report an impression or click on a boosted feed entry. Idempotent per viewer per UTC day. */
40
+ recordEvent(campaignId, kind) {
41
+ return this.client.post(`/boosts/${encodeURIComponent(campaignId)}/events`, { kind });
42
+ }
43
+ /**
44
+ * Buy boost credit: opens a one-time Stripe Checkout for `amount_cents` (USD) on the app's
45
+ * own Stripe account. The balance is credited once the payment succeeds. Limits are in
46
+ * `balance().meta.topup`. Requires a project-scoped key with `billing:write`.
47
+ */
48
+ topup(input) {
49
+ return this.client.post('/boosts/topup', input, { retry: false });
50
+ }
51
+ /**
52
+ * Credit a paid top-up when the member returns from Checkout. Safe to call repeatedly
53
+ * and alongside the webhook: a session credits the balance once.
54
+ */
55
+ confirmTopup(sessionId) {
56
+ return this.client.post('/boosts/topup/confirm', { session_id: sessionId });
57
+ }
58
+ /** Campaigns waiting for review, oldest first (admin). */
59
+ listPendingReview(params) {
60
+ return this.client.get('/boosts/review', params);
61
+ }
62
+ /** Approve or reject a campaign (admin). Rejecting refunds the unspent budget. */
63
+ review(campaignId, input) {
64
+ return this.client.post(`/boosts/review/${encodeURIComponent(campaignId)}`, input);
65
+ }
66
+ /** Grant balance to a user (admin). */
67
+ grant(input) {
68
+ return this.client.post('/boosts/grant', input, { retry: false });
69
+ }
70
+ }