/** * SDKChatClient — standalone npm package implementation. * * This is a standalone copy of the HTTP client for use by third-party * integrations that import @oxpulse/chat-sdk directly. The SvelteKit * front-end uses web/src/lib/api/sdkChat.ts (same semantics, same wire * protocol) — kept separate to avoid pulling in SvelteKit build tooling. * * W4 skeleton: full implementation mirrors web/src/lib/api/sdkChat.ts. * Publishing to npmjs.org is deferred to W8 (embed widget wave). */ import type { SDKChatClientOptions, SendArgs, ListArgs, ListResult, MessageRow, ReactionsResponse, ProductMeta, SubscribeArgs, Room, RoomSummary, Member, CreateRoomArgs, UpdateRoomArgs, UpdateMessageArgs, PinnedMessage, PresenceUser, OptimisticHandle, BatchAppendItem } from './types.js'; import { type SendFileArgs } from './attachments.js'; /** * Server-side maximum number of user_ids accepted in a single bulk * POST /api/sdk/rooms/{room_id}/members request. * Mirrors `BULK_ADD_MAX = 500` in crates/sdk/src/rooms.rs:78. */ export declare const BATCH_ADD_MEMBERS_CHUNK = 500; export declare class SDKChatClient { #private; constructor(opts: SDKChatClientOptions); /** * Encode a payload to the bytes that would be sent on the wire, without sending. * Mirrors the encoding _encodeBody applies to POST /api/sdk/messages. */ encodeEnvelope(payload: unknown): Promise; /** * Decode bytes received from the server back to the JSON payload. * Server responses are always plain JSON; this also handles wire-codec * compressed frames for completeness (round-trip with encodeEnvelope). * * Note: #compression governs OUTGOING encoding only. A server may send a * compressed frame (0xC6/0xC7) regardless of the client's compression setting. * decodeEnvelope checks the first byte directly and initializes zstd if needed. */ decodeEnvelope(bytes: Uint8Array | string): Promise; /** Base URL without trailing slash. Used by attachment helpers. */ get baseUrl(): string; /** JWT (without Bearer prefix). Used by attachment helpers. */ get jwt(): string; /** * SEC-CR-001: public poison gate for an out-of-client attachment-upload wrapper. * * The chat-widget uploads attachment BYTES via presignAttachment() + a raw PUT * directly — deliberately bypassing sendFile() to retain the presigned attachmentId * for its stage-then-send UX — so it cannot inherit sendFile()'s own * #assertRoomNotPoisoned gate (see sendFile above). This thin delegate lets that * room-aware wrapper fail CLOSED *before* presign, so no attachment BYTES leave for a * room poisoned by a prior crypto_mode_mismatch. It reads the SAME authoritative * #poisonedRooms as every internal gate (send / sendText / sendFile / list / …) — it * is NOT a second poison store. * * Throws SDKChatError('crypto_mode_poisoned') for a poisoned room; returns for a * healthy one. */ assertRoomNotPoisoned(roomId: string): void; /** * @internal test-only: current sizes of the per-room crypto-state collections. * Lets tests assert eviction (discovered-mode entries released on last teardown) * and poison stickiness without exposing the private fields. Not exported/stable. */ _roomCryptoStateSize(): { modes: number; poisoned: number; }; /** * @internal test-only: number of live per-room decrypt-chain entries. Lets tests * assert the chain DRAINS + its entry is cleaned up after a force-drain (no Map leak) * without exposing the private field. Not exported/stable. */ _decryptChainSize(): number; /** * W6 E2EE: encrypt text and send as a sealed message. * * Requires e2ee to be configured in the constructor options. * Throws SDKChatError('unsupported') when e2ee is not configured. * * Internally: UTF-8 encodes text → seals via CryptoProvider → calls send(). * * @param roomId Target room. * @param args senderUid + text string to encrypt and send. */ sendText(roomId: string, args: { senderUid: string; text: string; msgId?: string; threadRootMsgId?: string; productRef?: string; productMeta?: unknown; }): Promise<{ seq: number; msgId: string; }>; send(roomId: string, args: SendArgs): Promise<{ seq: number; msgId: string; }>; list(roomId: string, args?: ListArgs): Promise; subscribe(roomId: string, args: SubscribeArgs): () => void; /** * Broadcast a typing indicator for roomId. * Wire-contract: POST /api/sdk/rooms/:room_id/typing * Body: { ttl_secs? } */ sendTyping(roomId: string, ttlSecs?: number): Promise; /** * Send a presence heartbeat for roomId. * Wire-contract: POST /api/sdk/rooms/:room_id/presence * Body: {} */ sendPresence(roomId: string): Promise; /** * Fetch presence snapshot for roomId. * Wire-contract: GET /api/sdk/rooms/:room_id/presence * Returns: Array */ getPresence(roomId: string): Promise; /** * Mark messages up to seq as read (monotonic — cannot regress). * Wire-contract: POST /api/sdk/rooms/:room_id/read?seq=N */ markRead(roomId: string, seq: number): Promise; /** * Create a room. Idempotent per roomId. * Wire-contract: POST /api/sdk/rooms */ createRoom(args?: CreateRoomArgs): Promise; /** * Fetch room metadata + active members. * Wire-contract: GET /api/sdk/rooms/:room_id */ getRoom(roomId: string): Promise; /** * Update room title / metadata. Owner-only. * Wire-contract: PATCH /api/sdk/rooms/:room_id */ updateRoom(roomId: string, args: UpdateRoomArgs): Promise; /** * List active members of a room (fetches room and returns members). */ listMembers(roomId: string): Promise; /** * List rooms in the calling app where the user is an active member. * Ordered by `created_at` DESC. * * @param opts.limit Default 50, clamped server-side to 1..=200. * @param opts.offset Pagination offset, default 0. * @param opts.includeArchived Include rooms with non-NULL archived_at. * Default false (active deals only). * @returns `rooms` array (typed as `RoomSummary[]`) + `limit` + `offset` + `hasMore`. * `RoomSummary` has no `members` field — call `getRoom(roomId)` for the * full member list of a specific room. */ listRooms(opts?: { limit?: number; offset?: number; includeArchived?: boolean; }): Promise<{ rooms: RoomSummary[]; limit: number; offset: number; hasMore: boolean; }>; /** * Add a user to a room. * Wire-contract: POST /api/sdk/rooms/:room_id/members * Body: { user_id, role? } */ addMember(roomId: string, userId: string, role?: string): Promise; /** * Remove a user from a room (soft-delete). * Wire-contract: DELETE /api/sdk/rooms/:room_id/members/:user_id */ removeMember(roomId: string, userId: string): Promise; /** * Fetch all reply messages in a thread. * Wire-contract: GET /api/sdk/rooms/:room_id/threads/:root_msg_id * Returns: Array sorted by seq ascending (server-side ORDER BY seq). * Requires scope: chat:read:. * * SEC-CR-17-02: fails CLOSED for a poisoned room — a thread is message content, so it * belongs to the same gate class as list()/#fetchRows (a room proven to have a * downgraded/tampered crypto_mode must not keep serving its content). Unlike list(), * getThread does NOT resolve crypto_mode: the threads endpoint returns a BARE JSON array * with no per-response crypto_mode field (crates/sdk/src/messages/dtos.rs — only list()'s * page wrapper carries it), and getThread returns rows with `sealed` intact (the caller * unseals), so there is no plaintext-vs-sframe dispatch that would need the mode. The * poison gate is the relevant boundary here. */ getThread(roomId: string, rootMsgId: string): Promise; /** * Edit a message (only the original sender may edit). * Wire-contract: PATCH /api/sdk/messages/:room_id/:msg_id * Body: { sealed_b64 } * Scope: chat:write:. */ updateMessage(roomId: string, msgId: string, args: UpdateMessageArgs): Promise; /** * Soft-delete a message (only the original sender may delete). * Wire-contract: DELETE /api/sdk/messages/:room_id/:msg_id * Returns void on 204. * Scope: chat:write:. */ deleteMessage(roomId: string, msgId: string): Promise; /** * Pin a message in a room. Idempotent. * Wire-contract: POST /api/sdk/rooms/:room_id/pins/:msg_id * Scope: chat:write:. */ pinMessage(roomId: string, msgId: string): Promise; /** * Unpin a message in a room. No-op if not pinned. * Wire-contract: DELETE /api/sdk/rooms/:room_id/pins/:msg_id * Scope: chat:write:. */ unpinMessage(roomId: string, msgId: string): Promise; /** * List pinned messages in a room, ordered by pinned_at descending. * Wire-contract: GET /api/sdk/rooms/:room_id/pins * Scope: chat:read:. */ listPins(roomId: string): Promise; /** * W3: Add a reaction emoji to a message. Idempotent per (user, emoji). * Wire-contract: POST /api/sdk/messages/:room_id/:msg_id/reactions * Scope: chat:write:. * * @param reaction - Emoji / reaction string. Must be 1–32 Unicode code points * (`[...reaction].length`). The server enforces the same limit via * `character_length(reaction) BETWEEN 1 AND 32` (PG code-point count). * Throws `invalid_args` if the validation fails before the network call. */ sendReaction(roomId: string, msgId: string, reaction: string): Promise; /** * W3: Remove own reaction from a message. No-op if never added. * Wire-contract: DELETE /api/sdk/messages/:room_id/:msg_id/reactions/:reaction * Scope: chat:write:. * * The reaction string is percent-encoded in the path to support emoji. * * @param reaction - Emoji / reaction string. Must be 1–32 Unicode code points * (`[...reaction].length`). The server enforces the same limit via * `character_length(reaction) BETWEEN 1 AND 32` (PG code-point count). * Throws `invalid_args` if the validation fails before the network call. */ removeReaction(roomId: string, msgId: string, reaction: string): Promise; /** * W3: Fetch aggregated reaction counts + user lists for a message. * Wire-contract: GET /api/sdk/messages/:room_id/:msg_id/reactions * Scope: chat:read:. */ getReactions(roomId: string, msgId: string): Promise; /** * W4: Upload a file attachment and send a sealed reference in the room. * * Flow: presign → PUT blob → send sealed message. * Client-side size guard: rejects blobs > 50 MB before network call. * * @param roomId Room to post the attachment message to. * @param blob The (encrypted) blob to upload. * @param args senderUid + sealed payload + sha256. */ sendFile(roomId: string, blob: Blob, args: SendFileArgs): Promise<{ seq: number; msgId: string; }>; /** * MAX_RETRIES for network errors in sendOptimistic retry loop. * Non-network errors (4xx, invalid args) fail immediately. */ static readonly MAX_RETRIES = 5; /** * Send a message optimistically with Sendbird-style callbacks. * * Flow: * 1. Fires onPending callbacks (after microtask gap so callers can register them). * 2. Enqueues to idb-keyval (persists across page reload). * 3. Attempts send(); on network error retries up to MAX_RETRIES with backoff. * 4. Non-network errors (4xx) fail immediately — no retry, outbox cleared. * 5. On success, dequeues from idb-keyval, fires onSucceeded. * 6. On MAX_RETRIES exhaustion, fires onFailed with code='network'. * * The returned handle.done Promise resolves on success or rejects on failure. * Always attach onFailed or .catch to avoid unhandled rejections. */ sendOptimistic(roomId: string, args: SendArgs): OptimisticHandle; /** * Retry all queued messages for a room (e.g. on reconnect after page reload). * Messages that succeed are dequeued. A message that fails with a PERMANENT error * (crypto_mode_poisoned / 4xx client error — see PERMANENT_OUTBOX_FAILURE_CODES) is * also dequeued, so a truly-undeliverable entry does not retry forever (CR17 Item C). * A TRANSIENT failure (network / 401 / 429 / 5xx) stays queued for the next flush — * this is a background durability path with no caller notification, so dropping a * retriable ciphertext message would be silent E2EE message loss (CR17-C-01). */ flushOutbox(roomId: string): Promise; /** * Send a text message optimistically with automatic E2EE sealing. * * Unlike sendOptimistic() which requires pre-sealed bytes, this method * UTF-8 encodes the text, seals it via the configured CryptoProvider, * then enqueues the ciphertext. The outbox never stores plaintext. * * Requires e2ee to be configured in the constructor. Throws SDKChatError * with code 'unsupported' if e2ee is not configured. * * @param roomId Target room. * @param args senderUid + text to seal and send optimistically. */ sendTextOptimistic(roomId: string, args: { senderUid: string; text: string; msgId?: string; threadRootMsgId?: string; productRef?: string; productMeta?: unknown; }): OptimisticHandle; /** * Send multiple messages in a single transaction. * * Wire-contract: POST /api/sdk/messages/batch * Scope required: chat:write: * * Items must be pre-sealed — batchAppend does NOT auto-seal (use sendText / * sendTextOptimistic for auto-seal). Pass sealed ciphertext as ArrayBuffer; * wire DTO conversion (base64, snake_case) happens internally. * * room_id is injected automatically; created_at is set server-side. */ batchAppend(roomId: string, items: BatchAppendItem[]): Promise; /** * Send a product card message — a variant of `send()` that attaches a * `product_ref` and `product_meta` payload for marketplace integrations. * * `sealedBody` is optional; omitting it sends an unsealed product-card * (plaintext productMeta only, no E2EE content). * * Wire-contract: POST /api/sdk/messages with `product_ref` + `product_meta`. * * API role (#114): this is the PUBLIC external-integrator convenience API for * sending a product-card message in a single call. The in-house * `@oxpulse/chat-widget` composer deliberately does NOT call this method — * it routes cards through `sendText()` with `productRef`/`productMeta` args * (see Composer.setProductCard in packages/chat-widget/src/ui/composer.ts) * so the card travels the same send path as the caption text. Both paths * produce the same wire payload; the split is an integrator-convenience vs. * in-house-routing decision, not a behavioral difference. Do NOT reroute the * widget to use this method — the composer's send-enable logic, error-chip * retry, and attachment-fallback paths all depend on the shared `sendText` * entrypoint. */ sendProductCard(roomId: string, opts: { productRef: string; productMeta: ProductMeta; sealedBody?: ArrayBuffer; msgId?: string; senderUid: string; }): Promise; /** * Search for messages tagged with a specific product_ref. * * When `roomId` is provided, restricts to that room. * Without `roomId`, searches cross-room within the app. * * Wire-contract: GET /api/sdk/messages?product_ref=X[&room_id=Y][&limit=N] */ searchByProductRef(productRef: string, opts?: { roomId?: string; limit?: number; }): Promise; /** * Add multiple users to a room in chunks. Wraps the bulk `user_ids` shape of * POST /api/sdk/rooms/{room_id}/members. Server caps at 500 user_ids per call, * so this method chunks the input array into 500-sized batches and issues * sequential requests, aggregating the `added` / `updated` results. * * For mass-chat use cases (5K+ buyers added to a marketplace room), this is * the correct method — calling addMember() in a loop will hit the * sdk_rooms_write rate limiter (default 30/min with burst 15) after ~15 single * calls and trigger 429 storms. With batchAddMembers(), 5000 users go in 10 * chunks of 500 — well below burst. * * @param roomId Room to add to * @param userIds Array of user IDs. Empty array is rejected; otherwise no * client-side limit (chunked transparently). * @param role 'owner' | 'member' (default 'member'). Same role applies to * every user_id in the batch. * @returns Aggregated `{ added: string[], updated: string[] }` across all * chunks. `added` = newly inserted members; `updated` = * re-activated or role-updated members. * @throws {SDKChatBatchError} On mid-bulk failure (chunk N of M fails). Carries * `{ partial, failedAtIndex, failedChunk, remaining }` so the caller can * compute the residual to retry. */ batchAddMembers(roomId: string, userIds: string[], role?: 'owner' | 'member'): Promise<{ added: string[]; updated: string[]; }>; /** * Delete an entire room's message history. Wraps DELETE /api/sdk/messages/{room_id}. * * Note: this is destructive — all messages in the room are gone. Use to close * out a deal / mark a buyer-seller flow complete. Does NOT delete the room * metadata or memberships (those are managed via the rooms endpoints). * * @param roomId Room to clear */ deleteRoom(roomId: string): Promise; } //# sourceMappingURL=client.d.ts.map