@recursiv/sdk 0.7.54 → 0.7.57

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, 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
  }
@@ -11,6 +11,15 @@ export interface E2eeDevice {
11
11
  isRoot: boolean;
12
12
  status: 'active' | 'revoked' | string;
13
13
  revokedAt: string | null;
14
+ /** When the relay registered the device (the relay's clock). Absent on relays before 0.7.57. */
15
+ createdAt?: string;
16
+ /**
17
+ * When the device last fetched events, to the hour. Only in your own
18
+ * directory (`devices()` without `user_id`); null for another account's
19
+ * devices, when unknown, or when the relay cannot say. Absent on relays
20
+ * before 0.7.57.
21
+ */
22
+ lastSeenAt?: string | null;
14
23
  }
15
24
  /** Opaque `recursiv-mls-v2-draft1` envelope with base64 MLS bytes. */
16
25
  export interface E2eeEnvelope {
@@ -30,6 +39,33 @@ export interface E2eeEvent {
30
39
  welcome: string | null;
31
40
  createdAt: string;
32
41
  }
42
+ /**
43
+ * The realtime `e2ee:event` doorbell (`r.realtime.onE2eeEvent`), sent after
44
+ * every append to an encrypted conversation to each member account's sockets.
45
+ * It never carries plaintext and has no ordering authority.
46
+ *
47
+ * `event` is present only for a small application message (an ordinary or a
48
+ * quiet one; never a commit, never a Welcome, never one whose envelope is over
49
+ * 16 KB), and only on a socket whose credential could read it with `events()`:
50
+ * one authenticated by the account owner's session or by an owner key whose
51
+ * app or organization binding reaches the conversation. A socket opened with
52
+ * an agent-scoped key gets the content-free form only. When present it is the
53
+ * stored event exactly as `events()` returns it, JSON byte for byte.
54
+ *
55
+ * With the shared engine, hand `event` to `applyPushed(conversationId, event)`
56
+ * on the device session: it applies the event without a relay read when it is
57
+ * the next one this device expects, and syncs otherwise. Without `event`, sync.
58
+ */
59
+ export interface E2eeRealtimeEvent {
60
+ conversationId: string;
61
+ /** The relay sequence the append was stored at. */
62
+ seq: number;
63
+ /** The stored event, when this socket may carry it (see above). */
64
+ event?: E2eeEvent & {
65
+ kind: 'application';
66
+ welcome: null;
67
+ };
68
+ }
33
69
  /** One-time key packages a device still has to hand out. */
34
70
  export interface E2eeKeyPackageStatus {
35
71
  /** Unclaimed packages that have not expired. Once this reaches 0 nobody can add the device to a new conversation. */
@@ -37,12 +73,6 @@ export interface E2eeKeyPackageStatus {
37
73
  /** Of `available`, how many expire within 7 days. */
38
74
  expiringSoon: number;
39
75
  }
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
76
  /** A reserved encrypted attachment object: PUT exactly `length` bytes of ciphertext to `uploadUrl`, then complete it. */
47
77
  export interface E2eeObjectUpload {
48
78
  reference: string;
@@ -56,9 +86,111 @@ export interface E2eeObjectDownload {
56
86
  downloadUrl: string;
57
87
  length: number;
58
88
  }
89
+ /**
90
+ * Why this account cannot use the E2EE relay on its network:
91
+ * - `network_off`: the network has not turned E2EE v2 on.
92
+ * - `not_allowlisted`: it is on, but only for pilot accounts, and this is not one.
93
+ */
94
+ export type E2eeStatusReason = 'network_off' | 'not_allowlisted';
95
+ /** Whether the calling account is admitted to the E2EE relay on its network. */
96
+ export interface E2eeStatus {
97
+ /**
98
+ * True: this account may enroll devices, publish key packages, send, and
99
+ * start encryption in an eligible direct conversation. False: the relay is
100
+ * frozen for it. Conversations that already carry encryption stay readable
101
+ * (fetch, acknowledge, object downloads), its own devices can be listed and
102
+ * revoked, and nothing new can be sent or enrolled. It never falls back to
103
+ * plaintext.
104
+ */
105
+ enabled: boolean;
106
+ /** Present only when `enabled` is false. Treat any unrecognised reason as off. */
107
+ reason?: E2eeStatusReason;
108
+ /**
109
+ * True when this relay can link a new device by scanning (`createLink`).
110
+ * False or absent: offer the codes fallback only as a choice the person
111
+ * makes, never switch to it on their behalf.
112
+ */
113
+ links?: boolean;
114
+ }
115
+ /** A device link's state. An open link past `expiresAt` reads as `cancelled` with `cancelledBy: 'expired'`. */
116
+ export type E2eeLinkState = 'open' | 'approved' | 'completed' | 'cancelled';
117
+ /** Why a link was cancelled. */
118
+ export type E2eeLinkCancelledBy = 'creator' | 'superseded' | 'declined' | 'expired';
119
+ /** How far the approving device has got adding the new device to the account's encrypted chats. */
120
+ export interface E2eeLinkProgress {
121
+ done: number;
122
+ total: number;
123
+ joined: number;
124
+ notHeld: number;
125
+ failed: number;
126
+ }
127
+ /**
128
+ * A device link (spec RCV1): the relay's short-lived mailbox between a new
129
+ * device, which shows a QR code, and an approved device of the same account,
130
+ * which scans it and posts an approval only the new device can verify. The
131
+ * relay stores public data only and is not trusted for integrity: the engine
132
+ * checks the approval's tag, the chain and the directory itself.
133
+ */
134
+ export interface E2eeLink {
135
+ /** 128 random bits as unpadded base64url (22 characters); the QR carries these bytes. */
136
+ id: string;
137
+ state: E2eeLinkState;
138
+ deviceName: string;
139
+ signingKey: string;
140
+ /** The new device's self-reported label. Chosen by whoever made the link: never show it as the app's own words. */
141
+ label: string | null;
142
+ createdAt: string;
143
+ expiresAt: string;
144
+ /** The relay's clock when it answered: count down with `expiresAt - now`, never the device's own clock. */
145
+ now: string;
146
+ scannedAt: string | null;
147
+ /** Another link of this account is open (another sign-in is linking too). */
148
+ otherOpen: boolean;
149
+ cancelledBy: E2eeLinkCancelledBy | null;
150
+ /** The approval body and its tag: only for the sign-in that created the link, null for everyone else. */
151
+ approval: string | null;
152
+ tag: string | null;
153
+ approverDeviceId: string | null;
154
+ progress: E2eeLinkProgress | null;
155
+ lastConversationId: string | null;
156
+ needPackages: boolean;
157
+ }
158
+ /**
159
+ * The realtime `e2ee:link` event (`r.realtime.onE2eeLink`): a link changed.
160
+ * Content-free; read the link with `link(id)`. There is never an `open` event.
161
+ */
162
+ export interface E2eeLinkRealtimeEvent {
163
+ linkId: string;
164
+ state: 'scanned' | 'approved' | 'completed' | 'progress' | 'cancelled';
165
+ }
166
+ /**
167
+ * The realtime `e2ee:devices` event (`r.realtime.onE2eeDevices`): `packages`
168
+ * when one of the account's devices can now be claimed (it published after
169
+ * having none, or after a claim for it failed); `revoked` when one was revoked.
170
+ */
171
+ export interface E2eeDevicesRealtimeEvent {
172
+ deviceId: string;
173
+ reason: 'packages' | 'revoked';
174
+ }
175
+ /**
176
+ * E2EE v2 ciphertext transport. The server relays envelopes and public
177
+ * credentials only; encryption happens in the shared core on each device.
178
+ * Requires the `e2eeV2` network feature flag and a key acting as the account
179
+ * owner (agent-scoped keys are refused). The realm is the network id.
180
+ */
59
181
  export declare class E2eeResource {
60
182
  private client;
61
183
  constructor(client: HttpClient);
184
+ /**
185
+ * Whether this account is admitted to encrypted chat on its network, without
186
+ * attempting a relay call. `{ enabled: true }`, or `{ enabled: false, reason }`
187
+ * with `reason` `'network_off'` or `'not_allowlisted'`. Only the caller's own
188
+ * account is described; the network comes from the key or session. Enrolling
189
+ * a device still needs an accepted two-person DM (or an already-encrypted
190
+ * conversation) in this key's scope. Agent-scoped keys get
191
+ * `agent_key_not_allowed`.
192
+ */
193
+ status(): Promise<SingleResponse<E2eeStatus>>;
62
194
  /** Register this device's public identity and its signed approval chain. */
63
195
  registerDevice(input: {
64
196
  device_name: string;
@@ -96,12 +228,28 @@ export declare class E2eeResource {
96
228
  bytes: string;
97
229
  device: E2eeDevice;
98
230
  }>>;
231
+ /**
232
+ * Append one MLS envelope to an encrypted conversation.
233
+ *
234
+ * `silent: true` marks a message that changes an earlier one (a reaction,
235
+ * an edit, a delete for everyone): the relay sends no push (so no unread
236
+ * badge either) and does not move the conversation up the chat list. The
237
+ * `e2ee:event` doorbell still rings exactly as for any message: on the
238
+ * sockets of every member, the sender's own account included, except
239
+ * across a block. So every listener hears a quiet event, not only an open
240
+ * thread: a chat list that pulls on the doorbell pulls it too, and should
241
+ * leave the row where it is once it has decrypted a reaction, an edit or a
242
+ * delete. A commit ignores the flag. The relay learns only that the event
243
+ * is quiet; the flag is not stored. A relay that predates it drops the
244
+ * key, so the event is simply not quiet there.
245
+ */
99
246
  appendEvent(conversationId: string, input: {
100
247
  device_id: string;
101
248
  envelope: E2eeEnvelope;
102
249
  welcome?: string;
103
250
  welcome_for_device_id?: string;
104
251
  expected_commit_seq?: number;
252
+ silent?: boolean;
105
253
  }): Promise<SingleResponse<{
106
254
  seq: number;
107
255
  duplicate: boolean;
@@ -149,4 +297,71 @@ export declare class E2eeResource {
149
297
  reference: string;
150
298
  deleted: boolean;
151
299
  }>>;
300
+ /**
301
+ * Start linking this new, not yet registered device: `POST /e2ee/links`.
302
+ * Answers the open link (180 s on the relay's clock). A new link cancels
303
+ * this sign-in's previous open link. `label` is 1-48 letters, numbers,
304
+ * spaces and `. , ' ( ) + -`; reserved words are refused (`invalid_label`).
305
+ * Not retried: a 5xx is answered at once, and the caller decides.
306
+ */
307
+ createLink(input: {
308
+ device_name: string;
309
+ signing_key: string;
310
+ label?: string;
311
+ }): Promise<SingleResponse<E2eeLink>>;
312
+ /** Read a link: `GET /e2ee/links/:id`. `approval` and `tag` only for the sign-in that created it. Not retried: poll instead. */
313
+ link(id: string): Promise<SingleResponse<E2eeLink>>;
314
+ /** The approving device scanned the code: once per link, it keeps the link open 120 s past the scan. */
315
+ scannedLink(id: string, input: {
316
+ device_id: string;
317
+ }): Promise<SingleResponse<E2eeLink>>;
318
+ /**
319
+ * Post the approval body and its tag, once. Retry-safe: the same approving
320
+ * device is answered 200 on an approved or completed link. The answer never
321
+ * carries the approval or tag.
322
+ */
323
+ approveLink(id: string, input: {
324
+ device_id: string;
325
+ approval: string;
326
+ tag: string;
327
+ }): Promise<SingleResponse<E2eeLink>>;
328
+ /** The new device, now registered, finished: the sign-in that created the link only. */
329
+ completeLink(id: string, input: {
330
+ device_id: string;
331
+ }): Promise<SingleResponse<E2eeLink>>;
332
+ /** The approving device, from the sign-in that approved, reports how many chats it has added the new device to. */
333
+ linkProgress(id: string, input: {
334
+ device_id: string;
335
+ done: number;
336
+ total: number;
337
+ joined: number;
338
+ not_held: number;
339
+ failed: number;
340
+ last_conversation_id?: string;
341
+ need_packages?: boolean;
342
+ }): Promise<SingleResponse<E2eeLink>>;
343
+ /**
344
+ * Cancel a link. The sign-in that created it cancels it while open or
345
+ * approved; an active device of the account declines an open one with
346
+ * `{ device_id, reason: 'declined' }`, and once the link is scanned only
347
+ * from the sign-in that scanned it.
348
+ */
349
+ cancelLink(id: string, input?: {
350
+ device_id?: string;
351
+ reason?: 'cancelled' | 'declined';
352
+ }): Promise<SingleResponse<E2eeLink>>;
353
+ /**
354
+ * The ids of the encrypted conversations this account belongs to, in this
355
+ * key's scope, ordered by id: pass `next` as `after` for the next page
356
+ * (`limit` 1-200, default 100).
357
+ */
358
+ conversations(params?: {
359
+ after?: string;
360
+ limit?: number;
361
+ }): Promise<{
362
+ data: {
363
+ conversationId: string;
364
+ }[];
365
+ next: string | null;
366
+ }>;
152
367
  }
@@ -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 });
@@ -58,4 +91,61 @@ export class E2eeResource {
58
91
  deleteObject(objectId, params) {
59
92
  return this.client.delete(`/e2ee/objects/${encodeURIComponent(objectId)}?device_id=${encodeURIComponent(params.device_id)}`);
60
93
  }
94
+ // ── Device links (spec RCV1 §6) ──
95
+ // Names match the engine's optional transport methods, so `r.e2ee` can be
96
+ // passed to the engine as its transport unchanged. Link refusals are 409
97
+ // (`link_limit`, `no_root`, `already_registered`, `conflict`), never 429;
98
+ // `invalid_label` is 400; `storage_unavailable` is a temporary 503: keep
99
+ // polling until the link's `expiresAt`.
100
+ /**
101
+ * Start linking this new, not yet registered device: `POST /e2ee/links`.
102
+ * Answers the open link (180 s on the relay's clock). A new link cancels
103
+ * this sign-in's previous open link. `label` is 1-48 letters, numbers,
104
+ * spaces and `. , ' ( ) + -`; reserved words are refused (`invalid_label`).
105
+ * Not retried: a 5xx is answered at once, and the caller decides.
106
+ */
107
+ createLink(input) {
108
+ return this.client.post('/e2ee/links', input, { retry: false });
109
+ }
110
+ /** Read a link: `GET /e2ee/links/:id`. `approval` and `tag` only for the sign-in that created it. Not retried: poll instead. */
111
+ link(id) {
112
+ return this.client.get(`/e2ee/links/${encodeURIComponent(id)}`, undefined, { retry: false });
113
+ }
114
+ /** The approving device scanned the code: once per link, it keeps the link open 120 s past the scan. */
115
+ scannedLink(id, input) {
116
+ return this.client.post(`/e2ee/links/${encodeURIComponent(id)}/scanned`, input);
117
+ }
118
+ /**
119
+ * Post the approval body and its tag, once. Retry-safe: the same approving
120
+ * device is answered 200 on an approved or completed link. The answer never
121
+ * carries the approval or tag.
122
+ */
123
+ approveLink(id, input) {
124
+ return this.client.post(`/e2ee/links/${encodeURIComponent(id)}/approve`, input, { retry: true });
125
+ }
126
+ /** The new device, now registered, finished: the sign-in that created the link only. */
127
+ completeLink(id, input) {
128
+ return this.client.post(`/e2ee/links/${encodeURIComponent(id)}/complete`, input);
129
+ }
130
+ /** The approving device, from the sign-in that approved, reports how many chats it has added the new device to. */
131
+ linkProgress(id, input) {
132
+ return this.client.post(`/e2ee/links/${encodeURIComponent(id)}/progress`, input);
133
+ }
134
+ /**
135
+ * Cancel a link. The sign-in that created it cancels it while open or
136
+ * approved; an active device of the account declines an open one with
137
+ * `{ device_id, reason: 'declined' }`, and once the link is scanned only
138
+ * from the sign-in that scanned it.
139
+ */
140
+ cancelLink(id, input) {
141
+ return this.client.post(`/e2ee/links/${encodeURIComponent(id)}/cancel`, input ?? {});
142
+ }
143
+ /**
144
+ * The ids of the encrypted conversations this account belongs to, in this
145
+ * key's scope, ordered by id: pass `next` as `after` for the next page
146
+ * (`limit` 1-200, default 100).
147
+ */
148
+ conversations(params) {
149
+ return this.client.get('/e2ee/conversations', params);
150
+ }
61
151
  }
@@ -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) {