@recursiv/sdk 0.7.54 → 0.7.55

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/README.md CHANGED
@@ -75,7 +75,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
75
75
  | Resource | Key methods |
76
76
  |----------|------------|
77
77
  | `r.agents` | `create`, `update`, `chat`, `chatStream`, `retryChatStream`, `grantProjectAccess`, `conversations` |
78
- | `r.e2ee` | `registerDevice`, `devices`, `revokeDevice`, `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge` (flag-gated E2EE v2 transport) |
78
+ | `r.e2ee` | `status`, `registerDevice`, `devices`, `revokeDevice`, `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge` (flag-gated E2EE v2 transport) |
79
79
  | `r.brain` | `sendMessage` |
80
80
  | `r.commands` | `execute`, `gatePrompt` |
81
81
  | `r.projectBrain` | `tasks`, `decisions`, `milestones`, `completeTask`, `usage` |
@@ -84,7 +84,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
84
84
  ### Identity & Auth
85
85
  | Resource | Key methods |
86
86
  |----------|------------|
87
- | `r.auth` | `signUp`, `signIn`, `signInWithUsername`, `getSession`, `signOut`, `createApiKey`, `forgetPassword`, `resetPassword`; two-factor: `enableTwoFactor`, `verifyTwoFactor`, `disableTwoFactor`, `regenerateTwoFactorBackupCodes`, `requestTwoFactorEmailCode`, `completeTwoFactorSignIn`; OAuth consent: `exchangeSessionForCookie`, `getOAuthClientInfo` (session calls use explicit Bearer auth with a native/server Cookie fallback) |
87
+ | `r.auth` | `signUp`, `signIn`, `signInWithUsername`, `getSession`, `signOut`, `createApiKey`, `forgetPassword`, `resetPassword`; two-factor: `enableTwoFactor`, `verifyTwoFactor`, `disableTwoFactor`, `regenerateTwoFactorBackupCodes`, `requestTwoFactorEmailCode`, `completeTwoFactorSignIn`; OAuth consent: `exchangeSessionForCookie`, `getOAuthClientInfo`; cutover: `legacyHandoff` (a legacy Minds session for a session here; throws `LegacyHandoffError`) (session calls use explicit Bearer auth with a native/server Cookie fallback) |
88
88
  | `@recursiv/sdk/next` | `createRecursivAuth` — cookie-session server auth for Next.js App Router (see below) |
89
89
  | `r.users` | `me`, `get` |
90
90
  | `r.organizations` | `create`, `members`, `addMember`, `invite` |
@@ -115,7 +115,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
115
115
  | `r.admin` | User management, stats, content reports (`listReports`, `resolveReport`, `dismissReport`) |
116
116
  | `r.dispatcher` | Task queue, claims, signals, outcomes, webhooks |
117
117
  | `r.goals` | Org goals with task rollups (group dispatcher tasks) |
118
- | `r.protocols` | Private Minds safety: `sourceControls` (current enablement and saved choices; filter with `control`, each item may carry a `display` label), `setSourceControl`, `onSourceControlChange`, `reportSource`. Delegated accounts: `nativeSocialAccounts`, `startNativeSocialConnection`, `finishNativeSocialConnection`, `disconnectNativeSocialAccount`. Native follows: `nativeFollowStates` (every connected account's state for one source, one read), `nativeFollowState`, `prepareNativeFollow`, `submitNativeFollow`, `nativeFollowStatus` (native following or pending approval, distinct from a Minds feed subscription; preserve the complete signed Nostr list). Native reposts and undo: `nativeRepostState`, `prepareNativeRepost`, `submitNativeRepost`, `nativeRepostStatus` (public posts, explicit approval, connected account, no uncertain retries). Native likes and unlikes: `nativeLikeState`, `prepareNativeLike`, `submitNativeLike`, `nativeLikeStatus` (connected authority, app enablement and explicit approval; resume pending actions instead of duplicating them). Reviewed replies and optional linked posts: `prepareNativeReply`, `submitNativeReply`, `nativeReplyStatus`, `nativeReplyHistory`, `prepareNativeReplyRemoval`, `removeNativeReply` (require deployed API support and app enablement). Keyless shared post links: `sharedNetworks(projectId)`, `getSharedConversation(projectId, { protocol, post, readRef? })`. Member public reading: `searchPublicPosts` (selected account required for Bluesky/ActivityPub; public Nostr search), `getConnectedSocialConversation` (preserve result readerAccountId/readRef), `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; radar stories: `getPublicRadarStory` (one story by id with its posts; one that has left the radar comes with its archived copy and the live story it continues as), keyless `getSharedRadarStory(projectId, { cluster })` and `getSharedRadarPosts(projectId, { cluster })`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. `list` returns bounded adapter `maturity`, `limitations`, and exhaustive `supported`/`planned`/`unsupported` implementation capabilities (not provider health or enablement); settings, candidate refresh/status, search, and read-only quarantined candidate review via `listCandidates` / `getCandidate` |
118
+ | `r.protocols` | Private Minds safety: `sourceControls` (current enablement and saved choices; filter with `control`, each item may carry a `display` label), `setSourceControl`, `onSourceControlChange`, `reportSource`. Delegated accounts: `nativeSocialAccounts`, `startNativeSocialConnection`, `finishNativeSocialConnection`, `disconnectNativeSocialAccount`. Native follows: `nativeFollowStates` (every connected account's state for one source, one read), `nativeFollowState`, `prepareNativeFollow`, `submitNativeFollow`, `nativeFollowStatus` (native following or pending approval, distinct from a Minds feed subscription; preserve the complete signed Nostr list). Native reposts and undo: `nativeRepostState`, `prepareNativeRepost`, `submitNativeRepost`, `nativeRepostStatus` (public posts, explicit approval, connected account, no uncertain retries). Native likes and unlikes: `nativeLikeState`, `prepareNativeLike`, `submitNativeLike`, `nativeLikeStatus` (connected authority, app enablement and explicit approval; resume pending actions instead of duplicating them). Reviewed replies and optional linked posts: `prepareNativeReply`, `submitNativeReply`, `nativeReplyStatus`, `nativeReplyHistory`, `prepareNativeReplyRemoval`, `removeNativeReply` (require deployed API support and app enablement). Keyless shared post links: `sharedNetworks(projectId)`, `getSharedConversation(projectId, { protocol, post, readRef? })`. Member public reading: `searchPublicPosts` (selected account required for Bluesky/ActivityPub; public Nostr search), `getConnectedSocialConversation` (preserve result readerAccountId/readRef), `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; radar stories: `getPublicRadarStory` (one story by id with its posts; one that has left the radar comes with its archived copy and the live story it continues as), keyless `getSharedRadarStory(projectId, { cluster })` and `getSharedRadarPosts(projectId, { cluster })` (keyless reads carry images and videos only from outlets and members marked `visitorMedia`; signed-in reads keep all media, so a share card uses only those); private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. `list` returns bounded adapter `maturity`, `limitations`, and exhaustive `supported`/`planned`/`unsupported` implementation capabilities (not provider health or enablement); settings, candidate refresh/status, search, and read-only quarantined candidate review via `listCandidates` / `getCandidate` |
119
119
 
120
120
  Full reference: see [llm.md](./llm.md) (ships with this package) or the type definitions in `dist/`.
121
121
 
@@ -689,20 +689,29 @@ category, refreshed after 30 s, kept up to 300 s and warmed as the warmer reache
689
689
  viewer's `people` and `groups`; a section that timed out, failed or is off is empty with a
690
690
  `reason` for that request only (a failure is never cached, so asking again retries it), and one this
691
691
  key may not read is empty with `refused` (`stories` and `network` need the member's own app
692
- key with `users:read`; `people` and `groups` need `users:read`). In a category, a Bluesky
693
- topic the provider no longer lists, or whose feed holds nothing in the category, is served
694
- the network's indexed posts in it (`sourceIndex.reason` of `gone` or `empty`). While the
695
- provider has more of the topic the page's cursor is the provider's own, so the topic's
696
- deeper pages are still read; once it has none, or the topic is gone, the cursor is an
697
- `idx:tf:` one that continues in the index. When the index cannot answer (busy, or its safety
698
- cache unreadable), the page is what the provider answered: the empty page with its own
699
- cursor, or the gone topic's 404; only an `idx:tf:` page answers 503 `unavailable`, and that
700
- ends the index continuation, not the network. The app contract: a `trend_unavailable` 404,
701
- an empty topic page or a failed `idx:tf:` page never disables a network for the session;
702
- move to the next topic, keep every provider continuation, start the `idx:tf:` stream once
703
- per network and session (every such topic answers the same index stream; after a failed
704
- page it may start again), and pass cursors back unchanged. `DiscoveryFeedSession` does
705
- this for you.
692
+ key with `users:read`; `people` and `groups` need `users:read`). A plain category page
693
+ (`posts.list({ category })` with no other filter) reads on past a slice the safety floors
694
+ thinned, up to six reads, until it holds `limit` posts or its candidates end: `meta.scanned`
695
+ is how many it examined (the next offset is `offset + scanned`), and the stage ends on the
696
+ page that reaches its end, with `next` and `fallover`, rather than after a trail of empty
697
+ pages. In a category, Discover's read of Bluesky (`getPublicTrends` with `category` and
698
+ `discovery: true`) is the network's posts judged into the category from the source index,
699
+ as a page of posts with an `idx:tf:` cursor (pass it back to `getPublicTrends`), not the
700
+ topic catalog, which ignores the category; with the index off the catalog is answered as
701
+ before. A Bluesky topic feed in a category whose topic is gone, or whose page holds nothing
702
+ in the category, is served the network's indexed posts in it (`sourceIndex.reason` of `gone`
703
+ or `empty`) with an `idx:tf:` cursor that continues in the index; the provider's deeper pages
704
+ of such a topic are not read. In a category, a network's feed ends when its source does (a null
705
+ cursor) or on the third page in a row that shows no posts: an `idx:` cursor carries that count,
706
+ so pass it back unchanged; a provider's own cursor continues until the provider ends it. While a
707
+ category is read from the index, the server reads one Bluesky catalog topic a minute into it in
708
+ the background. When the index cannot answer (busy, or its safety cache unreadable), the page
709
+ is what the provider answered: the empty page, or the gone topic's 404; only an `idx:tf:` page
710
+ answers 503 `unavailable`, and that ends the index continuation, not the network. The app
711
+ contract: a `trend_unavailable` 404, an empty topic page or a failed `idx:tf:` page never
712
+ disables a network for the session; move to the next topic, start the `idx:tf:` stream once per network and session
713
+ (every such topic answers the same index stream; after a failed page it may start again),
714
+ and pass cursors back unchanged. `DiscoveryFeedSession` does this for you.
706
715
 
707
716
  `sdk.posts.categories()` answers the app's categories in the order this member
708
717
  should see them. `meta.order` is `personal`, `popular` or `static`; `meta.reason`
package/dist/client.d.ts CHANGED
@@ -35,6 +35,7 @@ export declare class HttpClient {
35
35
  }): Promise<T>;
36
36
  post<T>(path: string, body?: unknown, options?: {
37
37
  retry?: boolean;
38
+ idempotencyKey?: string;
38
39
  }): Promise<T>;
39
40
  put<T>(path: string, body?: unknown, options?: {
40
41
  retry?: boolean;
package/dist/client.js CHANGED
@@ -182,6 +182,7 @@ export class HttpClient {
182
182
  headers: {
183
183
  ...this.getAuthHeaders(),
184
184
  'Content-Type': 'application/json',
185
+ ...(options?.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),
185
186
  },
186
187
  body: body ? JSON.stringify(body) : undefined,
187
188
  };
package/dist/errors.d.ts CHANGED
@@ -24,6 +24,19 @@ export declare class RecursivError extends Error {
24
24
  export declare class AuthenticationError extends RecursivError {
25
25
  constructor(body: ApiErrorBody['error']);
26
26
  }
27
+ /**
28
+ * A failed legacy session handoff (`auth.legacyHandoff`). It keeps what a
29
+ * caller needs to tell a refusal from an outage: the HTTP status (0 when no
30
+ * response arrived), the server's code (`timeout` or `network_error` when no
31
+ * response arrived), and the seconds the server's Retry-After asked for.
32
+ * Only `legacy_token_used` and `legacy_token_invalid` are final; every other
33
+ * failure leaves the legacy session worth trying again.
34
+ */
35
+ export declare class LegacyHandoffError extends RecursivError {
36
+ /** Seconds from the server's Retry-After header, when it sent one. */
37
+ readonly retryAfterSeconds: number | null;
38
+ constructor(status: number, body: ApiErrorBody['error']);
39
+ }
27
40
  export declare class AuthorizationError extends RecursivError {
28
41
  constructor(body: ApiErrorBody['error']);
29
42
  }
package/dist/errors.js CHANGED
@@ -66,6 +66,23 @@ export class AuthenticationError extends RecursivError {
66
66
  this.name = 'AuthenticationError';
67
67
  }
68
68
  }
69
+ /**
70
+ * A failed legacy session handoff (`auth.legacyHandoff`). It keeps what a
71
+ * caller needs to tell a refusal from an outage: the HTTP status (0 when no
72
+ * response arrived), the server's code (`timeout` or `network_error` when no
73
+ * response arrived), and the seconds the server's Retry-After asked for.
74
+ * Only `legacy_token_used` and `legacy_token_invalid` are final; every other
75
+ * failure leaves the legacy session worth trying again.
76
+ */
77
+ export class LegacyHandoffError extends RecursivError {
78
+ /** Seconds from the server's Retry-After header, when it sent one. */
79
+ retryAfterSeconds;
80
+ constructor(status, body) {
81
+ super(status, body);
82
+ this.name = 'LegacyHandoffError';
83
+ this.retryAfterSeconds = typeof body.retry_after === 'number' ? body.retry_after : null;
84
+ }
85
+ }
69
86
  export class AuthorizationError extends RecursivError {
70
87
  constructor(body) {
71
88
  super(403, body);
package/dist/index.d.ts CHANGED
@@ -147,14 +147,14 @@ export { HttpClient } from './client.js';
147
147
  export { RealtimeClient } from './realtime.js';
148
148
  export type { RealtimeConnectOptions } from './realtime.js';
149
149
  export { AuthResource } from './resources/auth.js';
150
- export type { AuthSession, CurrentSession, SignUpInput, SignInInput, UsernameSignInInput, AppContext, ResetPasswordInput, TwoFactorConfirmation, TwoFactorEnrollment, TwoFactorChallenge, CompleteTwoFactorSignInInput, OAuthClientInfo, SendOtpInput, VerifyOtpInput, CreateApiKeyInput, ApiKeyResult, } from './resources/auth.js';
150
+ export type { AuthSession, CurrentSession, SignUpInput, SignInInput, UsernameSignInInput, AppContext, ResetPasswordInput, TwoFactorConfirmation, TwoFactorEnrollment, TwoFactorChallenge, CompleteTwoFactorSignInInput, OAuthClientInfo, SendOtpInput, VerifyOtpInput, CreateApiKeyInput, ApiKeyResult, LegacyHandoffOptions, LegacyHandoffSession, } from './resources/auth.js';
151
151
  export { PostsResource } from './resources/posts.js';
152
152
  export type { CategoryOrder, CategoryOrderMeta, CategoryOrderReason } from './resources/posts.js';
153
153
  export { UsersResource } from './resources/users.js';
154
154
  export { CommunitiesResource } from './resources/communities.js';
155
155
  export { ChatResource } from './resources/chat.js';
156
156
  export { E2eeResource } from './resources/e2ee.js';
157
- export type { E2eeDevice, E2eeEnvelope, E2eeEvent, E2eeKeyPackageStatus, E2eeObjectDownload, E2eeObjectUpload } from './resources/e2ee.js';
157
+ export type { E2eeDevice, E2eeEnvelope, E2eeEvent, E2eeKeyPackageStatus, E2eeObjectDownload, E2eeObjectUpload, E2eeStatus, E2eeStatusReason } from './resources/e2ee.js';
158
158
  export { ProjectsResource } from './resources/projects.js';
159
159
  export { AgentsResource, SUPPORTED_MODELS } from './resources/agents.js';
160
160
  export { EvidenceResource } from './resources/evidence.js';
@@ -165,7 +165,7 @@ export { SandboxResource } from './resources/sandbox.js';
165
165
  export { ProfilesResource } from './resources/profiles.js';
166
166
  export { OrganizationsResource } from './resources/organizations.js';
167
167
  export { NotificationsResource } from './resources/notifications.js';
168
- export type { Notification, NotificationActor, NotificationCategory, NotificationPostPreview, NotificationScopeParams, NotificationUnreadCount, ListNotificationsParams, NotificationListResponse, } from './resources/notifications.js';
168
+ export type { Notification, NotificationActor, NotificationCategory, NotificationPostPreview, NotificationPostTag, NotificationScopeParams, NotificationUnreadCount, ListNotificationsParams, NotificationListResponse, } from './resources/notifications.js';
169
169
  export { SettingsResource } from './resources/settings.js';
170
170
  export { BillingResource } from './resources/billing.js';
171
171
  export { FreeTierResource } from './resources/free-tier.js';
@@ -240,7 +240,7 @@ export { TemplatesResource } from './resources/templates.js';
240
240
  export type { Template, CreateTemplateInput, UpdateTemplateInput, ListTemplatesParams } from './resources/templates.js';
241
241
  export { MediaResource } from './resources/media.js';
242
242
  export type { GenerateImageInput, GeneratedImage, ImageProvider } from './resources/media.js';
243
- export { RecursivError, AuthenticationError, AuthorizationError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, } from './errors.js';
243
+ export { RecursivError, AuthenticationError, AuthorizationError, LegacyHandoffError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, } from './errors.js';
244
244
  export { parseSSE } from './lib/sse.js';
245
245
  export * from './lib/webhooks.js';
246
246
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -277,6 +277,6 @@ export { HumanInputResource } from './resources/human-input.js';
277
277
  export { SwarmsResource } from './resources/swarms.js';
278
278
  export { TemplatesResource } from './resources/templates.js';
279
279
  export { MediaResource } from './resources/media.js';
280
- export { RecursivError, AuthenticationError, AuthorizationError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, } from './errors.js';
280
+ export { RecursivError, AuthenticationError, AuthorizationError, LegacyHandoffError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, } from './errors.js';
281
281
  export { parseSSE } from './lib/sse.js';
282
282
  export * from './lib/webhooks.js';
@@ -1,4 +1,26 @@
1
1
  import type { HttpClient } from '../client.js';
2
+ /** Options for `auth.legacyHandoff`. */
3
+ export interface LegacyHandoffOptions {
4
+ projectId?: string;
5
+ organizationId?: string;
6
+ /** Random, 22 to 128 URL-safe characters, kept with the legacy session and sent with every exchange of it. */
7
+ attemptId?: string;
8
+ /** The legacy OAuth refresh token, when the legacy session has one. */
9
+ refreshToken?: string | null;
10
+ /** Bound for this call, body included. Defaults to the client's timeout. */
11
+ timeoutMs?: number;
12
+ }
13
+ /** What `auth.legacyHandoff` answers. */
14
+ export interface LegacyHandoffSession extends AuthSession {
15
+ /**
16
+ * Present when an `attemptId` was sent. Give the API key made with this
17
+ * session this description (`createApiKey({ ..., description })`): the
18
+ * server counts only keys that carry it when it decides whether a replay of
19
+ * the attempt is still allowed, so the member signing in elsewhere never
20
+ * refuses this device, and the key revoking itself is never counted as use.
21
+ */
22
+ apiKeyDescription?: string;
23
+ }
2
24
  export interface AuthSession {
3
25
  token: string;
4
26
  user: {
@@ -356,18 +378,32 @@ export declare class AuthResource {
356
378
  /**
357
379
  * Exchange a legacy Minds session (the OAuth access token or minds_sess JWT
358
380
  * the legacy mobile app stored) for a session on this stack. Only answers
359
- * during the cutover window; every failure means "show the OTP screen".
381
+ * during the cutover window.
360
382
  *
361
- * A legacy token is single-use: after one successful exchange the server
362
- * answers `legacy_token_used`, so erase the stored legacy token as soon as
363
- * this resolves and never retry it.
383
+ * A failure throws `LegacyHandoffError` with the HTTP status (0 when no
384
+ * response arrived), the server's code and its Retry-After. Only
385
+ * `legacy_token_used` and `legacy_token_invalid` mean the legacy session can
386
+ * never be exchanged; forget it then. Anything else (`legacy_unavailable`,
387
+ * `legacy_handoff_in_progress`, a rate limit, a closed window, an account
388
+ * not moved yet, no response at all) leaves it worth trying again: keep it.
389
+ *
390
+ * `attemptId` is a random id (22 to 128 URL-safe characters) the caller
391
+ * keeps with the legacy session and sends with every exchange of it. The
392
+ * server binds its single-use claim to it, so a retry after a lost answer is
393
+ * answered again instead of refused. `refreshToken` is the legacy OAuth
394
+ * refresh token; the server redeems it only when the access token cannot
395
+ * prove the session by itself. `timeoutMs` bounds this call, body included
396
+ * (default: the client's timeout). With an `attemptId`, the answer carries
397
+ * `apiKeyDescription`: the description to give the API key made with it.
364
398
  */
365
- legacyHandoff(legacyToken: string, appContext?: {
366
- projectId?: string;
367
- organizationId?: string;
368
- }): Promise<AuthSession>;
369
- /** legacyHandoff, then the app API key, exactly like verifyOtpAndCreateKey. */
370
- legacyHandoffAndCreateKey(legacyToken: string, keyInput: CreateApiKeyInput): Promise<{
399
+ legacyHandoff(legacyToken: string, options?: LegacyHandoffOptions): Promise<LegacyHandoffSession>;
400
+ /**
401
+ * legacyHandoff, then the app API key, exactly like verifyOtpAndCreateKey.
402
+ * Pass the same `attemptId` (kept with the legacy session) on every call:
403
+ * a retry after the key step failed is then answered again instead of
404
+ * refused, and the key is made with the description the answer names.
405
+ */
406
+ legacyHandoffAndCreateKey(legacyToken: string, keyInput: CreateApiKeyInput, options?: Pick<LegacyHandoffOptions, 'attemptId' | 'refreshToken' | 'timeoutMs'>): Promise<{
371
407
  apiKey: string;
372
408
  apiKeyId: string;
373
409
  user: AuthSession['user'];
@@ -1,4 +1,4 @@
1
- import { AuthenticationError, RateLimitError, RecursivError, ValidationError } from '../errors.js';
1
+ import { AuthenticationError, LegacyHandoffError, 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
@@ -89,6 +89,14 @@ function serverErrorInfo(body) {
89
89
  : undefined,
90
90
  };
91
91
  }
92
+ /** Seconds from a Retry-After header given in seconds; null when absent or not a number. */
93
+ function retryAfterHeaderSeconds(res) {
94
+ const raw = res.headers.get('retry-after');
95
+ if (!raw)
96
+ return null;
97
+ const seconds = Number(raw);
98
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
99
+ }
92
100
  /** Normalize the flat, nested-object, and legacy nested-string auth errors. */
93
101
  function serverError(body, fallback) {
94
102
  const record = (body ?? {});
@@ -634,10 +642,16 @@ export class AuthResource {
634
642
  const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/sign-out`, {
635
643
  method: 'POST',
636
644
  headers: {
645
+ // Better Auth refuses a POST without a JSON Content-Type (415
646
+ // UNSUPPORTED_MEDIA_TYPE) before its handler runs, and on the Node
647
+ // server every POST carries a body stream, even an empty one. Without
648
+ // these two lines no sign-out ever reached the session.
649
+ 'Content-Type': 'application/json',
637
650
  ...originHeader(this.baseOrigin),
638
651
  Authorization: `Bearer ${sessionToken}`,
639
652
  Cookie: `better-auth.session_token=${sessionToken}`,
640
653
  },
654
+ body: '{}',
641
655
  });
642
656
  if (!res.ok) {
643
657
  let body = {};
@@ -762,53 +776,96 @@ export class AuthResource {
762
776
  /**
763
777
  * Exchange a legacy Minds session (the OAuth access token or minds_sess JWT
764
778
  * the legacy mobile app stored) for a session on this stack. Only answers
765
- * during the cutover window; every failure means "show the OTP screen".
779
+ * during the cutover window.
766
780
  *
767
- * A legacy token is single-use: after one successful exchange the server
768
- * answers `legacy_token_used`, so erase the stored legacy token as soon as
769
- * this resolves and never retry it.
781
+ * A failure throws `LegacyHandoffError` with the HTTP status (0 when no
782
+ * response arrived), the server's code and its Retry-After. Only
783
+ * `legacy_token_used` and `legacy_token_invalid` mean the legacy session can
784
+ * never be exchanged; forget it then. Anything else (`legacy_unavailable`,
785
+ * `legacy_handoff_in_progress`, a rate limit, a closed window, an account
786
+ * not moved yet, no response at all) leaves it worth trying again: keep it.
787
+ *
788
+ * `attemptId` is a random id (22 to 128 URL-safe characters) the caller
789
+ * keeps with the legacy session and sends with every exchange of it. The
790
+ * server binds its single-use claim to it, so a retry after a lost answer is
791
+ * answered again instead of refused. `refreshToken` is the legacy OAuth
792
+ * refresh token; the server redeems it only when the access token cannot
793
+ * prove the session by itself. `timeoutMs` bounds this call, body included
794
+ * (default: the client's timeout). With an `attemptId`, the answer carries
795
+ * `apiKeyDescription`: the description to give the API key made with it.
770
796
  */
771
- async legacyHandoff(legacyToken, appContext) {
772
- const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/legacy-handoff`, {
773
- method: 'POST',
774
- headers: {
775
- 'Content-Type': 'application/json',
776
- ...originHeader(this.baseOrigin),
777
- ...(appContext?.projectId ? { 'x-recursiv-app-project': appContext.projectId } : {}),
778
- ...(appContext?.organizationId
779
- ? { 'x-recursiv-app-organization': appContext.organizationId }
780
- : {}),
781
- },
782
- body: JSON.stringify({ legacy_token: legacyToken }),
783
- });
784
- let data = null;
797
+ async legacyHandoff(legacyToken, options = {}) {
798
+ const body = { legacy_token: legacyToken };
799
+ if (options.attemptId)
800
+ body.attempt_id = options.attemptId;
801
+ if (options.refreshToken)
802
+ body.legacy_refresh_token = options.refreshToken;
803
+ const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : this.timeout;
804
+ const fail = (status, code, message, retryAfter = null) => new LegacyHandoffError(status, { type: 'auth_error', code, message, ...(retryAfter !== null ? { retry_after: retryAfter } : {}) });
805
+ const controller = new AbortController();
806
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
785
807
  try {
786
- data = await res.json();
787
- }
788
- catch {
789
- data = null;
790
- }
791
- if (!res.ok) {
792
- const { code, message } = serverErrorInfo(data);
793
- throw new AuthenticationError({
794
- type: 'auth_error',
795
- message: message || `Legacy handoff failed: HTTP ${res.status}`,
796
- code: code || 'legacy_handoff_failed',
797
- });
808
+ let res;
809
+ try {
810
+ res = await fetch(`${this.baseOrigin}/api/auth/legacy-handoff`, {
811
+ method: 'POST',
812
+ headers: {
813
+ 'Content-Type': 'application/json',
814
+ ...originHeader(this.baseOrigin),
815
+ ...(options.projectId ? { 'x-recursiv-app-project': options.projectId } : {}),
816
+ ...(options.organizationId ? { 'x-recursiv-app-organization': options.organizationId } : {}),
817
+ },
818
+ body: JSON.stringify(body),
819
+ signal: controller.signal,
820
+ });
821
+ }
822
+ catch (err) {
823
+ if (controller.signal.aborted)
824
+ throw fail(0, 'timeout', `Legacy handoff timed out after ${timeoutMs}ms`);
825
+ const detail = err instanceof Error ? err.message : String(err);
826
+ throw fail(0, 'network_error', `Legacy handoff failed before a response arrived (${detail})`);
827
+ }
828
+ let data = null;
829
+ try {
830
+ data = await res.json();
831
+ }
832
+ catch {
833
+ if (controller.signal.aborted)
834
+ throw fail(0, 'timeout', `Legacy handoff timed out after ${timeoutMs}ms`);
835
+ data = null;
836
+ }
837
+ if (!res.ok) {
838
+ const { code, message } = serverErrorInfo(data);
839
+ throw fail(res.status, code || 'legacy_handoff_failed', message || `Legacy handoff failed: HTTP ${res.status}`, retryAfterHeaderSeconds(res));
840
+ }
841
+ const token = this.extractToken(res, data);
842
+ if (!token || typeof data?.user?.id !== 'string') {
843
+ throw fail(res.status, 'legacy_handoff_failed', 'Legacy handoff returned no session');
844
+ }
845
+ const answer = { token, user: data.user };
846
+ if (typeof data.api_key_description === 'string' && data.api_key_description)
847
+ answer.apiKeyDescription = data.api_key_description;
848
+ return answer;
798
849
  }
799
- const token = this.extractToken(res, data);
800
- if (!token) {
801
- throw new AuthenticationError({ type: 'auth_error', message: 'Legacy handoff returned no session', code: 'legacy_handoff_failed' });
850
+ finally {
851
+ clearTimeout(timer);
802
852
  }
803
- return { token, user: data.user };
804
853
  }
805
- /** legacyHandoff, then the app API key, exactly like verifyOtpAndCreateKey. */
806
- async legacyHandoffAndCreateKey(legacyToken, keyInput) {
854
+ /**
855
+ * legacyHandoff, then the app API key, exactly like verifyOtpAndCreateKey.
856
+ * Pass the same `attemptId` (kept with the legacy session) on every call:
857
+ * a retry after the key step failed is then answered again instead of
858
+ * refused, and the key is made with the description the answer names.
859
+ */
860
+ async legacyHandoffAndCreateKey(legacyToken, keyInput, options = {}) {
807
861
  const session = await this.legacyHandoff(legacyToken, {
808
862
  projectId: keyInput.projectId,
809
863
  organizationId: keyInput.organizationId,
864
+ attemptId: options.attemptId,
865
+ refreshToken: options.refreshToken,
866
+ timeoutMs: options.timeoutMs,
810
867
  });
811
- const key = await this.createApiKeyOrRetireSession(keyInput, session);
868
+ const key = await this.createApiKeyOrRetireSession(session.apiKeyDescription && !keyInput.description ? { ...keyInput, description: session.apiKeyDescription } : keyInput, session);
812
869
  return { apiKey: key.key, apiKeyId: key.id, user: session.user, session };
813
870
  }
814
871
  /**
@@ -844,9 +901,13 @@ export class AuthResource {
844
901
  method: 'POST',
845
902
  credentials: 'include',
846
903
  headers: {
904
+ // JSON Content-Type and body, as in signOut: without them Better Auth
905
+ // answers 415 before the endpoint runs.
906
+ 'Content-Type': 'application/json',
847
907
  ...originHeader(this.baseOrigin),
848
908
  Authorization: `Bearer ${sessionToken}`,
849
909
  },
910
+ body: '{}',
850
911
  });
851
912
  let data = null;
852
913
  try {
@@ -6,6 +6,7 @@ interface GatePromptMessage {
6
6
  }
7
7
  interface GatePromptInput {
8
8
  messages: GatePromptMessage[];
9
+ /** @deprecated Ignored: the server always runs its own gate model. Still accepted so older callers validate. */
9
10
  model?: string;
10
11
  }
11
12
  interface GatePromptResult {
@@ -20,7 +21,13 @@ interface Attachment {
20
21
  interface ExecutePromptInput {
21
22
  prompt: string;
22
23
  current_screen?: string;
24
+ /**
25
+ * Organization to run under (its AI provider settings and billing). Must be one
26
+ * you are a member of on your network that the API key may reach, or the
27
+ * request fails with 403.
28
+ */
23
29
  org_slug?: string | null;
30
+ /** @deprecated Ignored: the organization's AI provider settings, or the platform default, pick the model. Kept so older callers validate. */
24
31
  model?: string;
25
32
  locale?: string;
26
33
  attachments?: Attachment[];
@@ -32,7 +39,13 @@ interface ExecutePromptResult {
32
39
  export declare class CommandsResource {
33
40
  private client;
34
41
  constructor(client: HttpClient);
35
- /** Run the gatekeeper prompt to evaluate an idea for access */
42
+ /**
43
+ * Recursiv's own signup gate: evaluates a visitor's pitch for access to Recursiv.
44
+ * Not a moderation or guardrail API. It answers only for keys on the platform's
45
+ * networks (PLATFORM_SIGNUP_NETWORK_SLUGS) and returns 404 anywhere else, including
46
+ * an app's own network. Calls that reach the model share one global daily budget
47
+ * (GATE_PROMPT_DAILY_LLM_CALLS) with Recursiv's signup page.
48
+ */
36
49
  gatePrompt(input: GatePromptInput): Promise<SingleResponse<GatePromptResult>>;
37
50
  /** Execute a command prompt with AI */
38
51
  execute(input: ExecutePromptInput): Promise<SingleResponse<ExecutePromptResult>>;
@@ -3,7 +3,13 @@ export class CommandsResource {
3
3
  constructor(client) {
4
4
  this.client = client;
5
5
  }
6
- /** Run the gatekeeper prompt to evaluate an idea for access */
6
+ /**
7
+ * Recursiv's own signup gate: evaluates a visitor's pitch for access to Recursiv.
8
+ * Not a moderation or guardrail API. It answers only for keys on the platform's
9
+ * networks (PLATFORM_SIGNUP_NETWORK_SLUGS) and returns 404 anywhere else, including
10
+ * an app's own network. Calls that reach the model share one global daily budget
11
+ * (GATE_PROMPT_DAILY_LLM_CALLS) with Recursiv's signup page.
12
+ */
7
13
  gatePrompt(input) {
8
14
  return this.client.post('/commands/gate-prompt', input);
9
15
  }
@@ -37,12 +37,6 @@ export interface E2eeKeyPackageStatus {
37
37
  /** Of `available`, how many expire within 7 days. */
38
38
  expiringSoon: number;
39
39
  }
40
- /**
41
- * E2EE v2 ciphertext transport. The server relays envelopes and public
42
- * credentials only; encryption happens in the shared core on each device.
43
- * Requires the `e2eeV2` network feature flag and a key acting as the account
44
- * owner (agent-scoped keys are refused). The realm is the network id.
45
- */
46
40
  /** A reserved encrypted attachment object: PUT exactly `length` bytes of ciphertext to `uploadUrl`, then complete it. */
47
41
  export interface E2eeObjectUpload {
48
42
  reference: string;
@@ -56,9 +50,45 @@ export interface E2eeObjectDownload {
56
50
  downloadUrl: string;
57
51
  length: number;
58
52
  }
53
+ /**
54
+ * Why this account cannot use the E2EE relay on its network:
55
+ * - `network_off`: the network has not turned E2EE v2 on.
56
+ * - `not_allowlisted`: it is on, but only for pilot accounts, and this is not one.
57
+ */
58
+ export type E2eeStatusReason = 'network_off' | 'not_allowlisted';
59
+ /** Whether the calling account is admitted to the E2EE relay on its network. */
60
+ export interface E2eeStatus {
61
+ /**
62
+ * True: this account may enroll devices, publish key packages, send, and
63
+ * start encryption in an eligible direct conversation. False: the relay is
64
+ * frozen for it. Conversations that already carry encryption stay readable
65
+ * (fetch, acknowledge, object downloads), its own devices can be listed and
66
+ * revoked, and nothing new can be sent or enrolled. It never falls back to
67
+ * plaintext.
68
+ */
69
+ enabled: boolean;
70
+ /** Present only when `enabled` is false. Treat any unrecognised reason as off. */
71
+ reason?: E2eeStatusReason;
72
+ }
73
+ /**
74
+ * E2EE v2 ciphertext transport. The server relays envelopes and public
75
+ * credentials only; encryption happens in the shared core on each device.
76
+ * Requires the `e2eeV2` network feature flag and a key acting as the account
77
+ * owner (agent-scoped keys are refused). The realm is the network id.
78
+ */
59
79
  export declare class E2eeResource {
60
80
  private client;
61
81
  constructor(client: HttpClient);
82
+ /**
83
+ * Whether this account is admitted to encrypted chat on its network, without
84
+ * attempting a relay call. `{ enabled: true }`, or `{ enabled: false, reason }`
85
+ * with `reason` `'network_off'` or `'not_allowlisted'`. Only the caller's own
86
+ * account is described; the network comes from the key or session. Enrolling
87
+ * a device still needs an accepted two-person DM (or an already-encrypted
88
+ * conversation) in this key's scope. Agent-scoped keys get
89
+ * `agent_key_not_allowed`.
90
+ */
91
+ status(): Promise<SingleResponse<E2eeStatus>>;
62
92
  /** Register this device's public identity and its signed approval chain. */
63
93
  registerDevice(input: {
64
94
  device_name: string;
@@ -96,12 +126,28 @@ export declare class E2eeResource {
96
126
  bytes: string;
97
127
  device: E2eeDevice;
98
128
  }>>;
129
+ /**
130
+ * Append one MLS envelope to an encrypted conversation.
131
+ *
132
+ * `silent: true` marks a message that changes an earlier one (a reaction,
133
+ * an edit, a delete for everyone): the relay sends no push (so no unread
134
+ * badge either) and does not move the conversation up the chat list. The
135
+ * `e2ee:event` doorbell still rings exactly as for any message: on the
136
+ * sockets of every member, the sender's own account included, except
137
+ * across a block. So every listener hears a quiet event, not only an open
138
+ * thread: a chat list that pulls on the doorbell pulls it too, and should
139
+ * leave the row where it is once it has decrypted a reaction, an edit or a
140
+ * delete. A commit ignores the flag. The relay learns only that the event
141
+ * is quiet; the flag is not stored. A relay that predates it drops the
142
+ * key, so the event is simply not quiet there.
143
+ */
99
144
  appendEvent(conversationId: string, input: {
100
145
  device_id: string;
101
146
  envelope: E2eeEnvelope;
102
147
  welcome?: string;
103
148
  welcome_for_device_id?: string;
104
149
  expected_commit_seq?: number;
150
+ silent?: boolean;
105
151
  }): Promise<SingleResponse<{
106
152
  seq: number;
107
153
  duplicate: boolean;
@@ -1,8 +1,26 @@
1
+ /**
2
+ * E2EE v2 ciphertext transport. The server relays envelopes and public
3
+ * credentials only; encryption happens in the shared core on each device.
4
+ * Requires the `e2eeV2` network feature flag and a key acting as the account
5
+ * owner (agent-scoped keys are refused). The realm is the network id.
6
+ */
1
7
  export class E2eeResource {
2
8
  client;
3
9
  constructor(client) {
4
10
  this.client = client;
5
11
  }
12
+ /**
13
+ * Whether this account is admitted to encrypted chat on its network, without
14
+ * attempting a relay call. `{ enabled: true }`, or `{ enabled: false, reason }`
15
+ * with `reason` `'network_off'` or `'not_allowlisted'`. Only the caller's own
16
+ * account is described; the network comes from the key or session. Enrolling
17
+ * a device still needs an accepted two-person DM (or an already-encrypted
18
+ * conversation) in this key's scope. Agent-scoped keys get
19
+ * `agent_key_not_allowed`.
20
+ */
21
+ status() {
22
+ return this.client.get('/e2ee/status');
23
+ }
6
24
  /** Register this device's public identity and its signed approval chain. */
7
25
  registerDevice(input) {
8
26
  return this.client.post('/e2ee/devices', input);
@@ -28,6 +46,21 @@ export class E2eeResource {
28
46
  claimKeyPackage(input) {
29
47
  return this.client.post('/e2ee/key-packages/claim', input);
30
48
  }
49
+ /**
50
+ * Append one MLS envelope to an encrypted conversation.
51
+ *
52
+ * `silent: true` marks a message that changes an earlier one (a reaction,
53
+ * an edit, a delete for everyone): the relay sends no push (so no unread
54
+ * badge either) and does not move the conversation up the chat list. The
55
+ * `e2ee:event` doorbell still rings exactly as for any message: on the
56
+ * sockets of every member, the sender's own account included, except
57
+ * across a block. So every listener hears a quiet event, not only an open
58
+ * thread: a chat list that pulls on the doorbell pulls it too, and should
59
+ * leave the row where it is once it has decrypted a reaction, an edit or a
60
+ * delete. A commit ignores the flag. The relay learns only that the event
61
+ * is quiet; the flag is not stored. A relay that predates it drops the
62
+ * key, so the event is simply not quiet there.
63
+ */
31
64
  appendEvent(conversationId, input) {
32
65
  // Retries are safe: the server is idempotent on (conversation, device, event id).
33
66
  return this.client.post(`/e2ee/conversations/${encodeURIComponent(conversationId)}/events`, input, { retry: true });
@@ -15,6 +15,7 @@ export interface RequestHumanInputInput {
15
15
  export interface HumanInputRequest {
16
16
  conversation_id: string;
17
17
  message_id: string;
18
+ human_ask_id: string | null;
18
19
  /** Where to read the answer. MCP is stateless, so the agent polls. */
19
20
  poll: string;
20
21
  poll_after_seconds: number;
@@ -45,7 +46,9 @@ export declare class HumanInputResource {
45
46
  private client;
46
47
  constructor(client: HttpClient);
47
48
  /** Raise a blocking question and notify the human wherever they are. */
48
- request(input: RequestHumanInputInput): Promise<SingleResponse<HumanInputRequest>>;
49
+ request(input: RequestHumanInputInput, options?: {
50
+ requestId?: string;
51
+ }): Promise<SingleResponse<HumanInputRequest>>;
49
52
  /** Has the human answered yet? */
50
53
  answers(conversationId: string): Promise<SingleResponse<HumanInputAnswers>>;
51
54
  }
@@ -17,9 +17,13 @@ export class HumanInputResource {
17
17
  this.client = client;
18
18
  }
19
19
  /** Raise a blocking question and notify the human wherever they are. */
20
- request(input) {
21
- // This write is not idempotent. A lost response must not replay the ask.
22
- return this.client.post('/human-input', input, { retry: false });
20
+ request(input, options) {
21
+ // Never automatically replay: chat delivery may have committed before a
22
+ // response was lost, even when a keyed workspace ask was recorded once.
23
+ // Callers can reuse a requestId with the exact same input after an
24
+ // uncertain response. The server claims it against the durable
25
+ // organization-owned ask row; changed fields need a new requestId.
26
+ return this.client.post('/human-input', input, { retry: false, idempotencyKey: options?.requestId });
23
27
  }
24
28
  /** Has the human answered yet? */
25
29
  answers(conversationId) {
@@ -81,7 +81,11 @@ export declare class ModerationResource {
81
81
  * reachable through a valid key while the account is suspended or banned.
82
82
  */
83
83
  mine(): Promise<SingleResponse<OwnModerationLog>>;
84
- /** Appeal a decision affecting the caller or content they own. */
84
+ /**
85
+ * Appeal a decision affecting the caller or content they own. Sent once,
86
+ * never retried automatically: a 429 past the member's appeal budget
87
+ * (`RateLimitError`, `retryAfter`) is for the member to see.
88
+ */
85
89
  appeal(actionId: string, statement: string): Promise<SingleResponse<{
86
90
  success: true;
87
91
  id: string;
@@ -22,9 +22,13 @@ export class ModerationResource {
22
22
  mine() {
23
23
  return this.client.get('/appeals/moderation-actions');
24
24
  }
25
- /** Appeal a decision affecting the caller or content they own. */
25
+ /**
26
+ * Appeal a decision affecting the caller or content they own. Sent once,
27
+ * never retried automatically: a 429 past the member's appeal budget
28
+ * (`RateLimitError`, `retryAfter`) is for the member to see.
29
+ */
26
30
  appeal(actionId, statement) {
27
- return this.client.post(`/appeals/${encodeURIComponent(actionId)}`, { statement });
31
+ return this.client.post(`/appeals/${encodeURIComponent(actionId)}`, { statement }, { retry: false });
28
32
  }
29
33
  /** Admin review: uphold or overturn an appeal. */
30
34
  resolveAppeal(appealId, input) {
@@ -19,6 +19,11 @@ export interface NotificationActor {
19
19
  * state so inline Upvote and Repost can start in the right position. `null`
20
20
  * when the post is gone or no longer visible to the viewer.
21
21
  */
22
+ /** A post's tag as a notification's post preview carries it. */
23
+ export interface NotificationPostTag {
24
+ name: string;
25
+ slug: string;
26
+ }
22
27
  export interface NotificationPostPreview {
23
28
  id: string;
24
29
  /**
@@ -35,6 +40,20 @@ export interface NotificationPostPreview {
35
40
  excerpt_truncated?: boolean;
36
41
  media_url: string | null;
37
42
  media_type: string | null;
43
+ /**
44
+ * The post's own NSFW flag. With `tags`, it says whether the post is
45
+ * sensitive: a client that masks a sensitive post's card masks its excerpt
46
+ * and picture here too, and holds the picture back while this is absent
47
+ * (servers older than this field).
48
+ */
49
+ is_nsfw?: boolean;
50
+ /**
51
+ * The post's tags, each with its name as written and the slug the tag API
52
+ * derived from it ("nsfw", "nude", "porn" and the like mark it sensitive).
53
+ * Match both: a tag created as "NSFW!" is slugged "nsfw". Absent from
54
+ * servers older than this field.
55
+ */
56
+ tags?: NotificationPostTag[];
38
57
  reactions_count?: number;
39
58
  score?: number;
40
59
  upvote_count?: number;
@@ -1119,6 +1119,15 @@ export interface SocialRadarMember {
1119
1119
  height?: number;
1120
1120
  };
1121
1121
  providerRank?: number;
1122
+ /**
1123
+ * Present on a post member whose image or video a signed-out visitor may see (an open-network post with a cached
1124
+ * safe verdict, or a Minds post that passes the visitor's floor); absent on every other post member, and on members
1125
+ * of a radar built before the server recorded it. An outlet's media needs no mark. The keyless routes
1126
+ * (`getSharedRadar`, `getSharedRadarPosts`, `getSharedRadarStory`) serve media only from marked members and outlets.
1127
+ * A signed-in read (`getPublicRadar` and friends) keeps every member's media, so use only an outlet's or a marked
1128
+ * member's media on a signed-out page or a share card.
1129
+ */
1130
+ visitorMedia?: true;
1122
1131
  }
1123
1132
  /** One story on the radar. `breaking` is outlet corroboration inside a short window; `viral` is social velocity above its network's norm. */
1124
1133
  export interface SocialRadarCluster {
@@ -1143,6 +1152,7 @@ export interface SocialRadarCluster {
1143
1152
  normalized: number;
1144
1153
  samples: number;
1145
1154
  };
1155
+ /** On a signed-in read the lead may belong to a member without `visitorMedia`; find it by `memberKey` before a share card uses it. */
1146
1156
  media: {
1147
1157
  images: number;
1148
1158
  videos: number;
@@ -1333,7 +1343,10 @@ export declare class ProtocolsResource {
1333
1343
  category?: string;
1334
1344
  discovery?: boolean;
1335
1345
  }): Promise<SingleResponse<SocialTrendFeed>>;
1336
- /** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
1346
+ /**
1347
+ * Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary.
1348
+ * Images and videos come only from outlets and from members marked `visitorMedia`; every other member is text.
1349
+ */
1337
1350
  getSharedRadar(projectId: string, params?: SocialRadarParams): Promise<SingleResponse<SocialRadar>>;
1338
1351
  /** One radar story's social member posts without a key, so a signed-out story card can show the posts it counts. */
1339
1352
  getSharedRadarPosts(projectId: string, params: {
@@ -1395,7 +1408,10 @@ export declare class ProtocolsResource {
1395
1408
  category?: string;
1396
1409
  discovery?: boolean;
1397
1410
  }, options?: SocialReadOptions): Promise<SingleResponse<SocialTrendFeed>>;
1398
- /** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
1411
+ /**
1412
+ * The app's topic radar under its own key: the same snapshot the keyless route serves, for a signed-in reader, so
1413
+ * every member keeps its media (the keyless route serves only outlets' and `visitorMedia` members'). Never triggers a build.
1414
+ */
1399
1415
  getPublicRadar(params?: SocialRadarParams, options?: SocialReadOptions): Promise<SingleResponse<SocialRadar>>;
1400
1416
  /** One radar story's social member posts, so its card can show the posts it counts. Minds members are read with `posts.get`. */
1401
1417
  getPublicRadarPosts(params: {
@@ -102,7 +102,10 @@ export class ProtocolsResource {
102
102
  return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/trend-feed`, { protocol: params.protocol, topic: params.topic,
103
103
  limit: params.limit, cursor: params.cursor, category: params.category, discovery: params.discovery });
104
104
  }
105
- /** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
105
+ /**
106
+ * Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary.
107
+ * Images and videos come only from outlets and from members marked `visitorMedia`; every other member is text.
108
+ */
106
109
  getSharedRadar(projectId, params = {}) {
107
110
  return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/radar`, radarQuery(params));
108
111
  }
@@ -167,7 +170,10 @@ export class ProtocolsResource {
167
170
  ...(params.category !== undefined ? { category: params.category } : {}),
168
171
  ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
169
172
  }
170
- /** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
173
+ /**
174
+ * The app's topic radar under its own key: the same snapshot the keyless route serves, for a signed-in reader, so
175
+ * every member keeps its media (the keyless route serves only outlets' and `visitorMedia` members'). Never triggers a build.
176
+ */
171
177
  getPublicRadar(params = {}, options) {
172
178
  return this.client.get('/protocols/social/radar', radarQuery(params), ...readOptions(options));
173
179
  }
@@ -35,6 +35,10 @@ export declare class ReportsResource {
35
35
  * Submit a content report. Targets a post, user, community, or message
36
36
  * by ID, with a short reason and optional free-text details. Reporter
37
37
  * is the authenticated user.
38
+ *
39
+ * Sent once, never retried automatically: past the member's report budget
40
+ * the server answers 429 (`RateLimitError`, `retryAfter` = seconds until
41
+ * the window opens), and that refusal is for the member to see.
38
42
  */
39
43
  create(input: CreateReportInput): Promise<SingleResponse<{
40
44
  success: true;
@@ -7,9 +7,13 @@ export class ReportsResource {
7
7
  * Submit a content report. Targets a post, user, community, or message
8
8
  * by ID, with a short reason and optional free-text details. Reporter
9
9
  * is the authenticated user.
10
+ *
11
+ * Sent once, never retried automatically: past the member's report budget
12
+ * the server answers 429 (`RateLimitError`, `retryAfter` = seconds until
13
+ * the window opens), and that refusal is for the member to see.
10
14
  */
11
15
  create(input) {
12
- return this.client.post('/reports', input);
16
+ return this.client.post('/reports', input, { retry: false });
13
17
  }
14
18
  /** List recent reports in the caller's project/network (admin only). */
15
19
  list() {
package/dist/types.d.ts CHANGED
@@ -229,6 +229,12 @@ export interface RepostedPost {
229
229
  /** The group the original is in (null when it is in none): it decides what the repost card offers. */
230
230
  community?: PostCommunity | null;
231
231
  organization_id: string | null;
232
+ /**
233
+ * The original's own NSFW flag. A repost or quote card shows the original's
234
+ * words and media: mask them when this is set or `tags` marks it sensitive.
235
+ * Absent from servers older than this field.
236
+ */
237
+ is_nsfw?: boolean;
232
238
  tags: Tag[];
233
239
  reactions_count: number;
234
240
  score: number;
package/llm.md CHANGED
@@ -190,7 +190,7 @@ const { data } = await r.commands.execute({
190
190
  // data: { text, actions }
191
191
  ```
192
192
 
193
- Methods: `gatePrompt({ messages, model? })`, `execute({ prompt, current_screen?, org_slug?, model?, locale?, attachments? })`
193
+ Methods: `execute({ prompt, current_screen?, org_slug?, model?, locale?, attachments? })`, `gatePrompt({ messages })`. `gatePrompt` is Recursiv's own signup gate, not a moderation API: it answers only for keys on the platform's networks (404 anywhere else, including an app's own network), ignores `model`, and shares one daily LLM budget with Recursiv's signup page.
194
194
 
195
195
  ### r.projectBrain — Per-project knowledge base & task management
196
196
 
@@ -389,7 +389,7 @@ await r.auth.requestTwoFactorEmailCode(live, { projectId }) // app context brand
389
389
  await r.auth.disableTwoFactor({ emailCode: '482913' }, live)
390
390
  ```
391
391
 
392
- Two-factor methods: `enableTwoFactor`, `verifyTwoFactor`, `disableTwoFactor`, `regenerateTwoFactorBackupCodes`, `requestTwoFactorEmailCode`, `completeTwoFactorSignIn`, `completeTwoFactorSignInAndCreateKey`. OAuth consent screens: `exchangeSessionForCookie(token)` (browser, sets the API-origin cookie so `/api/auth/mcp/authorize` continues without a second sign-in), `getOAuthClientInfo({ clientId, redirectUri?, scope? })` (public client name, redirect hosts, `redirect_uri_registered`, granted scopes).
392
+ Two-factor methods: `enableTwoFactor`, `verifyTwoFactor`, `disableTwoFactor`, `regenerateTwoFactorBackupCodes`, `requestTwoFactorEmailCode`, `completeTwoFactorSignIn`, `completeTwoFactorSignInAndCreateKey`. Minds cutover: `legacyHandoff(legacyToken, { attemptId, refreshToken, projectId, timeoutMs })` exchanges the legacy app's session once; it throws `LegacyHandoffError` (`status` 0 when no response came, `code`, `retryAfterSeconds`), and only `legacy_token_used` / `legacy_token_invalid` mean forget the legacy session. With an `attemptId` a success carries `apiKeyDescription`; give the API key made with that session that `description`, so replays of the attempt count only that key. `legacyHandoffAndCreateKey(token, keyInput, { attemptId, refreshToken })` does both; pass the same `attemptId` on every retry. OAuth consent screens: `exchangeSessionForCookie(token)` (browser, sets the API-origin cookie so `/api/auth/mcp/authorize` continues without a second sign-in), `getOAuthClientInfo({ clientId, redirectUri?, scope? })` (public client name, redirect hosts, `redirect_uri_registered`, granted scopes).
393
393
 
394
394
  **Mobile/React Native auth** (recommended — one call instead of three):
395
395
  ```typescript
@@ -502,14 +502,18 @@ Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growin
502
502
 
503
503
  ### r.e2ee — End-to-end encryption transport (flag-gated)
504
504
 
505
- Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag (otherwise `feature_disabled`) and a key acting as the account owner (agent-scoped keys get `agent_key_not_allowed`). The realm is the network id. Only a direct conversation between two people with an accepted request can start encryption (`conversation_not_eligible` otherwise); blocks apply (`blocked`); commits must send `expected_commit_seq`.
505
+ Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag (otherwise `feature_disabled`) and a key acting as the account owner (agent-scoped keys get `agent_key_not_allowed`). The realm is the network id. Only a direct conversation between two people with an accepted request can start encryption (`conversation_not_eligible` otherwise), and every later append to it is checked the same way again (both people still accepted, still in the pilot, still only the two of them), so a client need not read the policy before each send; a resend of an event already stored still returns `duplicate: true`, even once a Welcome's target device is gone; blocks apply (`blocked`), to resends too; commits must send `expected_commit_seq`.
506
+
507
+ Ask first instead of trying a call: `r.e2ee.status()` returns `{ enabled: true }` or `{ enabled: false, reason: 'network_off' | 'not_allowlisted' }` for the calling account only. `enabled: false` means frozen: already-encrypted conversations stay readable, nothing new can be sent or enrolled.
506
508
 
507
509
  ```ts
510
+ const { data: access } = await r.e2ee.status(); // offer encryption only when access.enabled
508
511
  const { data: device } = await r.e2ee.registerDevice({ device_name: 'phone-1', realm: networkId, signing_key, credential });
509
512
  await r.e2ee.publishKeyPackages({ device_id: device.id, packages: [keyPackageBase64] }); // → { published, available }
510
513
  const { data: stock } = await r.e2ee.keyPackageStatus({ device_id: device.id }); // { available, expiringSoon }; refill when low
511
514
  const { data: claimed } = await r.e2ee.claimKeyPackage({ device_id: device.id, target_device_id: peerDeviceId });
512
515
  await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope }); // idempotent per event_id
516
+ await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope: reaction, silent: true }); // no push, no move up the chat list; every member's doorbell still rings
513
517
  const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
514
518
  await r.e2ee.acknowledge(conversationId, { device_id: device.id, seq: page.headSeq });
515
519
  ```
@@ -808,7 +812,7 @@ Pagination: pass `limit` and `offset`. Check `meta.has_more`.
808
812
  | `RateLimitError` | 429 | Rate limit hit (`error.retryAfter` seconds) |
809
813
  | `ConflictError` | 409 | Resource already exists |
810
814
 
811
- Auto-retry on 429/5xx built in (exponential backoff, default `maxRetries: 2`).
815
+ Auto-retry on 429/5xx built in (exponential backoff, default `maxRetries: 2`). Member actions with a per-member budget are sent once and never auto-retried: `reports.create`, `moderation.appeal` and `protocols.reportSource` throw the 429 at once (`RateLimitError`, `message` is member-facing, `retryAfter` = seconds until the window opens; a budget refusal carries `X-RateLimit-Scope: action` and no `Retry-After` header).
812
816
 
813
817
  ### Idempotency
814
818
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.54",
3
+ "version": "0.7.55",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",