Skip to content

slingshot-chat

@lastshotlabs/slingshot-chat is a framework-level plugin that wires full chat functionality into any Slingshot app in one call. It provides rooms, messages, members, real-time delivery via WebSocket, DM topology, broadcast channels, and full management (mute, block, favorites, pins).

Terminal window
bun add @lastshotlabs/slingshot-chat
import { createApp } from '@lastshotlabs/slingshot';
import { createChatPackage } from '@lastshotlabs/slingshot-chat';
const { app } = await createApp({
packages: [
createChatPackage({
storeType: 'postgres',
mountPath: '/chat',
permissions: {
createRoom: ['admin'],
sendMessage: ['member', 'admin'],
deleteMessage: ['admin'],
},
encryption: {
provider: 'aes-gcm',
keyBase64: process.env.SLINGSHOT_CHAT_KEY_BASE64!,
},
}),
],
});
TypeDescriptionDM ID Format
'dm'1:1 direct message. Auto-created on first send.dm-{[userId1, userId2].sort().join('-')}
'group'Multi-user private room. Explicit membership. Admin-managed.UUID
'broadcast'One sender (admin), many readers. Non-admins receive only.UUID

DM room IDs are deterministic: dm-{sorted(userIds).join('-')}. No lookup table is needed — the ID is computed from both user IDs. This prevents race conditions on “find or create DM”.

// These both refer to the same DM room:
const dmId = 'dm-user-alice-user-bob'; // user-alice < user-bob alphabetically

Use POST /chat/rooms/find-or-create-dm to find or create a DM room. Block checks are enforced — if either party has blocked the other, the request returns 403.

All routes are mounted under the configured mountPath (default: /chat). Routes use x-user-id for auth (populated by slingshot-auth session middleware).

MethodPathDescription
GET/chat/roomsList rooms for the current user
POST/chat/roomsCreate a group or broadcast room
GET/chat/rooms/:roomIdGet a room by ID
PATCH/chat/rooms/:roomIdUpdate room (name, retentionDays). Admin only. DM rooms cannot be updated.
POST/chat/rooms/find-or-create-dmFind or create a DM room. Body: { targetUserId }
MethodPathDescription
GET/chat/room-membersList room memberships scoped to the current user
POST/chat/room-membersAdd a member. Admin only. Not allowed for DM rooms.
PATCH/chat/room-members/:idUpdate membership (role, nickname, notifyOn, mutedUntil)
DELETE/chat/room-members/:idRemove a member
MethodPathDescription
GET/chat/messagesList messages visible to the current user
POST/chat/messagesSend a message. Body: { roomId, body, type?, replyToId?, ... }
PATCH/chat/messages/:messageIdEdit a message body. Author only.
DELETE/chat/messages/:messageIdSoft-delete. Author or room admin.
MethodPathDescription
GET/chat/message-reactionsList reactions visible to the user
POST/chat/message-reactionsAdd a reaction
DELETE/chat/message-reactions/:idRemove a reaction
MethodPathDescription
GET/chat/blocksList blocked users
POST/chat/blocksBlock a user. Body: { targetUserId }
DELETE/chat/blocks/:idDelete a block row
GET/chat/favorite-roomsList favorited rooms
POST/chat/favorite-roomsFavorite a room. Body: { roomId }
DELETE/chat/favorite-rooms/:idRemove a favorite row
GET/chat/pinsList pins visible to the user
POST/chat/pinsPin a message. Body: { roomId, messageId }. Admin only.
DELETE/chat/pins/:idDelete a pin row. Admin only.
MethodPathDescription
GET/chat/encryption/statusServer encryption capability (e2eSupported: false in v1)
POST/chat/encryption/initUpload key material (v1: returns 501)
GET/chat/encryption/key-bundle/:userIdFetch X3DH key bundle (v1: returns 501)
PUT/chat/encryption/prekeysUpload prekey batch (v1: returns 501)
messages:{roomId}:live

Subscribe to a room’s channel to receive all message events in real time.

Server → Client Events (forwarded bus events)

Section titled “Server → Client Events (forwarded bus events)”
EventDescriptionPayload
chat.message.createdNew message postedFull Message object
chat.message.updatedMessage editedFull Message object
chat.message.deletedMessage soft-deleted{ id, roomId, deletedAt }
chat.message.reactionReaction added or removed{ messageId, roomId, emoji, count, added }
chat.readRead receipt broadcast{ userId, roomId, messageId, readAt }

Send from the client as:

{ "action": "event", "event": "chat.typing", "payload": { "roomId": "..." }, "ackId": "opt-uuid" }
EventPayloadServer behavior
chat.typing{ roomId }Volatile broadcast to room, excluding sender. No ack.
chat.read{ roomId, messageId }Records receipt, updates lastReadAt, emits chat.read to room. Acks { ok: true, readAt }.
chat.ping{ ts }Acks { ts, serverTs }. Connection keepalive.
const socket = useChannel(`messages:${roomId}:live`);
socket.on('chat.message.created', msg => appendMessage(msg));
socket.on('chat.message.updated', msg => replaceMessage(msg));
socket.on('chat.message.deleted', ({ id }) => softDeleteMessage(id));
socket.on('chat.message.reaction', ({ messageId, emoji, count }) =>
updateReaction(messageId, emoji, count),
);
// Send typing indicator (volatile, no ack)
socket.emit('chat.typing', { roomId });
// Mark messages as read
socket.emit('chat.read', { roomId, messageId: lastSeenId });

Mute is stored on RoomMember.mutedUntil (ISO-8601 future timestamp or null). Set via PATCH /chat/rooms/:roomId/members/:userId:

{ "mutedUntil": "2025-12-31T00:00:00.000Z" }

When mutedUntil > now, push notifications are suppressed. Clients should check this field.

Blocks are directional. When user A blocks user B:

  • B cannot create a DM room targeting A
  • A’s existing DM with B is inaccessible to B
  • In shared group rooms, B’s messages are visually filtered (client-side)

Favorites appear at the top of the room list. sortOrder is stored on the favorite row and lower values sort first.

Any room admin can pin or unpin messages. Pins are managed through the /chat/pins entity routes.

v1 provides server-side at-rest encryption through JSON-safe package config:

createChatPackage({
storeType: 'postgres',
encryption: {
provider: 'aes-gcm',
keyBase64: process.env.SLINGSHOT_CHAT_KEY_BASE64!,
aadPrefix: 'prod-chat',
},
});

Encryption is applied when Room.encrypted = true. The room ID is attached as authenticated data so ciphertext cannot be replayed across rooms.

v2 E2E encryption (Signal protocol) is @status PENDING. See Phase 11 spec for requirements (X3DH, Double Ratchet, Sender Keys for groups).

Import from @lastshotlabs/slingshot-chat/testing — not from the main export.

import { expect } from 'bun:test';
import {
createChatTestApp,
createMemoryChatState,
seedMember,
seedMessage,
seedRoom,
} from '@lastshotlabs/slingshot-chat/testing';
// Create fully wired in-memory state
const state = createMemoryChatState();
// Seed test data
const room = await seedRoom(state, { type: 'group', name: 'Test Room' });
const member = await seedMember(state, { roomId: room.id, userId: 'user-1', role: 'admin' });
const message = await seedMessage(state, { roomId: room.id, body: 'Hello', authorId: 'user-1' });
// Create a Hono test app for route testing
const { app, state: appState } = await createChatTestApp();
const res = await app.request('/chat/rooms', {
method: 'POST',
headers: { 'x-user-id': 'user-1', 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'General', type: 'group' }),
});
expect(res.status).toBe(201);
  • Durable subscriptions: The current v1 WS delivery uses InProcessAdapter. Messages delivered during disconnect are lost — clients must use GET /chat/rooms/:roomId/messages?since=cursor on reconnect to fill gaps.
  • E2E encryption: v1 is server-side provider-driven encryption only. True end-to-end encryption (Signal protocol) is v2 and @status PENDING.
  • listForUser in memory backend: The memory implementation returns all rooms (not just rooms the user is a member of). Production backends use a proper JOIN with the room_members table.