@pmate/chat-sdk 0.9.11 → 0.10.0

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
@@ -72,3 +72,16 @@ are explicit errors.
72
72
  and withholds incomplete charts. A revision gap requires fetching a snapshot.
73
73
  This reducer does not itself create a network stream; the sender must provide
74
74
  updates and the receiver must preserve its existing identity/authorization checks.
75
+
76
+
77
+ ## Team conversations
78
+
79
+ ### Background
80
+
81
+ A profile can belong to several existing organizations. Pass the organization's stable ID as `teamId` to keep conversations separate; never use the team's display name as identity. A compatible Hub must validate membership before accepting Team messages.
82
+
83
+ `dmThreadId(appId, ownProfileId, peerProfileId, teamId)` and `groupThreadId(appId, groupId, teamId)` produce Team-specific IDs matching Rust social. Omitting `teamId` retains the legacy ID for migration lookup and existing unscoped apps. `ChatClient` accepts a default `teamId`; `sendDm`, `sendGroup`, group operations, contact actions and activity targets also accept explicit scope. Conflicting explicit and default scopes are rejected. Raw `sendChat` callers can use `withChatTeam(body, teamId)`.
84
+
85
+ `TeamMigrationClient.plan(peerId)` reads the authenticated caller's legacy history summary and common Team candidates. `assign(peerId, teamId)` submits a durable ownership decision. A `pending` result means the indexer has not confirmed the result: plan again, do not report success yet. `TeamMigrationError` with status 409 includes the winning assignment when concurrent selections conflict.
86
+
87
+ Migrated reading snapshots retain source `id`/sequence positions and add `conversationId`/`teamId`. Use `sourcesInConversation` and `unreadInConversation` to render the logical conversation. `recordReadIntent` resolves the message back to its durable source, preserving old offline pending reads and explicit voice reads. Do not merge different sources' sequence numbers into a single cursor.
package/lib/index.d.ts CHANGED
@@ -35,3 +35,5 @@ export * from "./runtime/activity";
35
35
  export * from "./runtime/call";
36
36
  export * from "./runtime/presence";
37
37
  export * from "./runtime/meeting";
38
+ export * from "./runtime/teamScope";
39
+ export * from "./runtime/teamMigrations";
package/lib/index.js CHANGED
@@ -35,3 +35,5 @@ export * from "./runtime/activity";
35
35
  export * from "./runtime/call";
36
36
  export * from "./runtime/presence";
37
37
  export * from "./runtime/meeting";
38
+ export * from "./runtime/teamScope";
39
+ export * from "./runtime/teamMigrations";
@@ -3,6 +3,7 @@ export type ChatActivityState = "working" | "typing" | "idle";
3
3
  export type ChatActivityBody = {
4
4
  kind: "activity";
5
5
  appId: string;
6
+ teamId?: string;
6
7
  threadKind: "dm" | "group";
7
8
  activityId: string;
8
9
  seq: number;
@@ -23,6 +24,7 @@ export type ChatActivity = ChatActivityBody & {
23
24
  };
24
25
  export type ActivityTarget = {
25
26
  to: string;
27
+ teamId?: string;
26
28
  threadKind?: "dm" | "group";
27
29
  recipients?: string[];
28
30
  replyTo?: string;
@@ -1,8 +1,11 @@
1
1
  import { ChatMsgOp, createRuntimeMsg } from "./protocol.js";
2
- import { dmThreadId, groupThreadId } from "./thread.js";
2
+ import { dmThreadId, groupThreadId, isValidChatTeamId } from "./thread.js";
3
3
  export const ACTIVITY_TTL_MS = 15_000;
4
4
  export function createActivityMessage(input) {
5
+ if (input.teamId !== undefined && !isValidChatTeamId(input.teamId))
6
+ throw new Error("Invalid chat teamId");
5
7
  const body = { kind: "activity", appId: input.appId, threadKind: input.threadKind ?? "dm", activityId: input.activityId, seq: input.seq, state: input.state, ttlMs: ACTIVITY_TTL_MS,
8
+ ...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
6
9
  ...(input.phase ? { phase: input.phase } : {}),
7
10
  ...(input.replyTo ? { replyTo: input.replyTo } : {}),
8
11
  ...(input.detail ? { detail: Array.from(input.detail).slice(0, 120).join("") } : {}) };
@@ -16,6 +19,7 @@ export function parseChatActivity(message, appId, profileId, now = Date.now()) {
16
19
  if (message.opcode !== ChatMsgOp.CHAT || body?.kind !== "activity" || body.appId !== appId || !message.from || message.from === profileId || !message.to
17
20
  || (body.threadKind !== "dm" && body.threadKind !== "group")
18
21
  || (body.threadKind === "dm" ? message.to !== profileId : !message.recipients?.includes(profileId))
22
+ || (body.teamId !== undefined && !isValidChatTeamId(body.teamId))
19
23
  || typeof body.activityId !== "string" || !body.activityId || body.activityId.length > 128
20
24
  || !Number.isSafeInteger(body.seq) || body.seq < 0
21
25
  || !["working", "typing", "idle"].includes(body.state ?? "")
@@ -26,7 +30,7 @@ export function parseChatActivity(message, appId, profileId, now = Date.now()) {
26
30
  || (body.detail !== undefined && (typeof body.detail !== "string" || Array.from(body.detail).length > 120)))
27
31
  return null;
28
32
  return { ...body, from: message.from, to: message.to,
29
- threadId: body.threadKind === "dm" ? dmThreadId(appId, message.from, profileId) : groupThreadId(appId, message.to),
33
+ threadId: body.threadKind === "dm" ? dmThreadId(appId, message.from, profileId, body.teamId) : groupThreadId(appId, message.to, body.teamId),
30
34
  updatedAt: message.t, expiresAt: Math.min(message.t + body.ttlMs, now + ACTIVITY_TTL_MS) };
31
35
  }
32
36
  /** Keeps ordering tombstones after completion so a late heartbeat cannot revive an activity. */
@@ -56,7 +60,7 @@ export class ChatActivityStore {
56
60
  return;
57
61
  let changed = false;
58
62
  for (const [key, record] of this.records) {
59
- if (record.from === message.from && record.to === message.to && record.threadKind === body.threadKind && (record.replyTo && body.content?.replyTo ? record.replyTo === body.content.replyTo : record.updatedAt <= message.t) && record.state !== "idle") {
63
+ if (record.teamId === body.content?.teamId && record.from === message.from && record.to === message.to && record.threadKind === body.threadKind && (record.replyTo && body.content?.replyTo ? record.replyTo === body.content.replyTo : record.updatedAt <= message.t) && record.state !== "idle") {
60
64
  this.records.set(key, { ...record, state: "idle" });
61
65
  changed = true;
62
66
  }
@@ -10,6 +10,7 @@ import { type ChatBody, type ChatRuntimeMessage, type RealtimeClient } from "./p
10
10
  export type ChatConnectionState = "connecting" | "reconnecting" | "authenticating" | "connected" | "authentication_required" | "closed";
11
11
  export type ChatClientOptions = {
12
12
  appId: string;
13
+ teamId?: string;
13
14
  clientInstanceId?: string;
14
15
  onReach?: OnlineReachHandler;
15
16
  onNotification?: OnlineNotificationHandler;
@@ -72,33 +73,40 @@ export declare class ChatClient {
72
73
  startActivity(target: ActivityTarget, initial?: ChatActivityUpdate): ChatActivityPublisher;
73
74
  requestFriend(input: {
74
75
  to: string;
76
+ teamId?: string;
75
77
  message?: string;
76
78
  }): Promise<ChatRuntimeMessage>;
77
79
  acceptFriend(input: {
78
80
  to: string;
81
+ teamId?: string;
79
82
  requestId?: string;
80
83
  message?: string;
81
84
  }): Promise<ChatRuntimeMessage>;
82
85
  rejectFriend(input: {
83
86
  to: string;
87
+ teamId?: string;
84
88
  requestId?: string;
85
89
  reason?: string;
86
90
  }): Promise<ChatRuntimeMessage>;
87
91
  removeFriend(input: {
88
92
  to: string;
93
+ teamId?: string;
89
94
  reason?: string;
90
95
  }): Promise<ChatRuntimeMessage>;
91
96
  sendDm(input: {
92
97
  to: string;
93
98
  text: string;
99
+ teamId?: string;
94
100
  }): Promise<ChatRuntimeMessage>;
95
101
  createGroup(input: {
96
102
  groupId: string;
103
+ teamId?: string;
97
104
  members: string[];
98
105
  title?: string;
99
106
  }): Promise<ChatRuntimeMessage>;
100
107
  updateGroup(input: {
101
108
  groupId: string;
109
+ teamId?: string;
102
110
  expectedRevision: string;
103
111
  addMembers?: string[];
104
112
  removeMembers?: string[];
@@ -107,6 +115,7 @@ export declare class ChatClient {
107
115
  }): Promise<ChatRuntimeMessage>;
108
116
  sendGroup(input: {
109
117
  groupId: string;
118
+ teamId?: string;
110
119
  text: string;
111
120
  recipients?: string[];
112
121
  mentions?: ChatMention[];
@@ -1,3 +1,4 @@
1
+ import { withChatTeam } from "./teamScope.js";
1
2
  import { OnlineReachReceiver } from "./reach.js";
2
3
  import { OnlineNotificationReceiver } from "./notification.js";
3
4
  import { ChatPresenceClient } from "./presence.js";
@@ -175,7 +176,9 @@ export class ChatClient {
175
176
  }
176
177
  watchActivities(handler) { return this.activities.watch(handler); }
177
178
  async sendActivity(input) {
178
- const message = createActivityMessage({ ...input, from: this.profileId(), appId: this.options.appId });
179
+ if (input.teamId !== undefined && this.options.teamId !== undefined && input.teamId !== this.options.teamId)
180
+ throw new Error("Chat teamId does not match the active team");
181
+ const message = createActivityMessage({ ...input, teamId: this.options.teamId ?? input.teamId, from: this.profileId(), appId: this.options.appId });
179
182
  await this.sendAndWaitForAck(message);
180
183
  return message;
181
184
  }
@@ -191,22 +194,26 @@ export class ChatClient {
191
194
  async requestFriend(input) {
192
195
  return this.sendContactAction(input.to, "friendRequest", {
193
196
  message: input.message,
197
+ ...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
194
198
  });
195
199
  }
196
200
  async acceptFriend(input) {
197
201
  return this.sendContactAction(input.to, "friendAccept", {
202
+ ...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
198
203
  requestId: input.requestId,
199
204
  message: input.message,
200
205
  });
201
206
  }
202
207
  async rejectFriend(input) {
203
208
  return this.sendContactAction(input.to, "friendReject", {
209
+ ...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
204
210
  requestId: input.requestId,
205
211
  reason: input.reason,
206
212
  });
207
213
  }
208
214
  async removeFriend(input) {
209
215
  return this.sendContactAction(input.to, "friendRemove", {
216
+ ...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
210
217
  reason: input.reason,
211
218
  });
212
219
  }
@@ -215,7 +222,7 @@ export class ChatClient {
215
222
  kind: "message",
216
223
  threadKind: "dm",
217
224
  contentKind: "text",
218
- content: { appId: this.options.appId, text: input.text },
225
+ content: { appId: this.options.appId, text: input.text, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}) },
219
226
  });
220
227
  }
221
228
  async createGroup(input) {
@@ -226,6 +233,7 @@ export class ChatClient {
226
233
  payload: {
227
234
  appId: this.options.appId,
228
235
  groupId: input.groupId,
236
+ ...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
229
237
  members: input.members,
230
238
  title: input.title,
231
239
  },
@@ -234,7 +242,7 @@ export class ChatClient {
234
242
  async updateGroup(input) {
235
243
  return this.sendChat(input.groupId, {
236
244
  kind: "group", threadKind: "group", action: "groupUpdate",
237
- payload: { appId: this.options.appId, groupId: input.groupId, expectedRevision: input.expectedRevision,
245
+ payload: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), expectedRevision: input.expectedRevision,
238
246
  addMembers: input.addMembers ?? [], removeMembers: input.removeMembers ?? [], ...(input.title !== undefined ? { title: input.title } : {}), ...(input.avatar !== undefined ? { avatar: input.avatar } : {}) },
239
247
  });
240
248
  }
@@ -243,11 +251,11 @@ export class ChatClient {
243
251
  kind: "message",
244
252
  threadKind: "group",
245
253
  contentKind: "text",
246
- content: { appId: this.options.appId, groupId: input.groupId, text: input.text, ...(input.mentions?.length ? { mentions: input.mentions } : {}) },
254
+ content: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), text: input.text, ...(input.mentions?.length ? { mentions: input.mentions } : {}) },
247
255
  }, input.recipients);
248
256
  }
249
257
  async sendChat(to, body, recipients) {
250
- const msg = createRuntimeMsg(this.profileId(), to, ChatMsgOp.CHAT, body, ChatMsgKind.DM);
258
+ const msg = createRuntimeMsg(this.profileId(), to, ChatMsgOp.CHAT, withChatTeam(body, this.options.teamId), ChatMsgKind.DM);
251
259
  if (recipients) {
252
260
  msg.recipients = recipients;
253
261
  }
@@ -15,3 +15,5 @@ export * from "./meeting.js";
15
15
  export * from "./notification.js";
16
16
  export * from "./readSync.js";
17
17
  export * from "./reach.js";
18
+ export * from "./teamScope.js";
19
+ export * from "./teamMigrations.js";
@@ -15,3 +15,5 @@ export * from "./meeting.js";
15
15
  export * from "./notification.js";
16
16
  export * from "./readSync.js";
17
17
  export * from "./reach.js";
18
+ export * from "./teamScope.js";
19
+ export * from "./teamMigrations.js";
@@ -18,7 +18,11 @@ export type ReadingOperation = {
18
18
  explicitReadMessageIds: string[];
19
19
  };
20
20
  export type IndexedReadThread = {
21
+ /** Durable source ID: reading positions must remain attached to this ID. */
21
22
  id: string;
23
+ teamId?: string;
24
+ /** Present when historical source data has been assigned to a team conversation. */
25
+ conversationId?: string;
22
26
  kind: "dm" | "group";
23
27
  messages: IndexedReadMessage[];
24
28
  readThroughSeq: number;
@@ -86,3 +90,10 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
86
90
  update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
87
91
  close(): Promise<void>;
88
92
  }
93
+ export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
94
+ /** Aggregate logical history while each original source retains its own read cursor. */
95
+ export declare function unreadInConversation(state: ReadIndexState, conversationId: string): {
96
+ unreadMessageCount: number;
97
+ unreadMentionCount: number;
98
+ badgeCount: number;
99
+ };
@@ -1,3 +1,4 @@
1
+ import { isValidChatTeamId } from "./thread.js";
1
2
  export class ReadSyncGap extends Error {
2
3
  cursor;
3
4
  constructor(cursor) {
@@ -11,6 +12,10 @@ export function createReadIndex(appId, profileId) {
11
12
  function validateThread(thread) {
12
13
  if (!thread.id || !["dm", "group"].includes(thread.kind) || !Number.isSafeInteger(thread.readThroughSeq) || thread.readThroughSeq < 0)
13
14
  throw new Error("INVALID_READ_THREAD");
15
+ if (thread.teamId !== undefined && !isValidChatTeamId(thread.teamId))
16
+ throw new Error("INVALID_READ_TEAM");
17
+ if (thread.conversationId !== undefined && (!thread.conversationId || !thread.teamId))
18
+ throw new Error("INVALID_READ_CONVERSATION");
14
19
  const ids = new Set(), positions = new Set();
15
20
  for (const message of thread.messages) {
16
21
  if (!message.id || !Number.isSafeInteger(message.seq) || message.seq < 1 || ids.has(message.id) || positions.has(message.seq)
@@ -89,7 +94,7 @@ function resolveObservedReads(current) {
89
94
  let next = current;
90
95
  const remaining = [];
91
96
  for (const intent of current.observedReadIntents ?? []) {
92
- const message = next.threads.find(thread => thread.id === intent.threadId)?.messages.find(message => message.id === intent.messageId);
97
+ const message = readSource(next, intent.threadId, intent.messageId)?.messages.find(message => message.id === intent.messageId);
93
98
  if (!message) {
94
99
  remaining.push(intent);
95
100
  continue;
@@ -105,22 +110,22 @@ function resolveObservedReads(current) {
105
110
  }
106
111
  /** Called only for a message the visible viewport actually reached. */
107
112
  export function recordVisibleRead(current, threadId, messageId, operationId) {
108
- const thread = current.threads.find(t => t.id === threadId);
113
+ const thread = readSource(current, threadId, messageId);
109
114
  const message = thread?.messages.find(m => m.id === messageId);
110
115
  if (!thread || !message || message.readPolicy !== "visible")
111
116
  throw new Error("INVALID_VISIBLE_READ");
112
117
  if (message.seq <= thread.readThroughSeq)
113
118
  return current;
114
- return enqueue(current, { operationId, threadId, readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
119
+ return enqueue(current, { operationId, threadId: thread.id, readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
115
120
  }
116
121
  export function recordExplicitRead(current, threadId, messageId, operationId) {
117
- const thread = current.threads.find(t => t.id === threadId);
122
+ const thread = readSource(current, threadId, messageId);
118
123
  const message = thread?.messages.find(m => m.id === messageId);
119
124
  if (!thread || !message || message.readPolicy !== "explicit")
120
125
  throw new Error("INVALID_EXPLICIT_READ");
121
126
  if (thread.explicitReadMessageIds.includes(messageId))
122
127
  return current;
123
- return enqueue(current, { operationId, threadId, readThroughSeq: 0, explicitReadMessageIds: [messageId] });
128
+ return enqueue(current, { operationId, threadId: thread.id, readThroughSeq: 0, explicitReadMessageIds: [messageId] });
124
129
  }
125
130
  function enqueue(current, operation) {
126
131
  if (!operation.operationId || current.pending.some(p => p.operationId === operation.operationId))
@@ -207,3 +212,24 @@ export class IndexedDbReadIndex {
207
212
  }
208
213
  async close() { (await this.database).close(); }
209
214
  }
215
+ export function sourcesInConversation(state, conversationId) {
216
+ return state.threads.filter(thread => (thread.conversationId ?? thread.id) === conversationId);
217
+ }
218
+ function readSource(state, conversationId, messageId) {
219
+ const sources = state.threads.filter(thread => (thread.id === conversationId || thread.conversationId === conversationId) && thread.messages.some(message => message.id === messageId));
220
+ if (sources.length > 1)
221
+ throw new Error("AMBIGUOUS_READ_SOURCE");
222
+ return sources[0];
223
+ }
224
+ /** Aggregate logical history while each original source retains its own read cursor. */
225
+ export function unreadInConversation(state, conversationId) {
226
+ const sources = sourcesInConversation(state, conversationId);
227
+ if (!sources.length)
228
+ throw new Error("UNKNOWN_READ_CONVERSATION");
229
+ if (new Set(sources.map(source => source.teamId)).size > 1)
230
+ throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
231
+ return sources.reduce((total, source) => {
232
+ const unread = unreadInThread(state, source.id);
233
+ return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
234
+ }, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
235
+ }
@@ -32,6 +32,7 @@ export type ChatReaderThread = {
32
32
  unread: number;
33
33
  };
34
34
  export type ChatReaderContact = {
35
+ teamId?: string;
35
36
  id?: string;
36
37
  accountId?: string;
37
38
  peerId?: string;
@@ -46,6 +47,8 @@ export type ChatReaderContact = {
46
47
  members?: string[];
47
48
  };
48
49
  export type ChatReaderSocialMessage = {
50
+ teamId?: string;
51
+ sourceThreadId?: string;
49
52
  appId: string;
50
53
  threadId: string;
51
54
  threadKind: "dm" | "group";
@@ -64,6 +67,7 @@ export type ChatReaderMessagePage = {
64
67
  data: ChatReaderMessage[];
65
68
  };
66
69
  export type ChatReaderUnread = {
70
+ teamId?: string;
67
71
  appId: string;
68
72
  accountId: string;
69
73
  threadId: string;
@@ -0,0 +1,45 @@
1
+ export type TeamAssignment = {
2
+ appId: string;
3
+ sourceThreadId: string;
4
+ threadId: string;
5
+ teamId: string;
6
+ actorId: string;
7
+ assignmentId: string;
8
+ createdAt: number;
9
+ };
10
+ export type TeamMigrationPlan = {
11
+ status: "assigned" | "noHistory" | "noCommonTeam" | "ready" | "needsSelection";
12
+ sourceThreadId: string;
13
+ candidates: {
14
+ id: string;
15
+ name: string;
16
+ }[];
17
+ messageCount?: number;
18
+ assignment?: TeamAssignment;
19
+ };
20
+ export type TeamMigrationResult = {
21
+ status: "assigned";
22
+ assignment: TeamAssignment;
23
+ } | {
24
+ status: "pending";
25
+ sourceThreadId: string;
26
+ };
27
+ export declare class TeamMigrationError extends Error {
28
+ readonly status: number;
29
+ readonly assignment?: TeamAssignment | undefined;
30
+ constructor(status: number, message: string, assignment?: TeamAssignment | undefined);
31
+ }
32
+ /** Planning does not mutate history. Assignment uses the server's current membership and winner. */
33
+ export declare class TeamMigrationClient {
34
+ private readonly options;
35
+ constructor(options: {
36
+ appId: string;
37
+ profileId: string;
38
+ token: string;
39
+ hubHttpUrl?: string;
40
+ fetch?: typeof fetch;
41
+ });
42
+ plan(peerId: string): Promise<TeamMigrationPlan>;
43
+ assign(peerId: string, teamId: string): Promise<TeamMigrationResult>;
44
+ private request;
45
+ }
@@ -0,0 +1,32 @@
1
+ export class TeamMigrationError extends Error {
2
+ status;
3
+ assignment;
4
+ constructor(status, message, assignment) {
5
+ super(message);
6
+ this.status = status;
7
+ this.assignment = assignment;
8
+ }
9
+ }
10
+ /** Planning does not mutate history. Assignment uses the server's current membership and winner. */
11
+ export class TeamMigrationClient {
12
+ options;
13
+ constructor(options) {
14
+ this.options = options;
15
+ }
16
+ async plan(peerId) {
17
+ const query = new URLSearchParams({ appId: this.options.appId, profileId: this.options.profileId, peerId });
18
+ return this.request(`/plan?${query}`);
19
+ }
20
+ async assign(peerId, teamId) {
21
+ return this.request("", { method: "POST", body: JSON.stringify({ appId: this.options.appId, profileId: this.options.profileId, peerId, teamId }) });
22
+ }
23
+ async request(path, init = {}) {
24
+ const response = await (this.options.fetch ?? fetch)(`${(this.options.hubHttpUrl ?? "https://hub.pmate.chat").replace(/\/$/, "")}/api/social/team-migrations${path}`, {
25
+ ...init, signal: AbortSignal.timeout(15_000), headers: { "content-type": "application/json", authorization: `Bearer ${this.options.token}` }, redirect: "error",
26
+ });
27
+ const envelope = await response.json();
28
+ if (!response.ok || envelope.success !== true || envelope.data === undefined)
29
+ throw new TeamMigrationError(response.status, envelope.message ?? "TEAM_MIGRATION_FAILED", envelope.data?.assignment);
30
+ return envelope.data;
31
+ }
32
+ }
@@ -0,0 +1,5 @@
1
+ import type { ChatBody } from "./protocol.js";
2
+ /** Missing scope identifies legacy data; malformed scope must never become legacy. */
3
+ export declare function chatTeamId(body: unknown): string | undefined;
4
+ /** Preserve a message's scope and reject accidental sends into another active team. */
5
+ export declare function withChatTeam(body: ChatBody, teamId?: string): ChatBody;
@@ -0,0 +1,53 @@
1
+ import { isValidChatTeamId } from "./thread.js";
2
+ function record(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
4
+ }
5
+ function metadata(body) {
6
+ const value = record(body);
7
+ switch (value?.kind) {
8
+ case "message":
9
+ case "pending":
10
+ case "edit": return record(value.content);
11
+ case "quote": return record(record(value.content)?.content);
12
+ case "contact":
13
+ case "group":
14
+ case "hello": return record(value.payload);
15
+ case "activity":
16
+ case "recall":
17
+ case "delete": return value;
18
+ default: return undefined;
19
+ }
20
+ }
21
+ /** Missing scope identifies legacy data; malformed scope must never become legacy. */
22
+ export function chatTeamId(body) {
23
+ const value = metadata(body);
24
+ if (!value || !("teamId" in value))
25
+ return undefined;
26
+ if (!isValidChatTeamId(value.teamId))
27
+ throw new Error("Invalid chat teamId");
28
+ return value.teamId;
29
+ }
30
+ /** Preserve a message's scope and reject accidental sends into another active team. */
31
+ export function withChatTeam(body, teamId) {
32
+ const previous = chatTeamId(body);
33
+ if (teamId === undefined)
34
+ return body;
35
+ if (!isValidChatTeamId(teamId))
36
+ throw new Error("Invalid chat teamId");
37
+ if (previous !== undefined && previous !== teamId)
38
+ throw new Error("Chat teamId does not match the active team");
39
+ const scoped = { ...metadata(body), teamId };
40
+ switch (body.kind) {
41
+ case "message":
42
+ case "pending":
43
+ case "edit": return { ...body, content: scoped };
44
+ case "quote": return { ...body, content: { ...body.content, content: scoped } };
45
+ case "contact":
46
+ case "group":
47
+ case "hello": return { ...body, payload: scoped };
48
+ case "activity": return { ...body, teamId };
49
+ // Rust's legacy recall/delete bodies do not carry scope. Current clients use
50
+ // durable message content for recalls; fail explicitly until these upgrade.
51
+ default: throw new Error(`Team-scoped ${body.kind} is not supported`);
52
+ }
53
+ }
@@ -1,3 +1,4 @@
1
- export declare function pairId(appId: string, userA: string, userB: string): string;
2
- export declare function dmThreadId(appId: string, userA: string, userB: string): string;
3
- export declare function groupThreadId(appId: string, groupId: string): string;
1
+ export declare function pairId(appId: string, userA: string, userB: string, teamId?: string): string;
2
+ export declare function dmThreadId(appId: string, userA: string, userB: string, teamId?: string): string;
3
+ export declare function groupThreadId(appId: string, groupId: string, teamId?: string): string;
4
+ export declare function isValidChatTeamId(value: unknown): value is string;
@@ -1,15 +1,32 @@
1
1
  import { blake3 } from "@noble/hashes/blake3.js";
2
2
  import { bytesToHex } from "@noble/hashes/utils.js";
3
- export function pairId(appId, userA, userB) {
3
+ export function pairId(appId, userA, userB, teamId) {
4
4
  const [left, right] = userA <= userB ? [userA, userB] : [userB, userA];
5
- return stableHash(`social:v1:pair:${appId}:${left}:${right}`);
5
+ return teamId === undefined
6
+ ? stableHash(`social:v1:pair:${appId}:${left}:${right}`)
7
+ : teamThreadHash("pair", [appId, teamId, left, right]);
6
8
  }
7
- export function dmThreadId(appId, userA, userB) {
8
- return stableHash(`social:v1:dm:${appId}:${pairId(appId, userA, userB)}`);
9
+ export function dmThreadId(appId, userA, userB, teamId) {
10
+ return teamId === undefined
11
+ ? stableHash(`social:v1:dm:${appId}:${pairId(appId, userA, userB)}`)
12
+ : teamThreadHash("dm", [appId, teamId, pairId(appId, userA, userB)]);
9
13
  }
10
- export function groupThreadId(appId, groupId) {
11
- return stableHash(`social:v1:group:${appId}:${groupId}`);
14
+ export function groupThreadId(appId, groupId, teamId) {
15
+ return teamId === undefined
16
+ ? stableHash(`social:v1:group:${appId}:${groupId}`)
17
+ : teamThreadHash("group", [appId, teamId, groupId]);
18
+ }
19
+ /** JSON tuples avoid ambiguity when application/team IDs contain separators. */
20
+ function teamThreadHash(kind, parts) {
21
+ const teamId = parts[1];
22
+ if (!isValidChatTeamId(teamId)) {
23
+ throw new Error("Invalid chat teamId");
24
+ }
25
+ return stableHash(`social:v2:${kind}:${JSON.stringify(parts)}`);
12
26
  }
13
27
  function stableHash(value) {
14
28
  return bytesToHex(blake3(new TextEncoder().encode(value)));
15
29
  }
30
+ export function isValidChatTeamId(value) {
31
+ return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).length <= 512 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.9.11",
3
+ "version": "0.10.0",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",