dsh-a2a-groups 0.0.0-stage → 0.1.2
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-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
|
@@ -0,0 +1,3010 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group state machine: password, invites, members, feed, subscriptions.
|
|
3
|
+
*
|
|
4
|
+
* This is the security-critical half of the plugin, so the rules are written
|
|
5
|
+
* down here rather than discovered by reading the code:
|
|
6
|
+
*
|
|
7
|
+
* **What is stored vs. what is shown.** The file on disk holds password hashes,
|
|
8
|
+
* invite-token hashes and member-credential hashes. Plaintext secrets exist only
|
|
9
|
+
* in the return value of `create` / `invite` / `join`, exactly once, because that
|
|
10
|
+
* response is the only channel that can carry them: the owner pastes an invite
|
|
11
|
+
* link into a chat, and the invitee's Session keeps the credential it got back.
|
|
12
|
+
* After that single trip, the plaintext is unrecoverable from the host — a
|
|
13
|
+
* compromised `groups.json` yields no usable password and no usable token, which
|
|
14
|
+
* is the whole point of hashing them.
|
|
15
|
+
*
|
|
16
|
+
* **Why the two credential kinds are separate.** A group password is *shared*
|
|
17
|
+
* and *long-lived*: everyone who joins learns it, and revoking it means rotating
|
|
18
|
+
* it for everyone. A member key is *individual* and *revocable*: it identifies
|
|
19
|
+
* exactly one member and is deleted when that member leaves. Folding them into
|
|
20
|
+
* one secret would mean either "you cannot remove one member without locking out
|
|
21
|
+
* the group" or "one leaked member key can mint invites forever". Invites are a
|
|
22
|
+
* third kind again — *single-use, expiring, revocable* — and are hashed for the
|
|
23
|
+
* same reason: the link travels through chat logs and screenshots, so what the
|
|
24
|
+
* host keeps must be useless if the link leaks.
|
|
25
|
+
*
|
|
26
|
+
* **Why `feed` is a monotonically increasing `seq`.** A subscriber that drops
|
|
27
|
+
* (SSE killed by a proxy, laptop lid, gateway timeout) must be able to reconnect
|
|
28
|
+
* with `sinceSeq` and receive exactly what it missed. That requires an ordering
|
|
29
|
+
* that never repeats and never goes backwards, so `seq` is allocated from a
|
|
30
|
+
* persisted counter, not from array length or timestamps — array length breaks
|
|
31
|
+
* under retention pruning, and timestamps break under clock skew. `msgId` is a
|
|
32
|
+
* uuid and exists for the *other* job: end-to-end dedupe once the same message
|
|
33
|
+
* can arrive by more than one path (reconnect overlap, retry after timeout).
|
|
34
|
+
*
|
|
35
|
+
* **Why comparisons are constant-time and hashes are fixed-width.** Every secret
|
|
36
|
+
* is compared as a fixed-width sha256 hex digest (member keys) or as the scrypt
|
|
37
|
+
* output buffer (passwords), so `timingSafeEqual` never sees mismatched lengths
|
|
38
|
+
* and the comparison leaks neither content nor length. A wrong member key and a
|
|
39
|
+
* nonexistent member both fail through the same path with the same error, so the
|
|
40
|
+
* endpoint cannot be used to enumerate members.
|
|
41
|
+
*
|
|
42
|
+
* Boundary: pure state. No network, no DSH, no timers (only an injected clock).
|
|
43
|
+
* The A2A layer decides who may call which method; this module decides what the
|
|
44
|
+
* group's data means. Time is epoch milliseconds internally; ISO strings are
|
|
45
|
+
* added only on the public serializers, because a feed entry is a boundary
|
|
46
|
+
* object that goes straight into a UI or an artifact.
|
|
47
|
+
*
|
|
48
|
+
* @module dsh-a2a/groups
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One member row, as the store keeps it.
|
|
53
|
+
*
|
|
54
|
+
* `keyHash` is the sha256 of the member credential — the plaintext exists only in
|
|
55
|
+
* the `join` response and is never recoverable from this record. Anything that
|
|
56
|
+
* publishes a member must go through the public projection, not this type.
|
|
57
|
+
*
|
|
58
|
+
* @typedef {object} GroupMemberRecord
|
|
59
|
+
* @property {string} personId - this person’s id inside the group.
|
|
60
|
+
* @property {string} displayName
|
|
61
|
+
* @property {string} role - `owner` or `member`.
|
|
62
|
+
* @property {string} keyHash - sha256 of the member credential.
|
|
63
|
+
* @property {number} joinedAt - epoch ms.
|
|
64
|
+
* @property {number} lastSeenAt - epoch ms; drives the online window.
|
|
65
|
+
* @property {string} [endpoint] - the member’s A2A endpoint, when it reported one.
|
|
66
|
+
* @property {string} [about] - layer A: self-described role in THIS group.
|
|
67
|
+
* @property {string[]} [tags]
|
|
68
|
+
* @property {number} [profileUpdatedAt]
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One join request, from asking to be admitted to being decided.
|
|
73
|
+
*
|
|
74
|
+
* @typedef {object} GroupRequestRecord
|
|
75
|
+
* @property {string} requestId
|
|
76
|
+
* @property {string} displayName
|
|
77
|
+
* @property {string} status - `pending`, `approved`, `denied`, or `expired`.
|
|
78
|
+
* @property {number} createdAt - epoch ms.
|
|
79
|
+
* @property {number} expiresAt - epoch ms; a pending row past this is expired.
|
|
80
|
+
* @property {number} [decidedAt] - epoch ms.
|
|
81
|
+
* @property {string} [reason]
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One feed entry.
|
|
86
|
+
*
|
|
87
|
+
* @typedef {object} GroupFeedEntry
|
|
88
|
+
* @property {number} seq - monotonic within the group.
|
|
89
|
+
* @property {number} at - epoch ms.
|
|
90
|
+
* @property {string} msgId
|
|
91
|
+
* @property {string} kind - `message` or `system`.
|
|
92
|
+
* @property {{personId: string, displayName: string}} from
|
|
93
|
+
* @property {string} text
|
|
94
|
+
* @property {object} [payload]
|
|
95
|
+
* @property {string[]} [mentions]
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The whole group record — the unit that `groups.json` persists.
|
|
100
|
+
*
|
|
101
|
+
* `passwordHash` is scrypt, `tokenHash`/`keyHash` are sha256; no plaintext secret
|
|
102
|
+
* is ever a field here. `nextSeq` is the feed’s allocator, kept on the group so
|
|
103
|
+
* sequence numbers survive a restart.
|
|
104
|
+
*
|
|
105
|
+
* @typedef {object} GroupRecord
|
|
106
|
+
* @property {string} groupId
|
|
107
|
+
* @property {string} name
|
|
108
|
+
* @property {string} topic
|
|
109
|
+
* @property {number} createdAt - epoch ms.
|
|
110
|
+
* @property {string} joinPolicy - `invite` or `password`.
|
|
111
|
+
* @property {string} ownerMemberId - which member row is the owner.
|
|
112
|
+
* @property {string} passwordHash - `scrypt$N$salt$hash`.
|
|
113
|
+
* @property {object} settings - per-group overrides of the store defaults.
|
|
114
|
+
* @property {Array<{tokenHash: string, label?: string, createdAt: number, expiresAt: number, maxUses: number, uses: number, revoked: boolean}>} invites
|
|
115
|
+
* @property {GroupMemberRecord[]} members
|
|
116
|
+
* @property {GroupFeedEntry[]} feed
|
|
117
|
+
* @property {number} nextSeq
|
|
118
|
+
* @property {GroupRequestRecord[]} [requests]
|
|
119
|
+
* @property {boolean} [archived]
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
123
|
+
import {
|
|
124
|
+
chmodSync,
|
|
125
|
+
closeSync,
|
|
126
|
+
fsyncSync,
|
|
127
|
+
mkdirSync,
|
|
128
|
+
openSync,
|
|
129
|
+
readFileSync,
|
|
130
|
+
renameSync,
|
|
131
|
+
unlinkSync,
|
|
132
|
+
writeSync,
|
|
133
|
+
} from 'node:fs';
|
|
134
|
+
import { join as joinPath } from 'node:path';
|
|
135
|
+
|
|
136
|
+
import { buildLink, buildPrompt, randomPassword, randomToken } from './invite.js';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* scrypt cost parameter. 2^14 = 16384, the value the contract pins.
|
|
140
|
+
*
|
|
141
|
+
* The tradeoff: scrypt's cost is N*r*128 bytes and N*r*p work. At N=2^14, r=8,
|
|
142
|
+
* p=1 a single hash costs ~16 MiB and ~50 ms here, which is cheap enough to run
|
|
143
|
+
* on the request path of a `join` (once per member) and expensive enough that an
|
|
144
|
+
* attacker holding a stolen `groups.json` cannot brute-force a human-chosen
|
|
145
|
+
* group password at scale. Raising N scales both sides; 2^14 is the point where
|
|
146
|
+
* a login-shaped operation stays under a tenth of a second on the slowest
|
|
147
|
+
* machine we support. The parameter is stored *in* the hash string so a future
|
|
148
|
+
* bump stays verifiable: old hashes keep their old N, and `verifyPassword`
|
|
149
|
+
* reports `needsRehash` instead of silently accepting a weaker hash forever.
|
|
150
|
+
*/
|
|
151
|
+
export const SCRYPT_N = 1 << 14;
|
|
152
|
+
|
|
153
|
+
/** scrypt block size. Fixed at the value Node's own defaults use. */
|
|
154
|
+
export const SCRYPT_R = 8;
|
|
155
|
+
|
|
156
|
+
/** scrypt parallelization. 1: parallelism would only help an attacker with many cores. */
|
|
157
|
+
export const SCRYPT_P = 1;
|
|
158
|
+
|
|
159
|
+
/** Salt length in bytes. 16 bytes = 128 bits, per the contract. */
|
|
160
|
+
export const SCRYPT_SALT_BYTES = 16;
|
|
161
|
+
|
|
162
|
+
/** Derived-key length in bytes (256 bits). */
|
|
163
|
+
export const SCRYPT_KEY_BYTES = 32;
|
|
164
|
+
|
|
165
|
+
/** scrypt's memory ceiling for the hash call; generous so a bigger N still runs. */
|
|
166
|
+
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Digest of a string no credential is generated from, used as the "no such
|
|
170
|
+
* member" comparison target. See the `dummyKeyHash()` helper for why this is a
|
|
171
|
+
* constant rather than a random value.
|
|
172
|
+
*/
|
|
173
|
+
const DUMMY_KEY_HASH = createHash('sha256').update('dsh-a2a:no-such-credential', 'utf8').digest('hex');
|
|
174
|
+
|
|
175
|
+
/** How many entries `join` hands back so a new member can read the recent context. */
|
|
176
|
+
export const FEED_TAIL_LIMIT = 20;
|
|
177
|
+
|
|
178
|
+
/** Default page size for `read`, and its ceiling. */
|
|
179
|
+
export const READ_LIMIT_DEFAULT = 100;
|
|
180
|
+
export const READ_LIMIT_MAX = 500;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Ceiling on a serialized `payload`.
|
|
184
|
+
*
|
|
185
|
+
* A group feed carries conversation plus structured extras (a task id, a file
|
|
186
|
+
* path). Anything larger belongs in a file the message points at, not in a state
|
|
187
|
+
* file that is rewritten in full on every post: an unbounded payload turns one
|
|
188
|
+
* 3 MiB attachment into "every subsequent post costs 3 MiB of disk".
|
|
189
|
+
*/
|
|
190
|
+
export const MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
191
|
+
|
|
192
|
+
/** A member is shown as online if it authenticated this recently. */
|
|
193
|
+
export const ONLINE_WINDOW_MS = 120_000;
|
|
194
|
+
|
|
195
|
+
/** The only message kinds the MVP sends. A fourth kind means the scope changed. */
|
|
196
|
+
export const DM_KIND_INVITE = 'group-invite';
|
|
197
|
+
export const DM_KIND_JOIN_NOTICE = 'join-notice';
|
|
198
|
+
export const DM_KIND_WELCOME = 'welcome';
|
|
199
|
+
|
|
200
|
+
/** Message kinds the store will accept. */
|
|
201
|
+
const DM_KINDS = new Set([DM_KIND_INVITE, DM_KIND_JOIN_NOTICE, DM_KIND_WELCOME]);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* How long an undelivered message is kept.
|
|
205
|
+
*
|
|
206
|
+
* Seven days, matching the default invite lifetime: a link that has expired cannot be redeemed, so
|
|
207
|
+
* keeping the envelope longer would only deliver a dead invitation and teach the recipient to
|
|
208
|
+
* distrust the next one.
|
|
209
|
+
*/
|
|
210
|
+
export const DM_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
211
|
+
|
|
212
|
+
/** Cap on stored messages, oldest undelivered dropped first. */
|
|
213
|
+
export const MAX_DIRECT_MESSAGES = 2000;
|
|
214
|
+
|
|
215
|
+
/** Most messages one read returns. */
|
|
216
|
+
export const MAX_DM_PAGE = 50;
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
/** Hard structural caps, so a hostile or buggy caller cannot grow one file forever. */
|
|
220
|
+
export const MAX_GROUPS = 200;
|
|
221
|
+
export const MAX_MEMBERS_PER_GROUP = 200;
|
|
222
|
+
export const MAX_INVITES_PER_GROUP = 500;
|
|
223
|
+
export const MAX_NAME_CHARS = 120;
|
|
224
|
+
export const MAX_TOPIC_CHARS = 2000;
|
|
225
|
+
export const MAX_DISPLAY_NAME_CHARS = 64;
|
|
226
|
+
export const MAX_ENDPOINT_CHARS = 512;
|
|
227
|
+
export const MAX_LABEL_CHARS = 120;
|
|
228
|
+
|
|
229
|
+
/** Longest `roleAbout` (one sentence about what someone does in a group). */
|
|
230
|
+
export const MAX_ROLE_ABOUT_CHARS = 120;
|
|
231
|
+
|
|
232
|
+
/** Most tags one member may carry in one group. */
|
|
233
|
+
export const MAX_TAGS = 5;
|
|
234
|
+
|
|
235
|
+
/** Longest single tag. */
|
|
236
|
+
export const MAX_TAG_CHARS = 16;
|
|
237
|
+
|
|
238
|
+
/** Default page size for the channel directory. */
|
|
239
|
+
export const DIRECTORY_DEFAULT_LIMIT = 20;
|
|
240
|
+
|
|
241
|
+
/** Largest page a directory caller may request, so one call cannot dump everything. */
|
|
242
|
+
export const DIRECTORY_MAX_LIMIT = 100;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How long a join request stays answerable, in milliseconds.
|
|
246
|
+
*
|
|
247
|
+
* An application is a transient intent, not a standing invitation: a queue that
|
|
248
|
+
* stays open forever lets a request approved months ago be redeemed from a machine
|
|
249
|
+
* nobody remembers. Seven days matches the default invite lifetime, so the two
|
|
250
|
+
* halves of "ask, then get in" expire together.
|
|
251
|
+
*/
|
|
252
|
+
export const REQUEST_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
253
|
+
|
|
254
|
+
/** Most unanswered requests one group may hold, so a spammer cannot grow the file. */
|
|
255
|
+
export const MAX_PENDING_REQUESTS = 50;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The sentinel that means "everybody in the group".
|
|
259
|
+
*
|
|
260
|
+
* A string rather than a boolean flag because a post carries ONE list of addressees:
|
|
261
|
+
* `["@all"]` and `["m-1","m-2"]` are the same field, so a reader never has to
|
|
262
|
+
* combine two things to know who was addressed.
|
|
263
|
+
*/
|
|
264
|
+
export const MENTION_ALL = '@all';
|
|
265
|
+
|
|
266
|
+
/** Most members one post may address, so a list cannot grow without bound. */
|
|
267
|
+
export const MAX_MENTIONS_PER_POST = 200;
|
|
268
|
+
|
|
269
|
+
/** Longest free-text reason on a join request. */
|
|
270
|
+
export const MAX_REQUEST_REASON_CHARS = 200;
|
|
271
|
+
|
|
272
|
+
/** Stable error codes. The A2A layer maps them onto JSON-RPC `data.code`. */
|
|
273
|
+
export const GROUP_ERROR_CODES = Object.freeze([
|
|
274
|
+
'bad_argument',
|
|
275
|
+
'unknown_group',
|
|
276
|
+
'not_member',
|
|
277
|
+
'bad_password',
|
|
278
|
+
'invite_invalid',
|
|
279
|
+
'invite_expired',
|
|
280
|
+
'invite_exhausted',
|
|
281
|
+
'invite_revoked',
|
|
282
|
+
'rate_limited',
|
|
283
|
+
'too_long',
|
|
284
|
+
'empty_message',
|
|
285
|
+
'group_full',
|
|
286
|
+
'group_archived',
|
|
287
|
+
'not_owner',
|
|
288
|
+
'missing_gateway',
|
|
289
|
+
'storage_unreadable',
|
|
290
|
+
'too_many_attempts',
|
|
291
|
+
// Join requests. Distinct codes because each one is a different thing for a
|
|
292
|
+
// caller to do about it: apply again, wait, or stop asking.
|
|
293
|
+
'request_invalid',
|
|
294
|
+
'request_expired',
|
|
295
|
+
'request_pending',
|
|
296
|
+
'request_not_found',
|
|
297
|
+
'request_rejected',
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A group-layer failure with a stable, machine-readable code.
|
|
302
|
+
*
|
|
303
|
+
* `message` is written for a human reading a tool result (Chinese, actionable)
|
|
304
|
+
* and never contains a secret: the whole point of the error surface is that a
|
|
305
|
+
* caller can log it safely.
|
|
306
|
+
*/
|
|
307
|
+
export class GroupError extends Error {
|
|
308
|
+
/**
|
|
309
|
+
* @param {string} code one of {@link GROUP_ERROR_CODES}
|
|
310
|
+
* @param {string} message human-readable, secret-free
|
|
311
|
+
* @param {object} [details] structured extras, secret-free
|
|
312
|
+
*/
|
|
313
|
+
constructor(code, message, details) {
|
|
314
|
+
super(message);
|
|
315
|
+
this.name = 'GroupError';
|
|
316
|
+
this.code = code;
|
|
317
|
+
this.details = details ?? {};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Compare two strings without leaking where they differ.
|
|
323
|
+
*
|
|
324
|
+
* `timingSafeEqual` throws on unequal lengths, so the length check comes first
|
|
325
|
+
* and returns `false` rather than throwing: an auth helper that throws on a
|
|
326
|
+
* malformed credential is an auth helper somebody will wrap in a try/catch that
|
|
327
|
+
* returns `true` by accident. Callers here always pass fixed-width digests, so
|
|
328
|
+
* the early length return is not reachable in the auth path — it only protects
|
|
329
|
+
* the function against misuse.
|
|
330
|
+
*
|
|
331
|
+
* @param {unknown} left
|
|
332
|
+
* @param {unknown} right
|
|
333
|
+
* @returns {boolean}
|
|
334
|
+
*/
|
|
335
|
+
export function constantTimeEquals(left, right) {
|
|
336
|
+
if (typeof left !== 'string' || typeof right !== 'string') return false;
|
|
337
|
+
const a = Buffer.from(left, 'utf8');
|
|
338
|
+
const b = Buffer.from(right, 'utf8');
|
|
339
|
+
if (a.length !== b.length) return false;
|
|
340
|
+
return timingSafeEqual(a, b);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* sha256 of a secret, as lowercase hex.
|
|
345
|
+
*
|
|
346
|
+
* Tokens and member keys are hashed rather than scrypt-hashed on purpose: they
|
|
347
|
+
* are generated with 192-256 bits of entropy, so there is nothing to brute-force
|
|
348
|
+
* and the host needs the lookup to be fast (every request authenticates). A
|
|
349
|
+
* password is the opposite case — human-chosen, low entropy — which is why it
|
|
350
|
+
* alone gets scrypt.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} value
|
|
353
|
+
* @returns {string} 64 hex characters
|
|
354
|
+
*/
|
|
355
|
+
export function sha256Hex(value) {
|
|
356
|
+
return createHash('sha256').update(String(value), 'utf8').digest('hex');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Hash a group password into the stored form `scrypt$N$saltHex$hashHex`.
|
|
361
|
+
*
|
|
362
|
+
* The parameter is embedded so the format is self-describing: an operator can
|
|
363
|
+
* read `groups.json`, see the cost, and know what it was hashed with without a
|
|
364
|
+
* version lookup. A random salt per hash means two groups with the same password
|
|
365
|
+
* produce different records — no cross-group rainbow table, and no "this person
|
|
366
|
+
* reused their password" signal in the file.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} password plaintext (never logged, never stored)
|
|
369
|
+
* @param {object} [options]
|
|
370
|
+
* @param {number} [options.N] cost parameter, default {@link SCRYPT_N}
|
|
371
|
+
* @param {Buffer} [options.salt] explicit salt, for tests only
|
|
372
|
+
* @returns {string} the stored hash string
|
|
373
|
+
*/
|
|
374
|
+
export function hashPassword(password, options = {}) {
|
|
375
|
+
if (typeof password !== 'string' || password === '') {
|
|
376
|
+
throw new GroupError('bad_argument', '群密码不能为空。');
|
|
377
|
+
}
|
|
378
|
+
const N = Number.isInteger(options.N) && options.N > 1 ? options.N : SCRYPT_N;
|
|
379
|
+
const salt = options.salt instanceof Buffer ? options.salt : randomBytes(SCRYPT_SALT_BYTES);
|
|
380
|
+
const derived = scryptSync(password, salt, SCRYPT_KEY_BYTES, {
|
|
381
|
+
N,
|
|
382
|
+
r: SCRYPT_R,
|
|
383
|
+
p: SCRYPT_P,
|
|
384
|
+
maxmem: SCRYPT_MAXMEM,
|
|
385
|
+
});
|
|
386
|
+
return `scrypt$${N}$${salt.toString('hex')}$${derived.toString('hex')}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Check a password against a stored hash, without throwing on malformed input.
|
|
391
|
+
*
|
|
392
|
+
* Returns an object instead of a boolean so the caller can distinguish "wrong
|
|
393
|
+
* password" (a normal user error) from "this record is not a hash we can read"
|
|
394
|
+
* (an operator problem) without parsing an exception. Both are failures; only
|
|
395
|
+
* the log line differs.
|
|
396
|
+
*
|
|
397
|
+
* @param {string} password
|
|
398
|
+
* @param {string} stored `scrypt$N$salt$hash`
|
|
399
|
+
* @returns {{ok: boolean, reason?: string, needsRehash?: boolean}}
|
|
400
|
+
*/
|
|
401
|
+
export function verifyPassword(password, stored) {
|
|
402
|
+
if (typeof password !== 'string' || password === '') return { ok: false, reason: 'empty' };
|
|
403
|
+
const parts = typeof stored === 'string' ? stored.split('$') : [];
|
|
404
|
+
if (parts.length !== 4 || parts[0] !== 'scrypt') return { ok: false, reason: 'malformed' };
|
|
405
|
+
const N = Number(parts[1]);
|
|
406
|
+
if (!Number.isInteger(N) || N < 2 || N > 1 << 24) return { ok: false, reason: 'malformed' };
|
|
407
|
+
const salt = Buffer.from(parts[2], 'hex');
|
|
408
|
+
const expected = Buffer.from(parts[3], 'hex');
|
|
409
|
+
if (salt.length < 8 || expected.length < 16) return { ok: false, reason: 'malformed' };
|
|
410
|
+
|
|
411
|
+
let derived;
|
|
412
|
+
try {
|
|
413
|
+
derived = scryptSync(password, salt, expected.length, {
|
|
414
|
+
N,
|
|
415
|
+
r: SCRYPT_R,
|
|
416
|
+
p: SCRYPT_P,
|
|
417
|
+
maxmem: SCRYPT_MAXMEM,
|
|
418
|
+
});
|
|
419
|
+
} catch {
|
|
420
|
+
// A cost the machine cannot pay is a configuration failure, not a valid login.
|
|
421
|
+
return { ok: false, reason: 'error' };
|
|
422
|
+
}
|
|
423
|
+
// Buffers are the same length by construction, so this is a pure content compare.
|
|
424
|
+
const ok = timingSafeEqual(derived, expected);
|
|
425
|
+
return ok ? { ok: true, needsRehash: N < SCRYPT_N } : { ok: false, reason: 'mismatch' };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The group store: one JSON file, one in-memory state, synchronous mutations.
|
|
430
|
+
*
|
|
431
|
+
* Writes are synchronous and atomic (temp file + `rename`) rather than debounced:
|
|
432
|
+
* a group's durable facts are *credentials* — a `join` that returns a member key
|
|
433
|
+
* which was never written to disk is a member that disappears on restart, and a
|
|
434
|
+
* debounced write is exactly the window where that happens. The file is small and
|
|
435
|
+
* mutations are human-paced, so the cost is acceptable.
|
|
436
|
+
*
|
|
437
|
+
* @param {object} options
|
|
438
|
+
* @param {string} [options.home] directory holding `groups.json`
|
|
439
|
+
* @param {object} [options.logger] `{info,warn,error}` (optional)
|
|
440
|
+
* @param {() => number} [options.clock] epoch-ms source; injected so tests can travel in time
|
|
441
|
+
* @param {object} [options.config] normalized plugin config (or a flat groups config)
|
|
442
|
+
* @param {(a: unknown, b: unknown) => boolean} [options.compare] secret comparator; defaults to
|
|
443
|
+
* {@link constantTimeEquals}. Injected so a test (or an audit harness) can observe that every
|
|
444
|
+
* credential check really goes through a constant-time path, without reaching into module state.
|
|
445
|
+
* @returns {object} the store API described in CONTRACT §2.6
|
|
446
|
+
*/
|
|
447
|
+
export function createGroupStore({ home, logger, clock, config, compare } = {}) {
|
|
448
|
+
const log = createLogger(logger);
|
|
449
|
+
const now = typeof clock === 'function' ? clock : Date.now;
|
|
450
|
+
const compareSecrets = typeof compare === 'function' ? compare : constantTimeEquals;
|
|
451
|
+
const settings = readGroupConfig(config);
|
|
452
|
+
const directory = typeof home === 'string' && home !== '' ? home : resolveDirectory(config);
|
|
453
|
+
const file = joinPath(directory, 'groups.json');
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Objects that came out of `authenticate()`, mapped to the member id they were
|
|
457
|
+
* authenticated *as*.
|
|
458
|
+
*
|
|
459
|
+
* A WeakMap instead of a WeakSet, and frozen views, because a verified object
|
|
460
|
+
* that the caller can still edit is not a verified identity: with a WeakSet,
|
|
461
|
+
* `member.personId = victimId` on your own view would make `post()` act as the
|
|
462
|
+
* victim. Binding the id inside the WeakMap means the identity used for the
|
|
463
|
+
* lookup is the one captured at authentication time, and freezing the view
|
|
464
|
+
* stops the mutation from happening in the first place.
|
|
465
|
+
*/
|
|
466
|
+
const authenticated = new WeakMap();
|
|
467
|
+
/** In-memory token buckets, keyed `groupId\u0000personId`. Never persisted: see `consume`. */
|
|
468
|
+
const buckets = new Map();
|
|
469
|
+
/**
|
|
470
|
+
* Wrong-password timestamps, keyed by group id.
|
|
471
|
+
*
|
|
472
|
+
* In memory on purpose: persisting failed attempts would turn every typo into a
|
|
473
|
+
* disk write, and a restart is not something an attacker can trigger, so the
|
|
474
|
+
* reset-on-restart behaviour costs nothing.
|
|
475
|
+
*/
|
|
476
|
+
/** Live subscribers, keyed by group id. */
|
|
477
|
+
const subscribers = new Map();
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* personId → the person's own profile.
|
|
481
|
+
*
|
|
482
|
+
* Keyed by `personId`, which identifies a person across every group on this host. One row
|
|
483
|
+
* per person, so "what I do" is stored once rather than repeated per membership.
|
|
484
|
+
*
|
|
485
|
+
* Declared BEFORE `loadState()` because the load hydrates it: a `const` sits in the temporal
|
|
486
|
+
* dead zone until its own line runs, and touching it earlier throws.
|
|
487
|
+
*/
|
|
488
|
+
const persons = new Map();
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Undelivered and recently delivered one-to-one messages, oldest first.
|
|
492
|
+
*
|
|
493
|
+
* An array rather than a per-person map because the two questions asked of it are "what is waiting
|
|
494
|
+
* for this person" and "did this one get delivered", and both scan. The cap keeps a runaway
|
|
495
|
+
* sender from growing the file without bound.
|
|
496
|
+
*/
|
|
497
|
+
let directMessages = [];
|
|
498
|
+
|
|
499
|
+
let state = loadState();
|
|
500
|
+
// Populated once at startup: a profile read is far more common than a profile write.
|
|
501
|
+
// Undelivered messages survive a restart: the whole point of a queue is that a message is not
|
|
502
|
+
// lost because the host bounced.
|
|
503
|
+
directMessages = Array.isArray(state.directMessages) ? state.directMessages : [];
|
|
504
|
+
for (const entry of state.persons ?? []) {
|
|
505
|
+
persons.set(entry.personId, {
|
|
506
|
+
personId: entry.personId,
|
|
507
|
+
displayName: typeof entry.displayName === 'string' ? entry.displayName : '',
|
|
508
|
+
headline: typeof entry.headline === 'string' ? entry.headline : '',
|
|
509
|
+
tags: Array.isArray(entry.tags) ? entry.tags.filter((tag) => typeof tag === 'string') : [],
|
|
510
|
+
endpoint: typeof entry.endpoint === 'string' ? entry.endpoint : '',
|
|
511
|
+
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : 0,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
let dirty = false;
|
|
515
|
+
|
|
516
|
+
// -------------------------------------------------------------------------
|
|
517
|
+
// Public API
|
|
518
|
+
// -------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Create a group and its owner.
|
|
522
|
+
*
|
|
523
|
+
* A group has NO password. Admission is one of two things and only these two:
|
|
524
|
+
*
|
|
525
|
+
* - an invite token, which the owner mints and can revoke at any time;
|
|
526
|
+
* - an approved join request, which the owner answers one person at a time.
|
|
527
|
+
*
|
|
528
|
+
* A shared password was dropped deliberately. It solved "the link got forwarded"
|
|
529
|
+
* by adding a second secret that has to be delivered out of band, while the
|
|
530
|
+
* actual control an owner wants — revoke exactly that link — was already there
|
|
531
|
+
* and is strictly sharper: a password change invalidates every pending invite at
|
|
532
|
+
* once, while a revocation hits only the link that leaked.
|
|
533
|
+
*
|
|
534
|
+
* @param {object} input
|
|
535
|
+
* @param {string} [input.name] group display name
|
|
536
|
+
* @param {string} [input.about]
|
|
537
|
+
* @param {string[]} [input.tags]
|
|
538
|
+
* @param {string} [input.topic]
|
|
539
|
+
* @param {string} [input.ownerDisplayName]
|
|
540
|
+
* @param {object} [input.settings] per-group overrides of the store defaults
|
|
541
|
+
* @param {string} [input.personId] the id this person already holds in another group, so
|
|
542
|
+
* creating a second group does not mint a second identity for the same human
|
|
543
|
+
* @returns {{group: object, ownerKey: string}}
|
|
544
|
+
*/
|
|
545
|
+
function create(input = {}) {
|
|
546
|
+
const name = requireText(input.name, 'name', MAX_NAME_CHARS, '群名');
|
|
547
|
+
const topic = optionalText(input.topic, MAX_TOPIC_CHARS, '群主题') ?? '';
|
|
548
|
+
const ownerDisplayName = optionalText(input.ownerDisplayName, MAX_DISPLAY_NAME_CHARS, '昵称') ?? '群主';
|
|
549
|
+
|
|
550
|
+
if (state.groups.length >= MAX_GROUPS) {
|
|
551
|
+
throw new GroupError('group_full', `本机群数量已达上限(${MAX_GROUPS}),请先退出不用的群。`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const groupId = allocateGroupId();
|
|
555
|
+
const createdAt = now();
|
|
556
|
+
const ownerPersonId = allocatePersonId(input.personId);
|
|
557
|
+
const ownerKey = randomToken(32);
|
|
558
|
+
|
|
559
|
+
const group = {
|
|
560
|
+
groupId,
|
|
561
|
+
name,
|
|
562
|
+
topic,
|
|
563
|
+
createdAt,
|
|
564
|
+
ownerPersonId,
|
|
565
|
+
// No `passwordHash`: see the doc comment on `create`. Admission is an invite
|
|
566
|
+
// token or an approved request, both of which are revocable per person.
|
|
567
|
+
settings: mergeSettings(input.settings),
|
|
568
|
+
invites: [],
|
|
569
|
+
// Applications to join, answered by the owner. Kept on the group record so a
|
|
570
|
+
// restart does not silently drop somebody's unanswered request.
|
|
571
|
+
requests: [],
|
|
572
|
+
members: [
|
|
573
|
+
{
|
|
574
|
+
personId: ownerPersonId,
|
|
575
|
+
displayName: ownerDisplayName,
|
|
576
|
+
role: 'owner',
|
|
577
|
+
keyHash: sha256Hex(ownerKey),
|
|
578
|
+
joinedAt: createdAt,
|
|
579
|
+
lastSeenAt: createdAt,
|
|
580
|
+
// The owner is a member like any other, and creating a group is how they join it. An
|
|
581
|
+
// address here keeps the one person guaranteed to be present from showing a blank
|
|
582
|
+
// endpoint, which reads as a broken feature rather than an unconfigured host.
|
|
583
|
+
endpoint: optionalText(input.endpoint, MAX_ENDPOINT_CHARS, 'endpoint') ?? undefined,
|
|
584
|
+
// Layer A: what this person is IN THIS GROUP. Deliberately separate from
|
|
585
|
+
// `role`: `role` is a permission bit (owner may mint invites), while
|
|
586
|
+
// these are self-described identity. Folding them together would make
|
|
587
|
+
// "I am the PM" mean "I can kick people".
|
|
588
|
+
roleAbout: optionalText(input.about, MAX_ROLE_ABOUT_CHARS, '角色说明') ?? undefined,
|
|
589
|
+
tags: normalizeTags(input.tags),
|
|
590
|
+
profileUpdatedAt: createdAt,
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
feed: [],
|
|
594
|
+
nextSeq: 1,
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
state.groups.push(group);
|
|
598
|
+
appendSystem(group, `${ownerDisplayName} 创建了群`);
|
|
599
|
+
persist();
|
|
600
|
+
|
|
601
|
+
log.info('group created', { groupId, name });
|
|
602
|
+
return {
|
|
603
|
+
group: publicGroup(group),
|
|
604
|
+
ownerKey,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Mint an invite.
|
|
610
|
+
*
|
|
611
|
+
* Authorized by the OWNER credential only. There is no shared password any more,
|
|
612
|
+
* so "any member can mint an invite" is no longer expressible — minting is an
|
|
613
|
+
* owner act, and the owner is the one who can revoke.
|
|
614
|
+
*
|
|
615
|
+
* The invite has exactly one bound: an expiry. Deliberately no use counter — an
|
|
616
|
+
* owner who wants to stop a link revokes it, which is precise; a counter only
|
|
617
|
+
* makes the sixth colleague wait for a fresh link while the leak it was meant to
|
|
618
|
+
* contain continues to work.
|
|
619
|
+
* caller cannot tell which factor failed.
|
|
620
|
+
*
|
|
621
|
+
* `invites` is a third secret with its own lifecycle: the token itself is
|
|
622
|
+
* returned once, only its sha256 is kept, and use/expiry/revocation are
|
|
623
|
+
* enforced at `join`. That is what makes a forwarded link a bounded problem
|
|
624
|
+
* instead of a permanent group membership.
|
|
625
|
+
*
|
|
626
|
+
* @param {object} input
|
|
627
|
+
* @param {string} [input.groupId]
|
|
628
|
+
* @param {string} [input.ownerKey] owner credential; REQUIRED — only the owner
|
|
629
|
+
* may mint, because only the owner may later revoke
|
|
630
|
+
* @param {string} [input.label] who this invite is for, for later revocation
|
|
631
|
+
* @param {number|null} [input.expiresInHours] hours until it expires; pass
|
|
632
|
+
* `null` explicitly for a link that never expires. Omitted means the
|
|
633
|
+
* configured default (7 days), NOT "never" — a long-lived public link has to be
|
|
634
|
+
* asked for on purpose.
|
|
635
|
+
* @param {string} [input.gatewayBase] gateway base to put in the link (default: config)
|
|
636
|
+
* @param {string} [input.agent] agent name inside that gateway (default: config)
|
|
637
|
+
* @returns {{token: string, link: string, prompt: string, expiresAt: number|null,
|
|
638
|
+
* inviteId: string, groupId: string, label: string|undefined}}
|
|
639
|
+
*/
|
|
640
|
+
function invite(input = {}) {
|
|
641
|
+
const group = requireGroup(input.groupId);
|
|
642
|
+
const label = optionalText(input.label, MAX_LABEL_CHARS, '备注') ?? undefined;
|
|
643
|
+
|
|
644
|
+
// Owner-only, unconditionally. The owner credential is what makes minting an
|
|
645
|
+
// act of the person who can also revoke; allowing any member to mint would let
|
|
646
|
+
// somebody create links the owner cannot attribute.
|
|
647
|
+
const owner = group.members.find((entry) => entry.personId === group.ownerPersonId);
|
|
648
|
+
if (owner === undefined) {
|
|
649
|
+
throw new GroupError('not_owner', '这个群现在没有群主,无法生成邀请。');
|
|
650
|
+
}
|
|
651
|
+
if (typeof input.ownerKey !== 'string' || input.ownerKey === ''
|
|
652
|
+
|| !compareSecrets(sha256Hex(input.ownerKey), owner.keyHash)) {
|
|
653
|
+
throw new GroupError('not_owner', '只有群主可以生成邀请。');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (group.invites.length >= MAX_INVITES_PER_GROUP) {
|
|
657
|
+
throw new GroupError('group_full', `该群的邀请数量已达上限(${MAX_INVITES_PER_GROUP}),请撤销一些旧邀请。`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Resolve the link target before minting anything: a missing gateway is an
|
|
661
|
+
// operator misconfiguration, and failing after the insert would leave an
|
|
662
|
+
// orphan invite in the file that nobody can ever use.
|
|
663
|
+
//
|
|
664
|
+
// The agent name is the HOST, not the group. One DSH instance registered once
|
|
665
|
+
// behind the gateway serves every group it hosts, and the group is named in
|
|
666
|
+
// the payload — which is why the link always carries `g=<groupId>` as well as
|
|
667
|
+
// `agent=`. An earlier version fell back to `agent = groupId`, and that turned
|
|
668
|
+
// "create a group" into "create a group AND add an entry to the gateway's
|
|
669
|
+
// resources.yaml": an operator step per group, for no reason at all.
|
|
670
|
+
const gatewayBase = optionalText(input.gatewayBase, 2048, '网关地址') ?? settings.gatewayBase;
|
|
671
|
+
const agent = optionalText(input.agent, 200, 'agent 名') ?? settings.agentName ?? undefined;
|
|
672
|
+
if (gatewayBase === undefined || gatewayBase === '') {
|
|
673
|
+
throw new GroupError(
|
|
674
|
+
'missing_gateway',
|
|
675
|
+
'还没有配置群网关:请先在插件设置里填 groups.gatewayBase(AISIX 网关基址)与 groups.agentName(本实例在网关里的 agent 名,所有群共用),否则邀请链接里的地址无法被同事访问。',
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if (agent === undefined || agent === '') {
|
|
679
|
+
throw new GroupError(
|
|
680
|
+
'missing_agent_name',
|
|
681
|
+
'还没有配置本实例的 agent 名:请在插件设置里填 groups.agentName(例如 "agent-person-zhangsan")。'
|
|
682
|
+
+ '它是本实例在网关里的名字,建一次即可,所有群共用,不需要每个群单独配置。'
|
|
683
|
+
+ '注意区分两个命名空间:群宿主叫 agent-group-host(一个入口承载所有群,因为群是数据、'
|
|
684
|
+
+ '靠报文里的 groupId 区分),成员实例叫 agent-person-<谁>(一个人一个入口,因为每个人'
|
|
685
|
+
+ '是不同机器上的进程,网关的一条路径只能转发到一个地址)。',
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Expiry is the ONLY bound on an invite. `null` means "never"; omitting the
|
|
690
|
+
// field means the configured default, and the two must not be confused — a
|
|
691
|
+
// caller who forgot to pass a value must not accidentally mint a permanent
|
|
692
|
+
// public link. `Object.hasOwn` is what tells them apart.
|
|
693
|
+
const createdAt = now();
|
|
694
|
+
const expiresAt = resolveInviteExpiry(input, settings, createdAt);
|
|
695
|
+
const token = `inv_${randomToken(24)}`;
|
|
696
|
+
const inviteId = `i-${randomBytes(4).toString('hex')}`;
|
|
697
|
+
|
|
698
|
+
group.invites.push({
|
|
699
|
+
inviteId,
|
|
700
|
+
tokenHash: sha256Hex(token),
|
|
701
|
+
label,
|
|
702
|
+
createdAt,
|
|
703
|
+
// `null` = never expires. No use counter: see the doc comment above.
|
|
704
|
+
expiresAt,
|
|
705
|
+
revoked: false,
|
|
706
|
+
});
|
|
707
|
+
persist();
|
|
708
|
+
|
|
709
|
+
const link = buildLink({
|
|
710
|
+
groupId: group.groupId,
|
|
711
|
+
token,
|
|
712
|
+
gatewayBase,
|
|
713
|
+
agent,
|
|
714
|
+
name: group.name,
|
|
715
|
+
expiresAt: expiresAt === null ? undefined : expiresAt,
|
|
716
|
+
});
|
|
717
|
+
const prompt = buildPrompt({
|
|
718
|
+
name: group.name,
|
|
719
|
+
link,
|
|
720
|
+
expiresAt: expiresAt === null ? undefined : expiresAt,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
log.info('invite created', {
|
|
724
|
+
groupId: group.groupId,
|
|
725
|
+
inviteId,
|
|
726
|
+
token: redact(token),
|
|
727
|
+
expiresAt: expiresAt === null ? 'never' : new Date(expiresAt).toISOString(),
|
|
728
|
+
});
|
|
729
|
+
return { token, link, prompt, expiresAt, inviteId, groupId: group.groupId, label };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Redeem an invite.
|
|
734
|
+
*
|
|
735
|
+
* Order of checks is deliberate. The cheap, non-secret checks (does the group
|
|
736
|
+
* exist, is this token a known hash, is it expired/revoked/spent) run before
|
|
737
|
+
* scrypt, so that a flood of bogus tokens cannot make the host burn 50 ms of
|
|
738
|
+
* CPU per attempt. The information this reveals — "this token was once real" —
|
|
739
|
+
* is known to the invitee already and is not a group secret.
|
|
740
|
+
*
|
|
741
|
+
* The use counter increments only after *every* check passes, including the
|
|
742
|
+
* password: a typo must not consume one of five uses, or a forwarded link
|
|
743
|
+
* becomes unusable through no fault of the owner.
|
|
744
|
+
*
|
|
745
|
+
* @param {object} input
|
|
746
|
+
* @param {string} [input.groupId]
|
|
747
|
+
* @param {string} [input.personId]
|
|
748
|
+
* @param {string} [input.about]
|
|
749
|
+
* @param {string[]} [input.tags]
|
|
750
|
+
* @param {string} [input.token] invite token
|
|
751
|
+
* @param {string} [input.displayName]
|
|
752
|
+
* @param {string} [input.endpoint] optional callback address for the member
|
|
753
|
+
* @returns {{member: object, personKey: string, group: object, feedTail: object[]}}
|
|
754
|
+
*/
|
|
755
|
+
function join(input = {}) {
|
|
756
|
+
const group = requireGroup(input.groupId);
|
|
757
|
+
const displayName = optionalText(input.displayName, MAX_DISPLAY_NAME_CHARS, '昵称') ?? '成员';
|
|
758
|
+
const endpoint = optionalText(input.endpoint, MAX_ENDPOINT_CHARS, 'endpoint') ?? undefined;
|
|
759
|
+
const at = now();
|
|
760
|
+
|
|
761
|
+
if (group.members.length >= MAX_MEMBERS_PER_GROUP) {
|
|
762
|
+
throw new GroupError('group_full', `该群成员已达上限(${MAX_MEMBERS_PER_GROUP})。`);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// A token is the ONLY way in by this path. There is no password to fall back
|
|
766
|
+
// on, so the check is unconditional rather than gated on a join policy.
|
|
767
|
+
const token = optionalText(input.token, 2048, '邀请令牌');
|
|
768
|
+
if (token === undefined) {
|
|
769
|
+
throw new GroupError('invite_invalid', '缺少邀请令牌:入群需要凭群主给你的邀请链接。');
|
|
770
|
+
}
|
|
771
|
+
const usedInvite = findInvite(group, token);
|
|
772
|
+
if (usedInvite === undefined) {
|
|
773
|
+
throw new GroupError('invite_invalid', '邀请无效:这个令牌不存在,可能已被群主撤销或删除了。');
|
|
774
|
+
}
|
|
775
|
+
if (usedInvite.revoked === true) {
|
|
776
|
+
throw new GroupError('invite_revoked', '该邀请已被群主撤销,请让群主重新生成一个。', { inviteId: usedInvite.inviteId });
|
|
777
|
+
}
|
|
778
|
+
// `expiresAt === null` is a link minted as permanent on purpose.
|
|
779
|
+
if (usedInvite.expiresAt !== null && usedInvite.expiresAt <= at) {
|
|
780
|
+
throw new GroupError('invite_expired', '该邀请已过期,请让群主重新生成一个。', {
|
|
781
|
+
expiresAt: new Date(usedInvite.expiresAt).toISOString(),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const personKey = `mk_${randomToken(32)}`;
|
|
786
|
+
const member = {
|
|
787
|
+
// The caller may present an id it already holds from another group; reusing it is what
|
|
788
|
+
// makes this person the same person here.
|
|
789
|
+
personId: allocatePersonId(input.personId),
|
|
790
|
+
displayName,
|
|
791
|
+
role: 'member',
|
|
792
|
+
keyHash: sha256Hex(personKey),
|
|
793
|
+
joinedAt: at,
|
|
794
|
+
lastSeenAt: at,
|
|
795
|
+
endpoint,
|
|
796
|
+
// Layer A, self-described at join time. Optional: a colleague who just
|
|
797
|
+
// wants in should not be blocked by a form, and can set it later with
|
|
798
|
+
// `setProfile`.
|
|
799
|
+
roleAbout: optionalText(input.about, MAX_ROLE_ABOUT_CHARS, '角色说明') ?? undefined,
|
|
800
|
+
tags: normalizeTags(input.tags),
|
|
801
|
+
profileUpdatedAt: at,
|
|
802
|
+
};
|
|
803
|
+
group.members.push(member);
|
|
804
|
+
// Join is the only chance to learn a member's address, so it is kept on their profile as
|
|
805
|
+
// well as the membership row.
|
|
806
|
+
noteEndpoint(member.personId, endpoint);
|
|
807
|
+
appendSystem(group, `${displayName} 加入了群`);
|
|
808
|
+
persist();
|
|
809
|
+
|
|
810
|
+
log.info('member joined', {
|
|
811
|
+
groupId: group.groupId,
|
|
812
|
+
personId: member.personId,
|
|
813
|
+
inviteId: usedInvite?.inviteId,
|
|
814
|
+
personKey: redact(personKey),
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
member: publicMember(member, at),
|
|
819
|
+
personKey,
|
|
820
|
+
group: withFeedTail(group, at),
|
|
821
|
+
feedTail: group.feed.slice(-FEED_TAIL_LIMIT).map((entry) => publicEntry(entry)),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Resolve a member from its credential, or `undefined`.
|
|
827
|
+
*
|
|
828
|
+
* A wrong key, an unknown member id and an unknown group all flow through the
|
|
829
|
+
* same digest comparison and return `undefined`: the A2A layer must not be able
|
|
830
|
+
* to tell a caller whether a member id exists. The comparison is over 64-char
|
|
831
|
+
* sha256 hex, so it is fixed-width and constant-time even though the supplied
|
|
832
|
+
* key length varies.
|
|
833
|
+
*
|
|
834
|
+
* The returned object is registered in a WeakMap. That is what lets later calls
|
|
835
|
+
* (`post`, `read`, …) accept it without asking for the key again, while still
|
|
836
|
+
* refusing a hand-built `{personId}` object — the difference between "came out
|
|
837
|
+
* of authenticate()" and "was constructed by the caller" is unforgeable.
|
|
838
|
+
*
|
|
839
|
+
* @param {object} input
|
|
840
|
+
* @param {string} [input.groupId]
|
|
841
|
+
* @param {string} [input.personId]
|
|
842
|
+
* @param {string} [input.personKey]
|
|
843
|
+
* @returns {object|undefined} a public member view (never contains `keyHash`)
|
|
844
|
+
*/
|
|
845
|
+
function authenticate(input = {}) {
|
|
846
|
+
const group = findGroup(input.groupId);
|
|
847
|
+
const supplied = sha256Hex(typeof input.personKey === 'string' ? input.personKey : '');
|
|
848
|
+
if (group === undefined) {
|
|
849
|
+
// Compare against a dummy digest so an unknown group costs the same as a
|
|
850
|
+
// wrong key; a fast "no such group" path would leak group existence.
|
|
851
|
+
compareSecrets(supplied, dummyKeyHash());
|
|
852
|
+
return undefined;
|
|
853
|
+
}
|
|
854
|
+
const personId = typeof input.personId === 'string' ? input.personId : '';
|
|
855
|
+
const member = group.members.find((entry) => entry.personId === personId);
|
|
856
|
+
if (member === undefined) {
|
|
857
|
+
compareSecrets(supplied, dummyKeyHash());
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
// Fixed-width: both sides are 64-char hex, so the comparison leaks neither
|
|
861
|
+
// content nor length and a wrong key is indistinguishable from a wrong id.
|
|
862
|
+
if (!compareSecrets(supplied, member.keyHash)) return undefined;
|
|
863
|
+
|
|
864
|
+
const at = now();
|
|
865
|
+
member.lastSeenAt = at; // in-memory only; the next mutation or flush persists it
|
|
866
|
+
dirty = true;
|
|
867
|
+
const view = deepFreeze(publicMember(member, at));
|
|
868
|
+
authenticated.set(view, member.personId);
|
|
869
|
+
return view;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Append a message to the feed.
|
|
874
|
+
*
|
|
875
|
+
* Two limits, both checked before appending: message length (`maxPostChars`)
|
|
876
|
+
* and a per-member token bucket. The bucket is deliberately memory-only — a
|
|
877
|
+
* restart resets it, which is fine (restarts are rare and an attacker cannot
|
|
878
|
+
* trigger one), and persisting it would turn every chat message into a disk
|
|
879
|
+
* write of the rate-limit table.
|
|
880
|
+
*
|
|
881
|
+
* @param {object} input
|
|
882
|
+
* @param {string} [input.groupId]
|
|
883
|
+
* @param {object} [input.member] authenticated member (or `{personId, personKey}`)
|
|
884
|
+
* @param {string} [input.mentions]
|
|
885
|
+
* @param {string} [input.text]
|
|
886
|
+
* @param {object} [input.payload] structured payload; not interpreted here
|
|
887
|
+
* @returns {{seq: number, msgId: string, at: number, atIso: string}}
|
|
888
|
+
*/
|
|
889
|
+
function post(input = {}) {
|
|
890
|
+
const group = requireGroup(input.groupId);
|
|
891
|
+
const member = resolveMember(group, input.member);
|
|
892
|
+
const text = typeof input.text === 'string' ? input.text : '';
|
|
893
|
+
const payload = input.payload === undefined ? undefined : input.payload;
|
|
894
|
+
// Validate the payload on the way in, before anything mutates: a cyclic or
|
|
895
|
+
// BigInt payload would otherwise throw from JSON.stringify inside persist()
|
|
896
|
+
// and leave the feed grown in memory but the file unwritten.
|
|
897
|
+
validatePayload(payload);
|
|
898
|
+
|
|
899
|
+
if (text.trim() === '' && payload === undefined) {
|
|
900
|
+
throw new GroupError('empty_message', '发帖内容为空:请给 text 或 payload。');
|
|
901
|
+
}
|
|
902
|
+
const maxPostChars = group.settings.maxPostChars;
|
|
903
|
+
if (text.length > maxPostChars) {
|
|
904
|
+
throw new GroupError('too_long', `单条消息超过 ${maxPostChars} 字上限,请拆条发送或改用附件路径。`, {
|
|
905
|
+
length: text.length,
|
|
906
|
+
maxPostChars,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (group.settings?.archived === true) {
|
|
911
|
+
// An archived group is a record, not a place to talk. Refusing the write is the whole
|
|
912
|
+
// meaning of archive; allowing it would make the flag decorative.
|
|
913
|
+
throw new GroupError('group_archived', '这个群已归档,不能再发言。需要继续讨论请让群主恢复它。');
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
consume(group, member);
|
|
917
|
+
|
|
918
|
+
const at = now();
|
|
919
|
+
const seq = group.nextSeq;
|
|
920
|
+
group.nextSeq += 1;
|
|
921
|
+
const mentions = resolveMentions(group, input.mentions);
|
|
922
|
+
const entry = {
|
|
923
|
+
seq,
|
|
924
|
+
at,
|
|
925
|
+
msgId: randomUUID(),
|
|
926
|
+
kind: 'message',
|
|
927
|
+
from: { personId: member.personId, displayName: member.displayName },
|
|
928
|
+
text,
|
|
929
|
+
payload,
|
|
930
|
+
// Absent rather than an empty array when nobody was addressed: the distinction
|
|
931
|
+
// between "addressed nobody" and "addressed everyone" must survive the file, and
|
|
932
|
+
// an empty array would read as the former for both.
|
|
933
|
+
...(mentions.length === 0 ? {} : { mentions }),
|
|
934
|
+
};
|
|
935
|
+
group.feed.push(entry);
|
|
936
|
+
member.lastSeenAt = at;
|
|
937
|
+
persist();
|
|
938
|
+
notify(group, entry);
|
|
939
|
+
return { seq, msgId: entry.msgId, at, atIso: new Date(at).toISOString(), mentions };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Turn a caller's mention list into member ids, or the `@all` sentinel.
|
|
944
|
+
*
|
|
945
|
+
* Accepted forms, because a model writing "@李四" should not have to resolve the id
|
|
946
|
+
* itself:
|
|
947
|
+
*
|
|
948
|
+
* - `"@all"` / `"all"` / `"@所有人"` → `["@all"]`
|
|
949
|
+
* - a member id → kept if that member exists
|
|
950
|
+
* - a display name → resolved when exactly ONE member has that name
|
|
951
|
+
*
|
|
952
|
+
* An ambiguous display name is dropped rather than guessed: waking the wrong person is
|
|
953
|
+
* worse than not waking anyone, and the poster can retry with an id.
|
|
954
|
+
*
|
|
955
|
+
* @param {GroupRecord} group - internal group record.
|
|
956
|
+
* @param {unknown} raw - the caller's mention list.
|
|
957
|
+
* @returns {string[]} resolved ids, or `[MENTION_ALL]`; empty array for no mentions.
|
|
958
|
+
*/
|
|
959
|
+
function resolveMentions(group, raw) {
|
|
960
|
+
if (raw === undefined || raw === null) return [];
|
|
961
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
962
|
+
const out = [];
|
|
963
|
+
for (const item of list) {
|
|
964
|
+
if (typeof item !== 'string') continue;
|
|
965
|
+
const token = item.trim();
|
|
966
|
+
if (token === '') continue;
|
|
967
|
+
const lowered = token.toLowerCase();
|
|
968
|
+
if (lowered === MENTION_ALL || lowered === 'all' || token === '@所有人' || token === '所有人') {
|
|
969
|
+
// "everyone" subsumes any individual name in the same list: the post addressed
|
|
970
|
+
// the whole group, and keeping both would make the grading logic pick one.
|
|
971
|
+
return [MENTION_ALL];
|
|
972
|
+
}
|
|
973
|
+
const byId = group.members.find((member) => member.personId === token);
|
|
974
|
+
if (byId !== undefined) {
|
|
975
|
+
if (!out.includes(byId.personId)) out.push(byId.personId);
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const byName = group.members.filter((member) => member.displayName === token);
|
|
979
|
+
if (byName.length === 1) {
|
|
980
|
+
if (!out.includes(byName[0].personId)) out.push(byName[0].personId);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return out.slice(0, MAX_MENTIONS_PER_POST);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Incremental read: everything after `sinceSeq`, oldest first.
|
|
988
|
+
*
|
|
989
|
+
* `lastSeq` is the cursor the caller should store, and `truncated` says "there
|
|
990
|
+
* is more right now" so a subscriber can loop instead of waiting a full
|
|
991
|
+
* heartbeat. Neither is derived from `feed.length`: retention pruning removes
|
|
992
|
+
* from the front, and a length-derived cursor would silently rewind.
|
|
993
|
+
*
|
|
994
|
+
* @param {object} input
|
|
995
|
+
* @param {string} [input.groupId]
|
|
996
|
+
* @param {object} [input.member]
|
|
997
|
+
* @param {number} [input.sinceSeq]
|
|
998
|
+
* @param {number} [input.limit]
|
|
999
|
+
* @returns {{entries: object[], lastSeq: number, truncated: boolean}}
|
|
1000
|
+
*/
|
|
1001
|
+
function read(input = {}) {
|
|
1002
|
+
const group = requireGroup(input.groupId);
|
|
1003
|
+
const member = resolveMember(group, input.member);
|
|
1004
|
+
const sinceSeq = numberOf(input.sinceSeq, -Infinity);
|
|
1005
|
+
const limit = clampLimit(input.limit);
|
|
1006
|
+
const head = group.nextSeq - 1;
|
|
1007
|
+
const entries = group.feed.filter((entry) => entry.seq > sinceSeq).slice(0, limit).map(publicEntry);
|
|
1008
|
+
const lastSeq = entries.length > 0 ? entries[entries.length - 1].seq : head;
|
|
1009
|
+
member.lastSeenAt = now();
|
|
1010
|
+
dirty = true;
|
|
1011
|
+
return { entries, lastSeq, truncated: entries.length === limit && lastSeq < head };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Whether this host keeps the group.
|
|
1016
|
+
*
|
|
1017
|
+
* The `a2a_group_join` tool has to branch on this before it knows which side it
|
|
1018
|
+
* is on: a link for a group hosted here is joined in-process, a link for
|
|
1019
|
+
* somebody else's group is joined by calling their A2A endpoint. Answering it
|
|
1020
|
+
* needs no credential — knowing that a group id exists on this host is not a
|
|
1021
|
+
* secret, and refusing to answer would only turn a routing decision into a
|
|
1022
|
+
* failed join.
|
|
1023
|
+
*
|
|
1024
|
+
* @param {string} groupId
|
|
1025
|
+
* @returns {boolean}
|
|
1026
|
+
*/
|
|
1027
|
+
function has(groupId) {
|
|
1028
|
+
return findGroup(groupId) !== undefined;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* The newest feed entry, or `undefined` for an empty feed.
|
|
1033
|
+
*
|
|
1034
|
+
* For the group list ("last message" preview). Requires a member: the preview
|
|
1035
|
+
* is message text, which is exactly what the password protects, so an
|
|
1036
|
+
* anonymous caller gets `undefined` rather than a redacted string — a partial
|
|
1037
|
+
* preview is still a leak.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {object} input
|
|
1040
|
+
* @param {string} input.groupId
|
|
1041
|
+
* @param {object} input.member
|
|
1042
|
+
* @returns {object|undefined} public entry view
|
|
1043
|
+
*/
|
|
1044
|
+
/**
|
|
1045
|
+
* One member's own record in a group, without a credential.
|
|
1046
|
+
*
|
|
1047
|
+
* Exists for ONE caller inside this process: the layer B profile builder, which
|
|
1048
|
+
* needs to know what this instance said about itself in a group it hosts, and
|
|
1049
|
+
* runs while rendering an agent card. Doing that through `members()` would
|
|
1050
|
+
* require the credential, which the card builder deliberately does not hold, and
|
|
1051
|
+
* doing it over the network would put a round trip inside card rendering.
|
|
1052
|
+
*
|
|
1053
|
+
* This is safe to expose because it is not a lookup service: it returns a view
|
|
1054
|
+
* only for a `personId` the caller already knows, and it reveals nothing beyond
|
|
1055
|
+
* what `members()` would show to any member of that group. It cannot be used to
|
|
1056
|
+
* enumerate members (`members()` requires a credential for that) and it exposes
|
|
1057
|
+
* no key material.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {object} input
|
|
1060
|
+
* @param {string} [input.groupId]
|
|
1061
|
+
* @param {string} [input.personId]
|
|
1062
|
+
* @returns {object|undefined} the public member view, or undefined.
|
|
1063
|
+
*/
|
|
1064
|
+
function memberView(input = {}) {
|
|
1065
|
+
const group = findGroup(input.groupId);
|
|
1066
|
+
if (group === undefined) return undefined;
|
|
1067
|
+
const personId = typeof input.personId === 'string' ? input.personId : '';
|
|
1068
|
+
if (personId === '') return undefined;
|
|
1069
|
+
const member = group.members.find((entry) => entry.personId === personId);
|
|
1070
|
+
if (member === undefined) return undefined;
|
|
1071
|
+
return publicMember(member, now());
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function lastEntry(input = {}) {
|
|
1075
|
+
const group = requireGroup(input.groupId);
|
|
1076
|
+
resolveMember(group, input.member);
|
|
1077
|
+
const entry = group.feed.length === 0 ? undefined : group.feed[group.feed.length - 1];
|
|
1078
|
+
return entry === undefined ? undefined : publicEntry(entry);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Public member list, with presence derived from `lastSeenAt`.
|
|
1083
|
+
*
|
|
1084
|
+
* Presence is computed, not stored: a stored "online" flag has to be un-set by
|
|
1085
|
+
* something, and a crashed host never un-sets it. A timestamp is always honest.
|
|
1086
|
+
*
|
|
1087
|
+
* @param {object} input
|
|
1088
|
+
* @param {string} [input.groupId]
|
|
1089
|
+
* @param {object} [input.member]
|
|
1090
|
+
* @returns {object[]} public member views (no `keyHash`)
|
|
1091
|
+
*/
|
|
1092
|
+
function members(input = {}) {
|
|
1093
|
+
const group = requireGroup(input.groupId);
|
|
1094
|
+
resolveMember(group, input.member);
|
|
1095
|
+
const at = now();
|
|
1096
|
+
return group.members.map((member) => publicMember(member, at));
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Post one message to one person.
|
|
1101
|
+
*
|
|
1102
|
+
* @param {object} input
|
|
1103
|
+
* @param {string} input.fromPersonId who is sending.
|
|
1104
|
+
* @param {string} input.toPersonId who should receive it.
|
|
1105
|
+
* @param {string} input.kind one of the `DM_KIND_*` values.
|
|
1106
|
+
* @param {string} input.text one line, shown as-is.
|
|
1107
|
+
* @param {object} [input.payload] structured data the client acts on, e.g. `{ inviteLink, groupId }`.
|
|
1108
|
+
* @returns {object} the stored message.
|
|
1109
|
+
*/
|
|
1110
|
+
function sendPersonMessage(input = {}) {
|
|
1111
|
+
const fromPersonId = requireText(input.fromPersonId, 'fromPersonId');
|
|
1112
|
+
const toPersonId = requireText(input.toPersonId, 'toPersonId');
|
|
1113
|
+
const kind = requireText(input.kind, 'kind');
|
|
1114
|
+
if (!DM_KINDS.has(kind)) {
|
|
1115
|
+
throw new GroupError('bad_argument', `不支持的一对一消息类型:${kind}。`);
|
|
1116
|
+
}
|
|
1117
|
+
if (fromPersonId === toPersonId) {
|
|
1118
|
+
// Not an error worth failing on, but not something to store either: a note to yourself
|
|
1119
|
+
// arrives with no sender, and the UI would have to special-case it.
|
|
1120
|
+
throw new GroupError('bad_argument', '不能给自己发消息。');
|
|
1121
|
+
}
|
|
1122
|
+
const text = requireText(input.text, 'text');
|
|
1123
|
+
const at = now();
|
|
1124
|
+
|
|
1125
|
+
const message = {
|
|
1126
|
+
dmId: `dm-${randomBytes(6).toString('hex')}`,
|
|
1127
|
+
fromPersonId,
|
|
1128
|
+
toPersonId,
|
|
1129
|
+
kind,
|
|
1130
|
+
text,
|
|
1131
|
+
...(input.payload === undefined ? {} : { payload: input.payload }),
|
|
1132
|
+
createdAt: at,
|
|
1133
|
+
// Absent until the receiver confirms. The sender's HTTP call returning is NOT delivery:
|
|
1134
|
+
// reporting "sent" for a message nobody received is how a messenger loses trust.
|
|
1135
|
+
deliveredAt: undefined,
|
|
1136
|
+
readAt: undefined,
|
|
1137
|
+
expiresAt: at + DM_TTL_MS,
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
directMessages.push(message);
|
|
1141
|
+
// Drop the oldest delivered messages first, then the oldest overall, so an overflowing queue
|
|
1142
|
+
// loses history rather than anything still waiting to be read.
|
|
1143
|
+
if (directMessages.length > MAX_DIRECT_MESSAGES) {
|
|
1144
|
+
const delivered = directMessages.filter((entry) => entry.deliveredAt !== undefined);
|
|
1145
|
+
const dropCount = directMessages.length - MAX_DIRECT_MESSAGES;
|
|
1146
|
+
const doomed = new Set(
|
|
1147
|
+
[...delivered, ...directMessages].slice(0, dropCount).map((entry) => entry.dmId),
|
|
1148
|
+
);
|
|
1149
|
+
directMessages = directMessages.filter((entry) => !doomed.has(entry.dmId));
|
|
1150
|
+
}
|
|
1151
|
+
persist();
|
|
1152
|
+
log.info('direct message stored', { dmId: message.dmId, kind });
|
|
1153
|
+
return publicDirectMessage(message);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Messages waiting for one person, including any already delivered but unread.
|
|
1158
|
+
*
|
|
1159
|
+
* @param {object} input - `{ personId, since }`.
|
|
1160
|
+
* @returns {object[]} messages, oldest first.
|
|
1161
|
+
*/
|
|
1162
|
+
function listPersonMessages(input = {}) {
|
|
1163
|
+
const personId = requireText(input.personId, 'personId');
|
|
1164
|
+
const since = Number.isFinite(input.since) ? input.since : 0;
|
|
1165
|
+
const at = now();
|
|
1166
|
+
return directMessages
|
|
1167
|
+
.filter((entry) => entry.toPersonId === personId)
|
|
1168
|
+
.filter((entry) => entry.expiresAt > at)
|
|
1169
|
+
.filter((entry) => entry.createdAt > since)
|
|
1170
|
+
.slice(-MAX_DM_PAGE)
|
|
1171
|
+
.map(publicDirectMessage);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Confirm receipt, which is what turns "sent" into "delivered".
|
|
1176
|
+
*
|
|
1177
|
+
* Idempotent: a retried ack must not move the timestamp, because `deliveredAt` is what the sender
|
|
1178
|
+
* shows and a shifting value would look like repeated deliveries.
|
|
1179
|
+
*
|
|
1180
|
+
* @param {object} input - `{ personId, dmIds }`.
|
|
1181
|
+
* @returns {{acked: number, unknown: string[]}} how many were confirmed, and which were not found.
|
|
1182
|
+
*/
|
|
1183
|
+
function ackPersonMessages(input = {}) {
|
|
1184
|
+
const personId = requireText(input.personId, 'personId');
|
|
1185
|
+
const ids = Array.isArray(input.dmIds) ? input.dmIds.filter((id) => typeof id === 'string') : [];
|
|
1186
|
+
if (ids.length === 0) throw new GroupError('bad_argument', '缺少要确认的 dmIds。');
|
|
1187
|
+
const at = now();
|
|
1188
|
+
let acked = 0;
|
|
1189
|
+
const unknown = [];
|
|
1190
|
+
for (const dmId of ids) {
|
|
1191
|
+
const message = directMessages.find((entry) => entry.dmId === dmId);
|
|
1192
|
+
// A message addressed to somebody else is reported as unknown rather than acknowledged: an ack
|
|
1193
|
+
// by the wrong person would mark a message delivered that its recipient never saw.
|
|
1194
|
+
if (message === undefined || message.toPersonId !== personId) {
|
|
1195
|
+
unknown.push(dmId);
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (message.deliveredAt === undefined) {
|
|
1199
|
+
message.deliveredAt = at;
|
|
1200
|
+
acked += 1;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (acked > 0) persist();
|
|
1204
|
+
return { acked, unknown };
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Whether a person is currently holding a connection this host can push into.
|
|
1209
|
+
*
|
|
1210
|
+
* The same rule the roster uses for presence: a subscription refreshes `lastSeenAt`, so a recent
|
|
1211
|
+
* timestamp means a live socket. Kept as a question rather than a flag because a flag would need
|
|
1212
|
+
* clearing on every disconnect path, and one missed path leaves someone "online" forever.
|
|
1213
|
+
*
|
|
1214
|
+
* @param {string} personId - the person.
|
|
1215
|
+
* @returns {boolean} whether they are reachable now.
|
|
1216
|
+
*/
|
|
1217
|
+
function isPersonOnline(personId) {
|
|
1218
|
+
const at = now();
|
|
1219
|
+
for (const group of state.groups) {
|
|
1220
|
+
for (const member of group.members) {
|
|
1221
|
+
if (member.personId !== personId) continue;
|
|
1222
|
+
if (at - member.lastSeenAt <= ONLINE_WINDOW_MS) return true;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Refresh a member's presence timestamp, and nothing else.
|
|
1230
|
+
*
|
|
1231
|
+
* Called on a timer by an open subscription, which is the real evidence that somebody is
|
|
1232
|
+
* reachable. Every other writer of `lastSeenAt` also does something visible (authenticate, post,
|
|
1233
|
+
* read), so reusing one of those for a presence tick would put noise in the feed or move a
|
|
1234
|
+
* sequence number just to say "still here".
|
|
1235
|
+
*
|
|
1236
|
+
* Silent on failure by design: the caller suppresses errors too, because a presence refresh must
|
|
1237
|
+
* never be able to kill a working subscription.
|
|
1238
|
+
*
|
|
1239
|
+
* @param {object} input - `{ groupId, member }`.
|
|
1240
|
+
* @returns {number} the timestamp written.
|
|
1241
|
+
*/
|
|
1242
|
+
function touch(input = {}) {
|
|
1243
|
+
const group = requireGroup(input.groupId);
|
|
1244
|
+
const member = resolveMember(group, input.member);
|
|
1245
|
+
const at = now();
|
|
1246
|
+
member.lastSeenAt = at;
|
|
1247
|
+
dirty = true;
|
|
1248
|
+
return at;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Change what this member is IN THIS GROUP.
|
|
1253
|
+
*
|
|
1254
|
+
* Layer A of identity: a colleague may be a developer all year and take on the
|
|
1255
|
+
* product role in one project. Re-declaring is the ordinary case, not an
|
|
1256
|
+
* exception, so this overwrites rather than versioning — the group shows who
|
|
1257
|
+
* somebody is now, not a history of job titles.
|
|
1258
|
+
*
|
|
1259
|
+
* Two rules that are not negotiable:
|
|
1260
|
+
*
|
|
1261
|
+
* - **Only the member itself.** `resolveMember` proves the caller holds that
|
|
1262
|
+
* member's credential, so there is no way to pass somebody else's identity —
|
|
1263
|
+
* a role is self-description, not a label others stick on you. (Contrast
|
|
1264
|
+
* with `role`, the permission bit, which the store owns.)
|
|
1265
|
+
* - **Never touches `role`.** Business identity and the ability to mint invites
|
|
1266
|
+
* are different axes; conflating them would turn "I am the PM" into a
|
|
1267
|
+
* privilege escalation.
|
|
1268
|
+
*
|
|
1269
|
+
* @param {object} input
|
|
1270
|
+
* @param {string} [input.groupId]
|
|
1271
|
+
* @param {object} [input.member] authenticated member view, or `{personId, personKey}`
|
|
1272
|
+
* @param {string} [input.about] one sentence; omit to leave unchanged
|
|
1273
|
+
* @param {string[]} [input.tags] up to MAX_TAGS short labels; `[]` clears them
|
|
1274
|
+
* @returns {{personId: string, roleAbout: (string|undefined), tags: string[], updatedAt: number}}
|
|
1275
|
+
*/
|
|
1276
|
+
function setProfile(input = {}) {
|
|
1277
|
+
const group = requireGroup(input.groupId);
|
|
1278
|
+
const member = resolveMember(group, input.member);
|
|
1279
|
+
const at = now();
|
|
1280
|
+
|
|
1281
|
+
// Compare against the stored values so a save that changes nothing is not announced. A feed where
|
|
1282
|
+
// every open-and-save adds a line makes the notification useless, because a member can no longer
|
|
1283
|
+
// tell an update from a repeat — and the report that started this was exactly that.
|
|
1284
|
+
const beforeAbout = member.roleAbout ?? '';
|
|
1285
|
+
const beforeTags = JSON.stringify([...(member.tags ?? [])].sort());
|
|
1286
|
+
|
|
1287
|
+
if (input.about !== undefined) {
|
|
1288
|
+
member.roleAbout = input.about === null || input.about === ''
|
|
1289
|
+
? undefined
|
|
1290
|
+
: requireText(input.about, 'about', MAX_ROLE_ABOUT_CHARS, '角色说明');
|
|
1291
|
+
}
|
|
1292
|
+
if (input.tags !== undefined) member.tags = normalizeTags(input.tags);
|
|
1293
|
+
|
|
1294
|
+
// Normalised on both sides: comparing a raw array against a normalised one would report a change on
|
|
1295
|
+
// every save, which is the same bug wearing a different hat.
|
|
1296
|
+
const afterAbout = member.roleAbout ?? '';
|
|
1297
|
+
const afterTags = JSON.stringify([...(member.tags ?? [])].sort());
|
|
1298
|
+
const profileChanged = beforeAbout !== afterAbout || beforeTags !== afterTags;
|
|
1299
|
+
|
|
1300
|
+
if (profileChanged === false) {
|
|
1301
|
+
// A no-op save: return the current state and touch nothing. `profileUpdatedAt` in particular stays,
|
|
1302
|
+
// because it means "when this last changed" and bumping it would make the value lie.
|
|
1303
|
+
return {
|
|
1304
|
+
personId: member.personId,
|
|
1305
|
+
roleAbout: member.roleAbout,
|
|
1306
|
+
tags: [...member.tags],
|
|
1307
|
+
updatedAt: member.profileUpdatedAt ?? at,
|
|
1308
|
+
changed: false,
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
member.profileUpdatedAt = at;
|
|
1313
|
+
dirty = true;
|
|
1314
|
+
persist();
|
|
1315
|
+
|
|
1316
|
+
// A system line so other members can notice a role change without re-reading the member list. It
|
|
1317
|
+
// carries no identity detail, only the fact of the change — and now only when there IS one.
|
|
1318
|
+
appendSystem(group, `${member.displayName} 更新了本群角色`);
|
|
1319
|
+
log.info('member profile updated', {
|
|
1320
|
+
groupId: group.groupId,
|
|
1321
|
+
personId: member.personId,
|
|
1322
|
+
tags: member.tags,
|
|
1323
|
+
});
|
|
1324
|
+
return {
|
|
1325
|
+
personId: member.personId,
|
|
1326
|
+
roleAbout: member.roleAbout,
|
|
1327
|
+
tags: [...member.tags],
|
|
1328
|
+
updatedAt: at,
|
|
1329
|
+
changed: true,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Remove another member. Owner only.
|
|
1335
|
+
*
|
|
1336
|
+
* Their messages stay. Deleting them would rewrite the conversation — a reader would see
|
|
1337
|
+
* people answering somebody who no longer appears to have spoken — and history is the
|
|
1338
|
+
* group's product, not the leaver's property to erase.
|
|
1339
|
+
*
|
|
1340
|
+
* A system entry records the removal, because the remaining members will notice that a
|
|
1341
|
+
* person disappeared. Silence would leave them guessing, and guessing in a group about
|
|
1342
|
+
* people is how rumours start.
|
|
1343
|
+
*
|
|
1344
|
+
* The owner cannot remove themselves: that is `leave`, which transfers ownership. Doing
|
|
1345
|
+
* it here would leave a group with an owner who is not in it.
|
|
1346
|
+
*
|
|
1347
|
+
* @param {object} input
|
|
1348
|
+
* @param {string} [input.groupId]
|
|
1349
|
+
* @param {object} [input.member] owner credential
|
|
1350
|
+
* @param {string} [input.targetPersonId] who to remove
|
|
1351
|
+
* @param {string} [input.reason] one line for the system entry
|
|
1352
|
+
* @returns {{removed: boolean, personId: string, feedSeq: number|undefined}}
|
|
1353
|
+
*/
|
|
1354
|
+
function removeMember(input = {}) {
|
|
1355
|
+
const group = requireGroup(input.groupId);
|
|
1356
|
+
const owner = resolveMember(group, input.member);
|
|
1357
|
+
if (owner.personId !== group.ownerPersonId) {
|
|
1358
|
+
throw new GroupError('not_owner', '只有群主可以移除成员。');
|
|
1359
|
+
}
|
|
1360
|
+
const targetId = typeof input.targetPersonId === 'string' ? input.targetPersonId.trim() : '';
|
|
1361
|
+
if (targetId === '') throw new GroupError('bad_argument', '缺少 targetPersonId。');
|
|
1362
|
+
if (targetId === owner.personId) {
|
|
1363
|
+
throw new GroupError(
|
|
1364
|
+
'bad_argument',
|
|
1365
|
+
'群主不能用移除操作离开:请用 group/leave,它会把群主身份交给下一位成员。',
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
const index = group.members.findIndex((entry) => entry.personId === targetId);
|
|
1369
|
+
if (index < 0) throw new GroupError('not_member', '要移除的人不在这个群里。');
|
|
1370
|
+
|
|
1371
|
+
const target = group.members[index];
|
|
1372
|
+
group.members.splice(index, 1);
|
|
1373
|
+
// The rate-limit bucket is keyed by group+member; leaving it behind would leak one
|
|
1374
|
+
// entry per removal for the life of the process.
|
|
1375
|
+
for (const key of buckets.keys()) {
|
|
1376
|
+
if (key === `${group.groupId}\u0000${target.personId}`) buckets.delete(key);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const reason = optionalText(input.reason, MAX_REQUEST_REASON_CHARS, '原因');
|
|
1380
|
+
appendSystem(
|
|
1381
|
+
group,
|
|
1382
|
+
`${owner.displayName} 将 ${target.displayName} 移出了群${reason === undefined ? '' : `(${reason})`}`,
|
|
1383
|
+
);
|
|
1384
|
+
persist();
|
|
1385
|
+
log.info('member removed', { groupId: group.groupId, personId: target.personId, by: owner.personId });
|
|
1386
|
+
return {
|
|
1387
|
+
removed: true,
|
|
1388
|
+
personId: target.personId,
|
|
1389
|
+
displayName: target.displayName,
|
|
1390
|
+
feedSeq: group.nextSeq - 1,
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Rename a group, or change its topic, or both. Owner only.
|
|
1396
|
+
*
|
|
1397
|
+
* The most frequent maintenance act in a real group, and until now the only remedy for a
|
|
1398
|
+
* wrong name was to create a new group and lose its history — which nobody would do, so
|
|
1399
|
+
* groups kept wrong names.
|
|
1400
|
+
*
|
|
1401
|
+
* @param {object} input
|
|
1402
|
+
* @param {string} [input.groupId]
|
|
1403
|
+
* @param {object} [input.member] owner credential
|
|
1404
|
+
* @param {string} [input.name] new display name
|
|
1405
|
+
* @param {string} [input.topic] new topic; an empty string clears it
|
|
1406
|
+
* @returns {{name: string, topic: string, changed: string[]}}
|
|
1407
|
+
*/
|
|
1408
|
+
function renameGroup(input = {}) {
|
|
1409
|
+
const group = requireGroup(input.groupId);
|
|
1410
|
+
const owner = resolveMember(group, input.member);
|
|
1411
|
+
if (owner.personId !== group.ownerPersonId) {
|
|
1412
|
+
throw new GroupError('not_owner', '只有群主可以改群名或主题。');
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const changed = [];
|
|
1416
|
+
if (input.name !== undefined) {
|
|
1417
|
+
const name = requireText(input.name, 'name', MAX_NAME_CHARS, '群名');
|
|
1418
|
+
if (name !== group.name) {
|
|
1419
|
+
group.name = name;
|
|
1420
|
+
changed.push('name');
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (input.topic !== undefined) {
|
|
1424
|
+
const topic = optionalText(input.topic, MAX_TOPIC_CHARS, '群主题') ?? '';
|
|
1425
|
+
if (topic !== group.topic) {
|
|
1426
|
+
group.topic = topic;
|
|
1427
|
+
changed.push('topic');
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
if (changed.length === 0) {
|
|
1431
|
+
// Not an error: a caller that sends the current values is asking "did anything
|
|
1432
|
+
// change", and the honest answer is no. Reporting it beats a silent no-op.
|
|
1433
|
+
return { name: group.name, topic: group.topic, changed: [] };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
appendSystem(group, `${owner.displayName} 修改了${changed.map((field) => (field === 'name' ? '群名' : '群主题')).join(' 和 ')}`);
|
|
1437
|
+
persist();
|
|
1438
|
+
log.info('group renamed', { groupId: group.groupId, changed });
|
|
1439
|
+
return { name: group.name, topic: group.topic, changed };
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Archive or unarchive a group. Owner only.
|
|
1444
|
+
*
|
|
1445
|
+
* Archived means readable but not writable, and absent from the directory. That is
|
|
1446
|
+
* deliberately NOT a delete: a finished project's conversation is worth keeping, and
|
|
1447
|
+
* re-opening an archive by mistake must be possible — a delete is not reversible.
|
|
1448
|
+
*
|
|
1449
|
+
* Read is still allowed because the most common reason to archive is "we are done" and
|
|
1450
|
+
* the second most common is "let me check what we decided". Refusing to read an archive
|
|
1451
|
+
* would make the feature useless.
|
|
1452
|
+
*
|
|
1453
|
+
* @param {object} input
|
|
1454
|
+
* @param {string} [input.groupId]
|
|
1455
|
+
* @param {object} [input.member] owner credential
|
|
1456
|
+
* @param {boolean} [input.archived] defaults to true
|
|
1457
|
+
* @returns {{archived: boolean}}
|
|
1458
|
+
*/
|
|
1459
|
+
function archiveGroup(input = {}) {
|
|
1460
|
+
const group = requireGroup(input.groupId);
|
|
1461
|
+
const owner = resolveMember(group, input.member);
|
|
1462
|
+
if (owner.personId !== group.ownerPersonId) {
|
|
1463
|
+
throw new GroupError('not_owner', '只有群主可以归档或恢复群。');
|
|
1464
|
+
}
|
|
1465
|
+
const archived = input.archived !== false;
|
|
1466
|
+
if (archived === (group.settings.archived === true)) {
|
|
1467
|
+
return { archived };
|
|
1468
|
+
}
|
|
1469
|
+
group.settings.archived = archived;
|
|
1470
|
+
appendSystem(group, `${owner.displayName} ${archived ? '归档' : '恢复'}了这个群`);
|
|
1471
|
+
persist();
|
|
1472
|
+
log.info('group archived', { groupId: group.groupId, archived });
|
|
1473
|
+
return { archived };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Remove a member.
|
|
1478
|
+
*
|
|
1479
|
+
* If the owner leaves while others remain, ownership transfers to the
|
|
1480
|
+
* earliest-joined remaining member instead of leaving the group unmanageable:
|
|
1481
|
+
* an owner-less group can never mint or revoke another invite. (The contract
|
|
1482
|
+
* does not specify this case; it is recorded as a deviation in the delivery
|
|
1483
|
+
* notes.)
|
|
1484
|
+
*
|
|
1485
|
+
* @param {object} input
|
|
1486
|
+
* @param {string} [input.groupId]
|
|
1487
|
+
* @param {object} [input.member]
|
|
1488
|
+
* @returns {boolean} whether a member was removed
|
|
1489
|
+
*/
|
|
1490
|
+
function leave(input = {}) { const group = requireGroup(input.groupId);
|
|
1491
|
+
const member = resolveMember(group, input.member);
|
|
1492
|
+
const index = group.members.findIndex((entry) => entry.personId === member.personId);
|
|
1493
|
+
group.members.splice(index, 1);
|
|
1494
|
+
for (const key of buckets.keys()) {
|
|
1495
|
+
if (key === `${group.groupId}\u0000${member.personId}`) buckets.delete(key);
|
|
1496
|
+
}
|
|
1497
|
+
appendSystem(group, `${member.displayName} 退出了群`);
|
|
1498
|
+
|
|
1499
|
+
if (member.personId === group.ownerPersonId) {
|
|
1500
|
+
const next = [...group.members].sort((a, b) => a.joinedAt - b.joinedAt)[0];
|
|
1501
|
+
if (next === undefined) {
|
|
1502
|
+
group.ownerPersonId = undefined; // no members left; the group is inert but retained
|
|
1503
|
+
} else {
|
|
1504
|
+
next.role = 'owner';
|
|
1505
|
+
group.ownerPersonId = next.personId;
|
|
1506
|
+
appendSystem(group, `${next.displayName} 成为新群主`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
persist();
|
|
1510
|
+
log.info('member left', { groupId: group.groupId, personId: member.personId });
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Revoke an invite so its tokens stop working immediately.
|
|
1516
|
+
*
|
|
1517
|
+
* Requires the *owner* credential, not the group password: a shared password
|
|
1518
|
+
* must not be enough to kick other people's pending invites out from under
|
|
1519
|
+
* them.
|
|
1520
|
+
*
|
|
1521
|
+
* @param {object} input
|
|
1522
|
+
* @param {string} [input.groupId]
|
|
1523
|
+
* @param {string} [input.ownerKey]
|
|
1524
|
+
* @param {string} [input.token]
|
|
1525
|
+
* @param {string} [input.inviteId]
|
|
1526
|
+
* @returns {boolean} whether an invite was revoked
|
|
1527
|
+
*/
|
|
1528
|
+
function revokeInvite(input = {}) {
|
|
1529
|
+
const group = requireGroup(input.groupId);
|
|
1530
|
+
const owner = group.members.find((entry) => entry.personId === group.ownerPersonId);
|
|
1531
|
+
const supplied = sha256Hex(typeof input.ownerKey === 'string' ? input.ownerKey : '');
|
|
1532
|
+
if (owner === undefined || !compareSecrets(supplied, owner.keyHash)) {
|
|
1533
|
+
throw new GroupError('not_owner', '只有群主可以撤销邀请。');
|
|
1534
|
+
}
|
|
1535
|
+
const inviteId = optionalText(input.inviteId, 200, 'inviteId');
|
|
1536
|
+
const token = optionalText(input.token, 2048, '邀请令牌');
|
|
1537
|
+
if (inviteId === undefined && token === undefined) {
|
|
1538
|
+
throw new GroupError('bad_argument', '撤销邀请需要提供 inviteId 或邀请令牌。');
|
|
1539
|
+
}
|
|
1540
|
+
const tokenHash = token === undefined ? undefined : sha256Hex(token);
|
|
1541
|
+
let found;
|
|
1542
|
+
for (const candidate of group.invites) {
|
|
1543
|
+
if (inviteId !== undefined && candidate.inviteId === inviteId) found = candidate;
|
|
1544
|
+
else if (tokenHash !== undefined && compareSecrets(tokenHash, candidate.tokenHash)) found = candidate;
|
|
1545
|
+
}
|
|
1546
|
+
if (found === undefined) return false;
|
|
1547
|
+
found.revoked = true;
|
|
1548
|
+
persist();
|
|
1549
|
+
log.info('invite revoked', { groupId: group.groupId, inviteId: found.inviteId });
|
|
1550
|
+
return true;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* Subscribe to a group's new entries.
|
|
1555
|
+
*
|
|
1556
|
+
* The callback is isolated: one subscriber that throws (a broken SSE sink, a
|
|
1557
|
+
* disposed session) must not stop the others or fail the `post` that triggered
|
|
1558
|
+
* it, because the post is already durable at that point.
|
|
1559
|
+
*
|
|
1560
|
+
* @param {object} input
|
|
1561
|
+
* @param {string} [input.groupId]
|
|
1562
|
+
* @param {(entry: object) => void} [input.fn]
|
|
1563
|
+
* @returns {() => void} unsubscribe
|
|
1564
|
+
*/
|
|
1565
|
+
function subscribe(input = {}) {
|
|
1566
|
+
const groupId = requireGroup(input.groupId).groupId;
|
|
1567
|
+
if (typeof input.fn !== 'function') throw new GroupError('bad_argument', 'subscribe 需要一个回调函数。');
|
|
1568
|
+
let set = subscribers.get(groupId);
|
|
1569
|
+
if (set === undefined) {
|
|
1570
|
+
set = new Set();
|
|
1571
|
+
subscribers.set(groupId, set);
|
|
1572
|
+
}
|
|
1573
|
+
set.add(input.fn);
|
|
1574
|
+
return () => {
|
|
1575
|
+
const current = subscribers.get(groupId);
|
|
1576
|
+
if (current === undefined) return;
|
|
1577
|
+
current.delete(input.fn);
|
|
1578
|
+
if (current.size === 0) subscribers.delete(groupId);
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* A summary of one group, safe to show in a panel.
|
|
1584
|
+
*
|
|
1585
|
+
* `member` is optional so the read-only UI can render a joined group's header
|
|
1586
|
+
* before the panel has a credential in hand. Without a credential the result
|
|
1587
|
+
* carries *metadata only* — counts, timestamps, settings — and deliberately no
|
|
1588
|
+
* message text, not even the last line: "how many messages and when" is a
|
|
1589
|
+
* group's shape, while what was said is the thing a password protects.
|
|
1590
|
+
*
|
|
1591
|
+
* @param {object} input
|
|
1592
|
+
* @param {string} [input.groupId]
|
|
1593
|
+
* @param {object} [input.member]
|
|
1594
|
+
* @returns {object} summary without any hash field
|
|
1595
|
+
*/
|
|
1596
|
+
function snapshot(input = {}) {
|
|
1597
|
+
const group = requireGroup(input.groupId);
|
|
1598
|
+
const at = now();
|
|
1599
|
+
let viewer;
|
|
1600
|
+
if (input.member !== undefined) viewer = resolveMember(group, input.member);
|
|
1601
|
+
const lastEntry = group.feed.length === 0 ? undefined : group.feed[group.feed.length - 1];
|
|
1602
|
+
const withContent = viewer !== undefined && lastEntry !== undefined;
|
|
1603
|
+
return {
|
|
1604
|
+
...publicGroup(group),
|
|
1605
|
+
feedTail: viewer === undefined ? [] : group.feed.slice(-FEED_TAIL_LIMIT).map(publicEntry),
|
|
1606
|
+
// Text only for a credential holder; the timestamp is metadata either way.
|
|
1607
|
+
lastPost: withContent ? publicEntry(lastEntry) : undefined,
|
|
1608
|
+
lastPostAt: lastEntry?.at,
|
|
1609
|
+
lastPostAtIso: lastEntry === undefined ? undefined : new Date(lastEntry.at).toISOString(),
|
|
1610
|
+
onlineCount: group.members.filter((member) => at - member.lastSeenAt <= ONLINE_WINDOW_MS).length,
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Every group on this host, as summaries.
|
|
1616
|
+
*
|
|
1617
|
+
* Not in CONTRACT §2.6 (the panel and `a2a_group_*` listing need it); additive,
|
|
1618
|
+
* and it returns the same hash-free shape as `snapshot` with no feed.
|
|
1619
|
+
*
|
|
1620
|
+
* @returns {object[]}
|
|
1621
|
+
*/
|
|
1622
|
+
function list() {
|
|
1623
|
+
const at = now();
|
|
1624
|
+
return state.groups.map((group) => ({
|
|
1625
|
+
...publicGroup(group),
|
|
1626
|
+
onlineCount: group.members.filter((member) => at - member.lastSeenAt <= ONLINE_WINDOW_MS).length,
|
|
1627
|
+
}));
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/**
|
|
1631
|
+
* Apply to join a group, without a credential.
|
|
1632
|
+
*
|
|
1633
|
+
* This is the "I saw the group in the directory and want in" path. The applicant
|
|
1634
|
+
* has no member key yet — that is the whole point — so the store cannot know who
|
|
1635
|
+
* they are. What it CAN do is hand back a secret that only the applicant holds,
|
|
1636
|
+
* and let the owner decide out of band.
|
|
1637
|
+
*
|
|
1638
|
+
* The returned `requestId` is public (it is a queue index); the returned
|
|
1639
|
+
* `claimKey` is the secret. Splitting them matters: an approval is addressed by
|
|
1640
|
+
* `requestId` (the owner reads that list anyway), while claiming the resulting
|
|
1641
|
+
* membership requires `claimKey` (only the applicant ever saw it). If the claim
|
|
1642
|
+
* accepted the id alone, anyone who guessed or saw an id in a log could collect
|
|
1643
|
+
* somebody else's membership.
|
|
1644
|
+
*
|
|
1645
|
+
* @param {object} input
|
|
1646
|
+
* @param {string} [input.groupId]
|
|
1647
|
+
* @param {string} [input.displayName] who is asking
|
|
1648
|
+
* @param {string} [input.reason] one line about why
|
|
1649
|
+
* @returns {{requestId: string, claimKey: string, expiresAt: number, groupName: string}}
|
|
1650
|
+
*/
|
|
1651
|
+
function requestJoin(input = {}) {
|
|
1652
|
+
const group = requireGroup(input.groupId);
|
|
1653
|
+
if ((group.settings?.visibility ?? 'public') === 'unlisted') {
|
|
1654
|
+
// An unlisted group does not admit to existing, so it cannot acknowledge a
|
|
1655
|
+
// request either: a "pending" answer would confirm the id.
|
|
1656
|
+
throw new GroupError('unknown_group', '找不到这个群。');
|
|
1657
|
+
}
|
|
1658
|
+
const displayName = optionalText(input.displayName, MAX_DISPLAY_NAME_CHARS, '昵称');
|
|
1659
|
+
if (displayName === undefined) {
|
|
1660
|
+
throw new GroupError('bad_argument', '申请入群需要填写你的名字(displayName)。');
|
|
1661
|
+
}
|
|
1662
|
+
const reason = optionalText(input.reason, MAX_REQUEST_REASON_CHARS, '申请理由') ?? '';
|
|
1663
|
+
|
|
1664
|
+
const at = now();
|
|
1665
|
+
// Expire the queue before looking at its size: a burst of stale requests must
|
|
1666
|
+
// not make a group look full to the next honest applicant.
|
|
1667
|
+
pruneRequests(group, at);
|
|
1668
|
+
if (group.requests.length >= MAX_PENDING_REQUESTS) {
|
|
1669
|
+
throw new GroupError('group_full', `该群待处理的申请已达上限(${MAX_PENDING_REQUESTS}),请稍后再试。`);
|
|
1670
|
+
}
|
|
1671
|
+
// One person, one open request. Re-applying refreshes the reason and the
|
|
1672
|
+
// deadline instead of queueing a duplicate the owner would see twice.
|
|
1673
|
+
const existing = group.requests.find(
|
|
1674
|
+
(entry) => entry.status === 'pending' && entry.displayName === displayName,
|
|
1675
|
+
);
|
|
1676
|
+
if (existing !== undefined) {
|
|
1677
|
+
existing.reason = reason;
|
|
1678
|
+
existing.expiresAt = at + REQUEST_TTL_MS;
|
|
1679
|
+
persist();
|
|
1680
|
+
return {
|
|
1681
|
+
requestId: existing.requestId,
|
|
1682
|
+
claimKey: undefined, // already handed out; re-issuing would invalidate it
|
|
1683
|
+
expiresAt: existing.expiresAt,
|
|
1684
|
+
groupName: group.name,
|
|
1685
|
+
duplicate: true,
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const claimKey = `ck_${randomToken(32)}`;
|
|
1690
|
+
const request = {
|
|
1691
|
+
requestId: `r-${randomBytes(4).toString('hex')}`,
|
|
1692
|
+
displayName,
|
|
1693
|
+
reason,
|
|
1694
|
+
claimHash: sha256Hex(claimKey),
|
|
1695
|
+
status: 'pending',
|
|
1696
|
+
createdAt: at,
|
|
1697
|
+
expiresAt: at + REQUEST_TTL_MS,
|
|
1698
|
+
};
|
|
1699
|
+
group.requests.push(request);
|
|
1700
|
+
persist();
|
|
1701
|
+
log.info('join requested', {
|
|
1702
|
+
groupId: group.groupId,
|
|
1703
|
+
requestId: request.requestId,
|
|
1704
|
+
displayName,
|
|
1705
|
+
});
|
|
1706
|
+
return {
|
|
1707
|
+
requestId: request.requestId,
|
|
1708
|
+
claimKey,
|
|
1709
|
+
expiresAt: request.expiresAt,
|
|
1710
|
+
groupName: group.name,
|
|
1711
|
+
duplicate: false,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* The pending queue, for the owner only.
|
|
1717
|
+
*
|
|
1718
|
+
* Requires the owner's credential because the queue contains other people's
|
|
1719
|
+
* names and stated reasons — the same class of information as the member list.
|
|
1720
|
+
*
|
|
1721
|
+
* @param {object} input
|
|
1722
|
+
* @param {string} [input.groupId]
|
|
1723
|
+
* @param {object} [input.member] owner credential
|
|
1724
|
+
* @param {string} [input.status] 'pending' (default) | 'approved' | 'rejected' | 'all'
|
|
1725
|
+
* @returns {object[]} public request views
|
|
1726
|
+
*/
|
|
1727
|
+
function listRequests(input = {}) {
|
|
1728
|
+
const group = requireGroup(input.groupId);
|
|
1729
|
+
const member = resolveMember(group, input.member);
|
|
1730
|
+
if (member.personId !== group.ownerPersonId) {
|
|
1731
|
+
throw new GroupError('not_owner', '只有群主可以查看入群申请。');
|
|
1732
|
+
}
|
|
1733
|
+
const at = now();
|
|
1734
|
+
pruneRequests(group, at);
|
|
1735
|
+
const wanted = typeof input.status === 'string' ? input.status : 'pending';
|
|
1736
|
+
return group.requests
|
|
1737
|
+
// The claim hash never leaves the store: it is the applicant's secret.
|
|
1738
|
+
.filter((entry) => (wanted === 'all' ? true : entry.status === wanted))
|
|
1739
|
+
.map((entry) => ({
|
|
1740
|
+
requestId: entry.requestId,
|
|
1741
|
+
displayName: entry.displayName,
|
|
1742
|
+
reason: entry.reason,
|
|
1743
|
+
status: entry.status,
|
|
1744
|
+
createdAt: entry.createdAt,
|
|
1745
|
+
createdAtIso: new Date(entry.createdAt).toISOString(),
|
|
1746
|
+
expiresAt: entry.expiresAt,
|
|
1747
|
+
expiresAtIso: new Date(entry.expiresAt).toISOString(),
|
|
1748
|
+
}));
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Approve or reject one request. Owner only.
|
|
1753
|
+
*
|
|
1754
|
+
* Approving does NOT create the membership yet. It only flips the request to
|
|
1755
|
+
* `approved` and records who did it, because the applicant is the one holding the
|
|
1756
|
+
* `claimKey` — they collect the membership with `claimJoin`. Creating the member
|
|
1757
|
+
* here would hand an unusable row to someone who cannot authenticate with it.
|
|
1758
|
+
*
|
|
1759
|
+
* @param {object} input
|
|
1760
|
+
* @param {string} [input.groupId]
|
|
1761
|
+
* @param {object} [input.member] owner credential
|
|
1762
|
+
* @param {string} [input.requestId]
|
|
1763
|
+
* @param {boolean} [input.approve] true to approve, false to reject
|
|
1764
|
+
* @returns {{requestId: string, status: string, displayName: string}}
|
|
1765
|
+
*/
|
|
1766
|
+
function decideRequest(input = {}) {
|
|
1767
|
+
const group = requireGroup(input.groupId);
|
|
1768
|
+
const member = resolveMember(group, input.member);
|
|
1769
|
+
if (member.personId !== group.ownerPersonId) {
|
|
1770
|
+
throw new GroupError('not_owner', '只有群主可以审批入群申请。');
|
|
1771
|
+
}
|
|
1772
|
+
const requestId = typeof input.requestId === 'string' ? input.requestId.trim() : '';
|
|
1773
|
+
if (requestId === '') throw new GroupError('bad_argument', '缺少 requestId。');
|
|
1774
|
+
const request = group.requests.find((entry) => entry.requestId === requestId);
|
|
1775
|
+
if (request === undefined) {
|
|
1776
|
+
throw new GroupError('request_not_found', '找不到这条申请:它可能已被处理或已过期。');
|
|
1777
|
+
}
|
|
1778
|
+
const at = now();
|
|
1779
|
+
if (request.expiresAt <= at) {
|
|
1780
|
+
request.status = 'expired';
|
|
1781
|
+
persist();
|
|
1782
|
+
throw new GroupError('request_expired', '这条申请已过期,请让申请人重新提交。');
|
|
1783
|
+
}
|
|
1784
|
+
if (request.status !== 'pending') {
|
|
1785
|
+
throw new GroupError('request_pending', `这条申请已经是「${request.status}」,不能重复处理。`);
|
|
1786
|
+
}
|
|
1787
|
+
request.status = input.approve === false ? 'rejected' : 'approved';
|
|
1788
|
+
request.decidedAt = at;
|
|
1789
|
+
request.decidedBy = member.displayName;
|
|
1790
|
+
persist();
|
|
1791
|
+
log.info('join decided', {
|
|
1792
|
+
groupId: group.groupId,
|
|
1793
|
+
requestId,
|
|
1794
|
+
status: request.status,
|
|
1795
|
+
by: member.personId,
|
|
1796
|
+
});
|
|
1797
|
+
// A system line so members can see the door working, without naming the
|
|
1798
|
+
// applicant's stated reason (that was written for the owner, not the group).
|
|
1799
|
+
appendSystem(group, `${request.displayName} 的入群申请已${request.status === 'approved' ? '通过' : '被拒绝'}`);
|
|
1800
|
+
return {
|
|
1801
|
+
requestId: request.requestId,
|
|
1802
|
+
status: request.status,
|
|
1803
|
+
displayName: request.displayName,
|
|
1804
|
+
// The owner needs the link to send the applicant... except they no longer do:
|
|
1805
|
+
// the applicant claims it with their own key. Returned for a UI that wants to
|
|
1806
|
+
// show what happened.
|
|
1807
|
+
decidedAt: request.decidedAt,
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* Redeem an approved request for a membership. Applicant only — via `claimKey`.
|
|
1813
|
+
*
|
|
1814
|
+
* This is where "approval IS the admission" becomes concrete: the applicant does
|
|
1815
|
+
* not need the group password, because the owner already vouched for them. That
|
|
1816
|
+
* is a deliberate weakening of the password model, and it is bounded:
|
|
1817
|
+
*
|
|
1818
|
+
* - the password still guards `invite` (the owner's own admission path);
|
|
1819
|
+
* - a claim needs the secret handed out at request time, so an approved
|
|
1820
|
+
* `requestId` alone is worthless;
|
|
1821
|
+
* - a request is single-use: claiming flips it to `claimed`.
|
|
1822
|
+
*
|
|
1823
|
+
* The trade is stated plainly: a password is a shared secret anyone can leak to
|
|
1824
|
+
* anyone, while an approval is one person vouching for one other person. For
|
|
1825
|
+
* admitting colleagues, the voucher is the stronger signal — and it is why the
|
|
1826
|
+
* approval path skips the password.
|
|
1827
|
+
*
|
|
1828
|
+
* @param {object} input
|
|
1829
|
+
* @param {string} [input.groupId]
|
|
1830
|
+
* @param {string} [input.requestId]
|
|
1831
|
+
* @param {string} [input.claimKey] the secret from `requestJoin`
|
|
1832
|
+
* @returns {{member: object, personKey: string, group: object}}
|
|
1833
|
+
*/
|
|
1834
|
+
function claimJoin(input = {}) {
|
|
1835
|
+
const group = requireGroup(input.groupId);
|
|
1836
|
+
const requestId = typeof input.requestId === 'string' ? input.requestId.trim() : '';
|
|
1837
|
+
const claimKey = typeof input.claimKey === 'string' ? input.claimKey : '';
|
|
1838
|
+
if (requestId === '' || claimKey === '') {
|
|
1839
|
+
throw new GroupError('bad_argument', '领取成员身份需要 requestId 与 claimKey。');
|
|
1840
|
+
}
|
|
1841
|
+
const request = group.requests.find((entry) => entry.requestId === requestId);
|
|
1842
|
+
if (request === undefined) {
|
|
1843
|
+
throw new GroupError('request_not_found', '找不到这条申请。');
|
|
1844
|
+
}
|
|
1845
|
+
// Constant-time compare: the claim key is a credential, not an index.
|
|
1846
|
+
if (!compareSecrets(sha256Hex(claimKey), request.claimHash)) {
|
|
1847
|
+
throw new GroupError('request_invalid', '领取凭证不正确。');
|
|
1848
|
+
}
|
|
1849
|
+
const at = now();
|
|
1850
|
+
if (request.status === 'rejected') {
|
|
1851
|
+
throw new GroupError('request_rejected', '这条申请已被拒绝。');
|
|
1852
|
+
}
|
|
1853
|
+
if (request.status === 'claimed') {
|
|
1854
|
+
// Idempotency matters here: a flaky link means the applicant retries, and a
|
|
1855
|
+
// retry must not mint a second membership or invalidate the first key.
|
|
1856
|
+
throw new GroupError('request_pending', '这条申请已经领取过了;请用当时拿到的成员凭证。');
|
|
1857
|
+
}
|
|
1858
|
+
if (request.status !== 'approved') {
|
|
1859
|
+
throw new GroupError('request_pending', '这条申请还没有被审批,请等待群主确认。');
|
|
1860
|
+
}
|
|
1861
|
+
if (request.expiresAt <= at) {
|
|
1862
|
+
request.status = 'expired';
|
|
1863
|
+
persist();
|
|
1864
|
+
throw new GroupError('request_expired', '这条申请已过期,请重新提交。');
|
|
1865
|
+
}
|
|
1866
|
+
if (group.members.length >= MAX_MEMBERS_PER_GROUP) {
|
|
1867
|
+
throw new GroupError('group_full', `该群成员已达上限(${MAX_MEMBERS_PER_GROUP})。`);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const personKey = `mk_${randomToken(32)}`;
|
|
1871
|
+
const member = {
|
|
1872
|
+
personId: allocatePersonId(),
|
|
1873
|
+
displayName: request.displayName,
|
|
1874
|
+
role: 'member',
|
|
1875
|
+
keyHash: sha256Hex(personKey),
|
|
1876
|
+
joinedAt: at,
|
|
1877
|
+
lastSeenAt: at,
|
|
1878
|
+
endpoint: undefined,
|
|
1879
|
+
roleAbout: undefined,
|
|
1880
|
+
tags: [],
|
|
1881
|
+
profileUpdatedAt: at,
|
|
1882
|
+
};
|
|
1883
|
+
group.members.push(member);
|
|
1884
|
+
request.status = 'claimed';
|
|
1885
|
+
request.claimedAt = at;
|
|
1886
|
+
appendSystem(group, `${request.displayName} 通过申请加入了群`);
|
|
1887
|
+
persist();
|
|
1888
|
+
log.info('join claimed', { groupId: group.groupId, personId: member.personId });
|
|
1889
|
+
return { member: publicMember(member, at), personKey, group: publicGroup(group) };
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* Mark stale pending requests expired and drop the ones nobody can act on.
|
|
1894
|
+
*
|
|
1895
|
+
* Run before every read of the queue so an operator opening the list never sees
|
|
1896
|
+
* applications that died a week ago, and before every insert so a stale backlog
|
|
1897
|
+
* cannot make the group look full.
|
|
1898
|
+
*
|
|
1899
|
+
* @param {object} group - internal group record.
|
|
1900
|
+
* @param {number} at - current epoch ms.
|
|
1901
|
+
*/
|
|
1902
|
+
function pruneRequests(group, at) {
|
|
1903
|
+
if (!Array.isArray(group.requests)) {
|
|
1904
|
+
group.requests = [];
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
for (const request of group.requests) {
|
|
1908
|
+
if (request.status === 'pending' && request.expiresAt <= at) request.status = 'expired';
|
|
1909
|
+
}
|
|
1910
|
+
// Bound the file: keep pending plus the most recent decided rows, drop the rest.
|
|
1911
|
+
if (group.requests.length > MAX_PENDING_REQUESTS * 4) {
|
|
1912
|
+
const pending = group.requests.filter((entry) => entry.status === 'pending');
|
|
1913
|
+
const decided = group.requests
|
|
1914
|
+
.filter((entry) => entry.status !== 'pending')
|
|
1915
|
+
.sort((left, right) => (right.decidedAt ?? right.createdAt) - (left.decidedAt ?? left.createdAt))
|
|
1916
|
+
.slice(0, MAX_PENDING_REQUESTS);
|
|
1917
|
+
group.requests = [...pending, ...decided];
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* The channel directory: which groups this host offers, without a credential.
|
|
1923
|
+
*
|
|
1924
|
+
* This is the one read in the whole store that needs no member key, so it is
|
|
1925
|
+
* also the one that has to be most careful about what it returns. The rule is a
|
|
1926
|
+
* single sentence: **say a group exists, never say who is in it or what was
|
|
1927
|
+
* said in it.**
|
|
1928
|
+
*
|
|
1929
|
+
* Concretely, each row carries the group's name, topic, join policy, member
|
|
1930
|
+
* count and last-activity time — the things a person needs to decide whether to
|
|
1931
|
+
* ask for an invite — and nothing else. No member identities, no `personId`
|
|
1932
|
+
* list, no feed text, no invite tokens, no owner id. A caller who wants any of
|
|
1933
|
+
* that must present a member credential and use `members()` / `read()`.
|
|
1934
|
+
*
|
|
1935
|
+
* `unlisted` groups are omitted entirely rather than returned with a flag: an
|
|
1936
|
+
* existence oracle is exactly what "unlisted" is supposed to prevent, and a
|
|
1937
|
+
* per-group flag would still tell a caller that a group with that id exists.
|
|
1938
|
+
*
|
|
1939
|
+
* @param {object} [input]
|
|
1940
|
+
* @param {string} [input.query] optional case-insensitive filter over name+topic
|
|
1941
|
+
* @param {number} [input.limit] page size, clamped
|
|
1942
|
+
* @param {number} [input.offset] page start
|
|
1943
|
+
* @returns {{groups: object[], total: number, returned: number, hasMore: boolean}} the directory page
|
|
1944
|
+
*/
|
|
1945
|
+
function listDirectory(input = {}) {
|
|
1946
|
+
const at = now();
|
|
1947
|
+
const needle = typeof input.query === 'string' ? input.query.trim().toLowerCase() : '';
|
|
1948
|
+
const limit = clampInteger(input.limit, DIRECTORY_DEFAULT_LIMIT, 1, DIRECTORY_MAX_LIMIT);
|
|
1949
|
+
const offset = clampInteger(input.offset, 0, 0, 100_000);
|
|
1950
|
+
|
|
1951
|
+
const visible = state.groups
|
|
1952
|
+
// `unlisted` never appears: see the doc comment for why a flag is not enough.
|
|
1953
|
+
.filter((group) => (group.settings?.visibility ?? 'public') !== 'unlisted')
|
|
1954
|
+
// An archived group is not on offer: it accepts no new members, so listing it would
|
|
1955
|
+
// invite a request nobody can act on.
|
|
1956
|
+
.filter((group) => group.settings?.archived !== true)
|
|
1957
|
+
.filter((group) => needle === ''
|
|
1958
|
+
|| group.name.toLowerCase().includes(needle)
|
|
1959
|
+
|| (typeof group.topic === 'string' && group.topic.toLowerCase().includes(needle)))
|
|
1960
|
+
// Most recently active first: a directory is for finding what is happening
|
|
1961
|
+
// now, and an idle group is rarely the one somebody wants.
|
|
1962
|
+
.sort((left, right) => lastActivityOf(right, at) - lastActivityOf(left, at));
|
|
1963
|
+
|
|
1964
|
+
const page = visible.slice(offset, offset + limit).map((group) => ({
|
|
1965
|
+
groupId: group.groupId,
|
|
1966
|
+
name: group.name,
|
|
1967
|
+
topic: group.topic,
|
|
1968
|
+
// A count, not a roster. "5 people are in this" is useful for judging
|
|
1969
|
+
// whether a group is alive; naming them is a privacy decision the members
|
|
1970
|
+
// made by joining, not one this endpoint gets to make.
|
|
1971
|
+
memberCount: group.members.length,
|
|
1972
|
+
onlineCount: group.members.filter((member) => at - member.lastSeenAt <= ONLINE_WINDOW_MS).length,
|
|
1973
|
+
// Coarse activity: when something last happened, so a caller can tell a live
|
|
1974
|
+
// group from an abandoned one without reading the feed.
|
|
1975
|
+
lastActivityAt: lastActivityOf(group, at),
|
|
1976
|
+
lastActivityAtIso: new Date(lastActivityOf(group, at)).toISOString(),
|
|
1977
|
+
lastSeq: group.nextSeq - 1,
|
|
1978
|
+
createdAt: group.createdAt,
|
|
1979
|
+
createdAtIso: new Date(group.createdAt).toISOString(),
|
|
1980
|
+
}));
|
|
1981
|
+
|
|
1982
|
+
return {
|
|
1983
|
+
groups: page,
|
|
1984
|
+
total: visible.length,
|
|
1985
|
+
returned: page.length,
|
|
1986
|
+
hasMore: offset + page.length < visible.length,
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Force a durable write. Best-effort: a slow disk must not fail a request whose
|
|
1992
|
+
* in-memory state is already correct.
|
|
1993
|
+
*
|
|
1994
|
+
* Retention pruning runs here (and before every write), so an idle host does
|
|
1995
|
+
* not hold feed entries forever just because nobody posted.
|
|
1996
|
+
*
|
|
1997
|
+
* @returns {boolean} whether anything was written
|
|
1998
|
+
*/
|
|
1999
|
+
function flush() {
|
|
2000
|
+
try {
|
|
2001
|
+
prune();
|
|
2002
|
+
if (!dirty) return false;
|
|
2003
|
+
persist();
|
|
2004
|
+
return true;
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
log.warn('group flush failed', { error: error?.message ?? String(error) });
|
|
2007
|
+
return false;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
return {
|
|
2012
|
+
create,
|
|
2013
|
+
invite,
|
|
2014
|
+
join,
|
|
2015
|
+
authenticate,
|
|
2016
|
+
post,
|
|
2017
|
+
read,
|
|
2018
|
+
members,
|
|
2019
|
+
// Layer A: re-declare what I am in this group. Only ever applies to the
|
|
2020
|
+
// caller's own membership — never another member's.
|
|
2021
|
+
// Presence refresh for an open subscription. Separate from the mutators above because it
|
|
2022
|
+
// changes only a timestamp.
|
|
2023
|
+
touch,
|
|
2024
|
+
// One-to-one invitations. A doorbell, not a chat: see the module note above.
|
|
2025
|
+
sendPersonMessage,
|
|
2026
|
+
listPersonMessages,
|
|
2027
|
+
ackPersonMessages,
|
|
2028
|
+
isPersonOnline,
|
|
2029
|
+
setProfile,
|
|
2030
|
+
// The person layer: one profile per person, independent of any group. Kept beside the
|
|
2031
|
+
// group API because it lives in the same file, not because it is the same concept.
|
|
2032
|
+
personProfile,
|
|
2033
|
+
setPersonProfile,
|
|
2034
|
+
listPersons,
|
|
2035
|
+
// Owner-only group administration. `remove` keeps the target's messages, `rename`
|
|
2036
|
+
// fixes a name without losing history, and `archive` retires a group reversibly.
|
|
2037
|
+
removeMember,
|
|
2038
|
+
renameGroup,
|
|
2039
|
+
archiveGroup,
|
|
2040
|
+
leave,
|
|
2041
|
+
revokeInvite,
|
|
2042
|
+
subscribe,
|
|
2043
|
+
snapshot,
|
|
2044
|
+
list,
|
|
2045
|
+
has,
|
|
2046
|
+
lastEntry,
|
|
2047
|
+
// Layer B support: one member's own record, for the profile builder. See the
|
|
2048
|
+
// function's doc for why this needs no credential and cannot enumerate.
|
|
2049
|
+
memberView,
|
|
2050
|
+
// The channel directory: a credential-free listing that says a group exists
|
|
2051
|
+
// and never who is in it. See the function's doc for the exact contract.
|
|
2052
|
+
listDirectory,
|
|
2053
|
+
// The other way in: apply, and let the owner decide. `requestJoin` needs no
|
|
2054
|
+
// credential (the applicant has none yet); `listRequests` and `decideRequest`
|
|
2055
|
+
// need the OWNER's; `claimJoin` needs the applicant's own `claimKey`.
|
|
2056
|
+
requestJoin,
|
|
2057
|
+
listRequests,
|
|
2058
|
+
decideRequest,
|
|
2059
|
+
claimJoin,
|
|
2060
|
+
flush,
|
|
2061
|
+
/** The file this store owns; exposed for diagnostics and tests. */
|
|
2062
|
+
file,
|
|
2063
|
+
/** Directory holding the file. */
|
|
2064
|
+
home: directory,
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
// -------------------------------------------------------------------------
|
|
2068
|
+
// Membership and authentication helpers
|
|
2069
|
+
// -------------------------------------------------------------------------
|
|
2070
|
+
|
|
2071
|
+
/** Look up a group by id, without throwing. */
|
|
2072
|
+
function findGroup(groupId) {
|
|
2073
|
+
if (typeof groupId !== 'string' || groupId === '') return undefined;
|
|
2074
|
+
return state.groups.find((group) => group.groupId === groupId);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/** Look up a group by id, throwing a readable error when absent. */
|
|
2078
|
+
function requireGroup(groupId) {
|
|
2079
|
+
const group = findGroup(groupId);
|
|
2080
|
+
if (group === undefined) {
|
|
2081
|
+
throw new GroupError('unknown_group', `找不到群 ${typeof groupId === 'string' ? groupId : ''}:它可能已被删除,或这个 id 打错了。`);
|
|
2082
|
+
}
|
|
2083
|
+
return group;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* Resolve the caller to a member record, requiring proof of identity.
|
|
2088
|
+
*
|
|
2089
|
+
* Two accepted forms, and only two: an object that came out of
|
|
2090
|
+
* `authenticate()` in this process (unforgeable, tracked in a WeakMap), or an
|
|
2091
|
+
* object carrying `personId` + `personKey`. A bare `{personId}` is rejected —
|
|
2092
|
+
* that is the shape a JSON body has, and accepting it would turn any A2A caller
|
|
2093
|
+
* into any member.
|
|
2094
|
+
*/
|
|
2095
|
+
function resolveMember(group, candidate) {
|
|
2096
|
+
if (!isPlainObject(candidate)) {
|
|
2097
|
+
throw new GroupError('not_member', '缺少成员身份:请先入群,或用 personId + personKey 调用。');
|
|
2098
|
+
}
|
|
2099
|
+
// An authenticated view carries its identity inside the WeakMap, not in a
|
|
2100
|
+
// field, so editing `candidate.personId` cannot change who the caller is.
|
|
2101
|
+
const authenticatedId = authenticated.get(candidate);
|
|
2102
|
+
const personId = authenticatedId ?? (typeof candidate.personId === 'string' ? candidate.personId : '');
|
|
2103
|
+
const record = group.members.find((entry) => entry.personId === personId);
|
|
2104
|
+
|
|
2105
|
+
if (authenticatedId !== undefined) {
|
|
2106
|
+
if (record === undefined) throw new GroupError('not_member', '你已不在这个群里。', { personId });
|
|
2107
|
+
return record;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
const supplied = sha256Hex(typeof candidate.personKey === 'string' ? candidate.personKey : '');
|
|
2111
|
+
if (record === undefined) {
|
|
2112
|
+
compareSecrets(supplied, dummyKeyHash());
|
|
2113
|
+
throw new GroupError('not_member', '成员凭证无效:你已不在这个群里,或凭证已失效。', { personId });
|
|
2114
|
+
}
|
|
2115
|
+
if (!compareSecrets(supplied, record.keyHash)) {
|
|
2116
|
+
throw new GroupError('not_member', '成员凭证无效:你已不在这个群里,或凭证已失效。', { personId });
|
|
2117
|
+
}
|
|
2118
|
+
return record;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* A stand-in digest for the "no such group / no such member" path.
|
|
2123
|
+
*
|
|
2124
|
+
* A fixed, published constant: it lets a failed lookup pay the same
|
|
2125
|
+
* constant-time comparison as a wrong credential so the two are
|
|
2126
|
+
* indistinguishable from outside. It cannot be matched by a real credential —
|
|
2127
|
+
* that would require a sha256 preimage — and it is not a secret, so committing
|
|
2128
|
+
* it to source is the point.
|
|
2129
|
+
*/
|
|
2130
|
+
function dummyKeyHash() {
|
|
2131
|
+
return DUMMY_KEY_HASH;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* Decide when one invite stops working.
|
|
2136
|
+
*
|
|
2137
|
+
* Three cases, and the distinction between the first two is the whole point:
|
|
2138
|
+
*
|
|
2139
|
+
* - the caller passed `expiresInHours: null` → **never** (`null` returned);
|
|
2140
|
+
* - the caller passed a number → that many hours;
|
|
2141
|
+
* - the caller passed nothing → the configured default.
|
|
2142
|
+
*
|
|
2143
|
+
* "Nothing" and "null" must not collapse into one another: a caller who simply
|
|
2144
|
+
* omitted the field is the common case and must get a bounded link, while a
|
|
2145
|
+
* permanent link is a deliberate request. `Object.hasOwn` is what separates them,
|
|
2146
|
+
* and a nonsense value (negative, NaN) falls back to the default rather than
|
|
2147
|
+
* becoming an unintended permanent link.
|
|
2148
|
+
*
|
|
2149
|
+
* @param {object} input - the caller's args.
|
|
2150
|
+
* @param {object} settings - store settings.
|
|
2151
|
+
* @param {number} createdAt - epoch ms the invite is minted.
|
|
2152
|
+
* @returns {number|null} expiry epoch ms, or null for never.
|
|
2153
|
+
*/
|
|
2154
|
+
function resolveInviteExpiry(input, settings, createdAt) {
|
|
2155
|
+
if (Object.hasOwn(input, 'expiresInHours')) {
|
|
2156
|
+
if (input.expiresInHours === null) return null;
|
|
2157
|
+
const hours = Number(input.expiresInHours);
|
|
2158
|
+
if (Number.isFinite(hours) && hours > 0) {
|
|
2159
|
+
return createdAt + Math.round(hours * 3600 * 1000);
|
|
2160
|
+
}
|
|
2161
|
+
// An unusable value is a caller mistake; fall through to the default rather
|
|
2162
|
+
// than minting something permanent by accident.
|
|
2163
|
+
}
|
|
2164
|
+
const fallback = settings.inviteExpiresInHours;
|
|
2165
|
+
if (fallback === null) return null;
|
|
2166
|
+
const hours = Number(fallback);
|
|
2167
|
+
return createdAt + Math.round((Number.isFinite(hours) && hours > 0 ? hours : 168) * 3600 * 1000);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** Find a pending invite by the plaintext token, comparing hashes in constant time. */
|
|
2171
|
+
function findInvite(group, token) {
|
|
2172
|
+
const tokenHash = sha256Hex(token);
|
|
2173
|
+
let found;
|
|
2174
|
+
for (const candidate of group.invites) {
|
|
2175
|
+
// No early exit: comparing every hash keeps the timing independent of which
|
|
2176
|
+
// invite (if any) matched, and the list is small and bounded.
|
|
2177
|
+
if (compareSecrets(tokenHash, candidate.tokenHash)) found = candidate;
|
|
2178
|
+
}
|
|
2179
|
+
return found;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// -------------------------------------------------------------------------
|
|
2183
|
+
// Rate limiting
|
|
2184
|
+
// -------------------------------------------------------------------------
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Spend one token from a member's bucket.
|
|
2188
|
+
*
|
|
2189
|
+
* A plain token bucket (capacity = maxPostsPerMinute, refill = capacity per
|
|
2190
|
+
* minute) rather than a fixed window: a fixed window lets a member send 2x the
|
|
2191
|
+
* limit across a window boundary, which is exactly the burst a spam bot would
|
|
2192
|
+
* use. The bucket is keyed by group+member so two groups do not share a budget.
|
|
2193
|
+
*/
|
|
2194
|
+
function consume(group, member) {
|
|
2195
|
+
const capacity = group.settings.maxPostsPerMinute;
|
|
2196
|
+
const key = `${group.groupId}\u0000${member.personId}`;
|
|
2197
|
+
const at = now();
|
|
2198
|
+
const bucket = buckets.get(key) ?? { tokens: capacity, updatedAt: at };
|
|
2199
|
+
const elapsed = Math.max(0, at - bucket.updatedAt);
|
|
2200
|
+
const refilled = Math.min(capacity, bucket.tokens + (elapsed * capacity) / 60_000);
|
|
2201
|
+
if (refilled < 1) {
|
|
2202
|
+
const waitMs = Math.ceil(((1 - refilled) / capacity) * 60_000);
|
|
2203
|
+
buckets.set(key, { tokens: refilled, updatedAt: at });
|
|
2204
|
+
throw new GroupError('rate_limited', `发得太快了:每分钟最多 ${capacity} 条,请等 ${Math.ceil(waitMs / 1000)} 秒再发。`, {
|
|
2205
|
+
maxPostsPerMinute: capacity,
|
|
2206
|
+
retryAfterMs: waitMs,
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
buckets.set(key, { tokens: refilled - 1, updatedAt: at });
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// -------------------------------------------------------------------------
|
|
2213
|
+
// Feed helpers
|
|
2214
|
+
// -------------------------------------------------------------------------
|
|
2215
|
+
|
|
2216
|
+
/** Append a `kind: 'system'` entry (join / leave / created) and notify subscribers. */
|
|
2217
|
+
function appendSystem(group, text) {
|
|
2218
|
+
if (group.nextSeq === undefined) group.nextSeq = 1;
|
|
2219
|
+
const entry = {
|
|
2220
|
+
seq: group.nextSeq,
|
|
2221
|
+
at: now(),
|
|
2222
|
+
msgId: randomUUID(),
|
|
2223
|
+
kind: 'system',
|
|
2224
|
+
from: { personId: 'system', displayName: '系统' },
|
|
2225
|
+
text,
|
|
2226
|
+
};
|
|
2227
|
+
group.nextSeq += 1;
|
|
2228
|
+
group.feed.push(entry);
|
|
2229
|
+
notify(group, entry);
|
|
2230
|
+
return entry;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/** Fan an entry out to subscribers, isolating failures. */
|
|
2234
|
+
function notify(group, entry) {
|
|
2235
|
+
const set = subscribers.get(group.groupId);
|
|
2236
|
+
if (set === undefined) return;
|
|
2237
|
+
const view = publicEntry(entry);
|
|
2238
|
+
for (const fn of [...set]) {
|
|
2239
|
+
try {
|
|
2240
|
+
// An async subscriber rejects into a floating promise; catch it so it
|
|
2241
|
+
// cannot become an unhandled rejection and take the process down long
|
|
2242
|
+
// after the post that caused it was already acknowledged.
|
|
2243
|
+
const result = fn(view);
|
|
2244
|
+
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
|
|
2245
|
+
result.then(undefined, (error) => {
|
|
2246
|
+
log.warn('subscriber rejected', { groupId: group.groupId, error: error?.message ?? String(error) });
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
log.warn('subscriber threw', { groupId: group.groupId, error: error?.message ?? String(error) });
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// -------------------------------------------------------------------------
|
|
2256
|
+
// Persistence
|
|
2257
|
+
// -------------------------------------------------------------------------
|
|
2258
|
+
|
|
2259
|
+
/** Load the state file, degrading to an empty store only when that is safe. */
|
|
2260
|
+
function loadState() {
|
|
2261
|
+
ensureDirectory();
|
|
2262
|
+
let raw;
|
|
2263
|
+
try {
|
|
2264
|
+
raw = readFileSync(file, 'utf8');
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
if (error?.code === 'ENOENT') return { version: 1, groups: [], persons: [] };
|
|
2267
|
+
// Unreadable for any other reason (permissions, EIO) must not be treated as
|
|
2268
|
+
// "empty": starting empty would silently drop every group and credential.
|
|
2269
|
+
throw new GroupError('storage_unreadable', `无法读取群状态文件 ${file}:${error?.code ?? error?.message ?? 'unknown'}。`, {
|
|
2270
|
+
file,
|
|
2271
|
+
cause: error?.code,
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
try {
|
|
2276
|
+
// A file we just wrote is 0o600, but a file that predates this version may
|
|
2277
|
+
// not be; tightening it on load is cheap and closes the window where an
|
|
2278
|
+
// old install kept credentials world-readable.
|
|
2279
|
+
bestEffortChmod(file, 0o600);
|
|
2280
|
+
const parsed = JSON.parse(raw);
|
|
2281
|
+
const loadedGroups = Array.isArray(parsed?.groups)
|
|
2282
|
+
? parsed.groups.map(sanitizeGroup).filter((group) => group !== undefined)
|
|
2283
|
+
: [];
|
|
2284
|
+
// A file written before this table existed yields an empty one, and `listPersons`
|
|
2285
|
+
// reconstructs names and endpoints from the membership rows. Nobody is lost; they simply
|
|
2286
|
+
// have no headline until somebody sets one.
|
|
2287
|
+
const loadedPersons = Array.isArray(parsed?.persons)
|
|
2288
|
+
? parsed.persons.filter((entry) => isPlainObject(entry) && typeof entry.personId === 'string')
|
|
2289
|
+
: [];
|
|
2290
|
+
const loadedMessages = Array.isArray(parsed?.directMessages)
|
|
2291
|
+
? parsed.directMessages.filter((entry) => isPlainObject(entry) && typeof entry.dmId === 'string')
|
|
2292
|
+
: [];
|
|
2293
|
+
return { version: 1, groups: loadedGroups, persons: loadedPersons, directMessages: loadedMessages };
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
// Corrupt JSON: keep the bytes for a human, start clean, and make noise. The
|
|
2296
|
+
// alternative (refusing to mount) turns a single bad byte into "the plugin
|
|
2297
|
+
// will not start", and the alternative of silently overwriting destroys the
|
|
2298
|
+
// only copy of the credentials.
|
|
2299
|
+
const backup = `${file}.corrupt-${now()}`;
|
|
2300
|
+
try {
|
|
2301
|
+
renameSync(file, backup);
|
|
2302
|
+
} catch {
|
|
2303
|
+
/* best effort */
|
|
2304
|
+
}
|
|
2305
|
+
log.error('groups.json is corrupt; moved aside and starting empty', {
|
|
2306
|
+
file,
|
|
2307
|
+
backup,
|
|
2308
|
+
error: error?.message ?? String(error),
|
|
2309
|
+
});
|
|
2310
|
+
return { version: 1, groups: [], persons: [] };
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
/** Prune feed entries older than the group's retention window. */
|
|
2315
|
+
function prune() {
|
|
2316
|
+
const at = now();
|
|
2317
|
+
for (const group of state.groups) {
|
|
2318
|
+
const retentionMs = group.settings.retentionDays * 86_400_000;
|
|
2319
|
+
if (!Number.isFinite(retentionMs) || retentionMs <= 0) continue;
|
|
2320
|
+
const cutoff = at - retentionMs;
|
|
2321
|
+
const kept = group.feed.filter((entry) => entry.at >= cutoff);
|
|
2322
|
+
if (kept.length !== group.feed.length) {
|
|
2323
|
+
group.feed = kept;
|
|
2324
|
+
dirty = true;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/**
|
|
2330
|
+
* Serialize and write atomically, then clear the dirty flag.
|
|
2331
|
+
*
|
|
2332
|
+
* Serialization happens before any file operation. A `JSON.stringify` that
|
|
2333
|
+
* throws here (a payload mutated by a subscriber between `post` and `persist`,
|
|
2334
|
+
* say) would otherwise leave a temp file behind and the dirty flag set, so the
|
|
2335
|
+
* error is reported as a group error and the state stays loadable from the last
|
|
2336
|
+
* good file.
|
|
2337
|
+
*/
|
|
2338
|
+
function persist() {
|
|
2339
|
+
prune();
|
|
2340
|
+
let payload;
|
|
2341
|
+
try {
|
|
2342
|
+
// One file for everything the host owns. A second file would be a second write that can
|
|
2343
|
+
// half-succeed, and the two halves would drift after a crash.
|
|
2344
|
+
payload = JSON.stringify(
|
|
2345
|
+
{ version: 1, groups: state.groups, persons: [...persons.values()], directMessages },
|
|
2346
|
+
null,
|
|
2347
|
+
2,
|
|
2348
|
+
);
|
|
2349
|
+
} catch (error) {
|
|
2350
|
+
throw new GroupError('bad_argument', `群状态无法序列化:${error?.message ?? String(error)}。`, {
|
|
2351
|
+
cause: error?.name,
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
writeFileAtomic(file, payload);
|
|
2355
|
+
dirty = false;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
/** Create the directory (0o700) if needed, and keep its mode tight. */
|
|
2359
|
+
function ensureDirectory() {
|
|
2360
|
+
try {
|
|
2361
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
throw new GroupError('storage_unreadable', `无法创建群状态目录 ${directory}:${error?.code ?? error?.message ?? 'unknown'}。`, {
|
|
2364
|
+
directory,
|
|
2365
|
+
cause: error?.code,
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
bestEffortChmod(directory, 0o700);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/** Allocate an unused group id. */
|
|
2372
|
+
function allocateGroupId() {
|
|
2373
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
2374
|
+
const candidate = `g-${randomBytes(4).toString('hex')}`;
|
|
2375
|
+
if (!state.groups.some((group) => group.groupId === candidate)) return candidate;
|
|
2376
|
+
}
|
|
2377
|
+
throw new GroupError('group_full', '无法分配新的群 id,请重试。');
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** Allocate an unused member id within the current state. */
|
|
2381
|
+
/**
|
|
2382
|
+
* Read one person's profile, or a blank one for somebody never seen.
|
|
2383
|
+
*
|
|
2384
|
+
* @param {string} personId - the person.
|
|
2385
|
+
* @returns {object} the profile.
|
|
2386
|
+
*/
|
|
2387
|
+
function personProfile(personId) {
|
|
2388
|
+
const record = persons.get(personId);
|
|
2389
|
+
if (record === undefined) {
|
|
2390
|
+
return { personId, displayName: '', headline: '', tags: [], endpoint: '', updatedAt: 0 };
|
|
2391
|
+
}
|
|
2392
|
+
return {
|
|
2393
|
+
personId: record.personId,
|
|
2394
|
+
displayName: record.displayName,
|
|
2395
|
+
headline: record.headline,
|
|
2396
|
+
tags: [...record.tags],
|
|
2397
|
+
endpoint: record.endpoint,
|
|
2398
|
+
updatedAt: record.updatedAt,
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* Update a person's own profile. Self-service only.
|
|
2404
|
+
*
|
|
2405
|
+
* The credential comes from any group the caller belongs to, and it can only ever write
|
|
2406
|
+
* its own row: there is no path here that names somebody else.
|
|
2407
|
+
*
|
|
2408
|
+
* @param {object} input - `{ groupId, member, displayName?, headline?, tags?, endpoint? }`.
|
|
2409
|
+
* @returns {object} the stored profile.
|
|
2410
|
+
*/
|
|
2411
|
+
function setPersonProfile(input = {}) {
|
|
2412
|
+
const group = requireGroup(input.groupId);
|
|
2413
|
+
const member = resolveMember(group, input.member);
|
|
2414
|
+
const at = now();
|
|
2415
|
+
|
|
2416
|
+
const previous = persons.get(member.personId);
|
|
2417
|
+
const record = {
|
|
2418
|
+
personId: member.personId,
|
|
2419
|
+
displayName: optionalText(input.displayName, MAX_DISPLAY_NAME_CHARS, '昵称')
|
|
2420
|
+
?? previous?.displayName
|
|
2421
|
+
?? member.displayName,
|
|
2422
|
+
// A group-independent self-description. An empty string clears it; undefined leaves it.
|
|
2423
|
+
headline: input.headline === undefined
|
|
2424
|
+
? (previous?.headline ?? '')
|
|
2425
|
+
: (input.headline === null ? '' : optionalText(input.headline, MAX_ROLE_ABOUT_CHARS, '简介') ?? ''),
|
|
2426
|
+
tags: input.tags === undefined ? (previous?.tags ?? []) : normalizeTags(input.tags),
|
|
2427
|
+
endpoint: input.endpoint === undefined
|
|
2428
|
+
? (previous?.endpoint ?? '')
|
|
2429
|
+
: (input.endpoint === null ? '' : optionalText(input.endpoint, MAX_ENDPOINT_CHARS, 'endpoint') ?? ''),
|
|
2430
|
+
updatedAt: at,
|
|
2431
|
+
};
|
|
2432
|
+
persons.set(member.personId, record);
|
|
2433
|
+
persist();
|
|
2434
|
+
log.info('person profile updated', { personId: member.personId });
|
|
2435
|
+
return personProfile(member.personId);
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/**
|
|
2439
|
+
* Every known person's profile.
|
|
2440
|
+
*
|
|
2441
|
+
* Built from the table plus the membership rows, so somebody who joined before this table
|
|
2442
|
+
* existed still appears: name and endpoint come from the membership, and the headline stays
|
|
2443
|
+
* empty rather than being invented.
|
|
2444
|
+
*
|
|
2445
|
+
* @returns {object[]} one profile per person.
|
|
2446
|
+
*/
|
|
2447
|
+
function listPersons() {
|
|
2448
|
+
/** personId → the first membership row seen, for fields the table may not have. */
|
|
2449
|
+
const fromMembers = new Map();
|
|
2450
|
+
for (const group of state.groups) {
|
|
2451
|
+
for (const member of group.members) {
|
|
2452
|
+
if (!fromMembers.has(member.personId)) fromMembers.set(member.personId, member);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
const out = [];
|
|
2457
|
+
for (const personId of new Set([...persons.keys(), ...fromMembers.keys()])) {
|
|
2458
|
+
const stored = persons.get(personId);
|
|
2459
|
+
const seen = fromMembers.get(personId);
|
|
2460
|
+
out.push({
|
|
2461
|
+
personId,
|
|
2462
|
+
displayName: stored?.displayName || seen?.displayName || '',
|
|
2463
|
+
headline: stored?.headline ?? '',
|
|
2464
|
+
tags: stored?.tags ?? [],
|
|
2465
|
+
endpoint: stored?.endpoint || seen?.endpoint || '',
|
|
2466
|
+
updatedAt: stored?.updatedAt ?? 0,
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
return out;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/** Record an endpoint a member presented at join time, so others can reach them. */
|
|
2473
|
+
function noteEndpoint(personId, endpoint) {
|
|
2474
|
+
if (typeof endpoint !== 'string' || endpoint === '') return;
|
|
2475
|
+
const previous = persons.get(personId);
|
|
2476
|
+
persons.set(personId, {
|
|
2477
|
+
personId,
|
|
2478
|
+
displayName: previous?.displayName ?? '',
|
|
2479
|
+
headline: previous?.headline ?? '',
|
|
2480
|
+
tags: previous?.tags ?? [],
|
|
2481
|
+
endpoint,
|
|
2482
|
+
updatedAt: now(),
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* A member id for this person, reusing one they already hold when they have it.
|
|
2487
|
+
*
|
|
2488
|
+
* The id identifies a PERSON across every group on this host, so joining a second group
|
|
2489
|
+
* keeps the id instead of minting a new one. That is what makes `@` work the same way
|
|
2490
|
+
* everywhere and lets a colleague card be one card rather than a per-group fragment.
|
|
2491
|
+
*
|
|
2492
|
+
* A preferred id is accepted only when nobody else holds it. If it is taken — a stale value
|
|
2493
|
+
* from another instance, or a hand-written one — a fresh id is allocated rather than
|
|
2494
|
+
* failing: joining a group matters more than the id a caller happened to suggest.
|
|
2495
|
+
*
|
|
2496
|
+
* @param {string} [preferred] an id the caller already owns, e.g. from a previous join.
|
|
2497
|
+
* @returns {string} the member id.
|
|
2498
|
+
*/
|
|
2499
|
+
/**
|
|
2500
|
+
* The member id for this person, reusing the one they already hold.
|
|
2501
|
+
*
|
|
2502
|
+
* The id identifies a PERSON across every group on this host, so joining a second group keeps
|
|
2503
|
+
* it instead of minting a new one. That is what makes `@` work the same way everywhere and
|
|
2504
|
+
* lets a colleague card be one card rather than a per-group fragment.
|
|
2505
|
+
*
|
|
2506
|
+
* A preferred id is honoured unconditionally. It is, by construction, this host's own id for
|
|
2507
|
+
* the caller — it must already appear in the groups the caller belongs to, so checking "is it
|
|
2508
|
+
* taken" would reject exactly the case reuse exists for. The first version did that and minted
|
|
2509
|
+
* a second identity for the same person on every new group.
|
|
2510
|
+
*
|
|
2511
|
+
* A malformed id falls back to a fresh one: joining a group matters more than the exact shape
|
|
2512
|
+
* of a caller-supplied value.
|
|
2513
|
+
*
|
|
2514
|
+
* @param {string} [preferred] the id this person already holds.
|
|
2515
|
+
* @returns {string} the member id.
|
|
2516
|
+
*/
|
|
2517
|
+
function allocatePersonId(preferred) {
|
|
2518
|
+
if (typeof preferred === 'string') {
|
|
2519
|
+
const candidate = preferred.trim();
|
|
2520
|
+
// Keep the shape check: an id from a future or foreign version must not enter this store,
|
|
2521
|
+
// where every other id follows \`m-<hex>\`.
|
|
2522
|
+
if (/^m-[0-9a-f]{4,32}$/u.test(candidate)) return candidate;
|
|
2523
|
+
}
|
|
2524
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
2525
|
+
const candidate = `m-${randomBytes(4).toString('hex')}`;
|
|
2526
|
+
const taken = state.groups.some((group) => group.members.some((member) => member.personId === candidate));
|
|
2527
|
+
if (!taken) return candidate;
|
|
2528
|
+
}
|
|
2529
|
+
throw new GroupError('group_full', '无法分配新的成员 id,请重试。');
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
// -------------------------------------------------------------------------
|
|
2533
|
+
/**
|
|
2534
|
+
* The public shape of one message.
|
|
2535
|
+
*
|
|
2536
|
+
* `payload` is passed through untouched: it is what the receiving client acts on (a link to redeem,
|
|
2537
|
+
* a group to open), and reshaping it here would mean the client's parser and the sender's builder
|
|
2538
|
+
* could disagree.
|
|
2539
|
+
*
|
|
2540
|
+
* @param {object} message - the stored record.
|
|
2541
|
+
* @returns {object} the public copy.
|
|
2542
|
+
*/
|
|
2543
|
+
function publicDirectMessage(message) {
|
|
2544
|
+
return {
|
|
2545
|
+
dmId: message.dmId,
|
|
2546
|
+
fromPersonId: message.fromPersonId,
|
|
2547
|
+
toPersonId: message.toPersonId,
|
|
2548
|
+
kind: message.kind,
|
|
2549
|
+
text: message.text,
|
|
2550
|
+
...(message.payload === undefined ? {} : { payload: message.payload }),
|
|
2551
|
+
createdAt: message.createdAt,
|
|
2552
|
+
deliveredAt: message.deliveredAt,
|
|
2553
|
+
expiresAt: message.expiresAt,
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// Serializers: the only place a record becomes something a caller sees
|
|
2558
|
+
// -------------------------------------------------------------------------
|
|
2559
|
+
|
|
2560
|
+
/**
|
|
2561
|
+
* When something last happened in a group, for directory ordering.
|
|
2562
|
+
*
|
|
2563
|
+
* Uses the newest of three signals rather than the feed alone: a group that was
|
|
2564
|
+
* just created but not yet posted in is alive, and a group whose members keep
|
|
2565
|
+
* arriving without posting is alive too. Falling back to `createdAt` keeps the
|
|
2566
|
+
* value total, so a caller never has to handle "no activity" as a special case.
|
|
2567
|
+
*
|
|
2568
|
+
* @param {object} group - internal group record.
|
|
2569
|
+
* @param {number} at - current time; accepted for signature symmetry, not used
|
|
2570
|
+
* as a candidate because "now" would make every group look equally fresh.
|
|
2571
|
+
* @returns {number} epoch ms.
|
|
2572
|
+
*/
|
|
2573
|
+
function lastActivityOf(group, at) {
|
|
2574
|
+
void at;
|
|
2575
|
+
const lastFeedEntry = group.feed.length === 0 ? 0 : group.feed[group.feed.length - 1].at;
|
|
2576
|
+
const lastMemberSeen = group.members.reduce(
|
|
2577
|
+
(newest, member) => (member.lastSeenAt > newest ? member.lastSeenAt : newest),
|
|
2578
|
+
0,
|
|
2579
|
+
);
|
|
2580
|
+
return Math.max(lastFeedEntry, lastMemberSeen, group.createdAt);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/** Public view of a group. Never includes `passwordHash` or `tokenHash`. */
|
|
2584
|
+
function publicGroup(group) {
|
|
2585
|
+
return {
|
|
2586
|
+
groupId: group.groupId,
|
|
2587
|
+
name: group.name,
|
|
2588
|
+
topic: group.topic,
|
|
2589
|
+
createdAt: group.createdAt,
|
|
2590
|
+
createdAtIso: new Date(group.createdAt).toISOString(),
|
|
2591
|
+
owner: {
|
|
2592
|
+
personId: group.ownerPersonId,
|
|
2593
|
+
displayName: group.members.find((member) => member.personId === group.ownerPersonId)?.displayName,
|
|
2594
|
+
},
|
|
2595
|
+
settings: { ...group.settings },
|
|
2596
|
+
memberCount: group.members.length,
|
|
2597
|
+
// A permanent invite (xpiresAt === null) counts as long as it is not revoked.
|
|
2598
|
+
inviteCount: group.invites.filter((invite) => invite.revoked !== true && (invite.expiresAt === null || invite.expiresAt > now())).length,
|
|
2599
|
+
lastSeq: group.nextSeq - 1,
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
/** Public view of a member. Never includes `keyHash`. */
|
|
2604
|
+
function publicMember(member, at) {
|
|
2605
|
+
return {
|
|
2606
|
+
personId: member.personId,
|
|
2607
|
+
displayName: member.displayName,
|
|
2608
|
+
role: member.role,
|
|
2609
|
+
joinedAt: member.joinedAt,
|
|
2610
|
+
joinedAtIso: new Date(member.joinedAt).toISOString(),
|
|
2611
|
+
lastSeenAt: member.lastSeenAt,
|
|
2612
|
+
lastSeenAtIso: new Date(member.lastSeenAt).toISOString(),
|
|
2613
|
+
online: at - member.lastSeenAt <= ONLINE_WINDOW_MS,
|
|
2614
|
+
endpoint: member.endpoint,
|
|
2615
|
+
// Layer A: what this person is IN THIS GROUP. Deliberately beside `role`
|
|
2616
|
+
// and never folded into it — `role` answers "may they mint invites", this
|
|
2617
|
+
// answers "who are they here". Defaulted so a member stored before these
|
|
2618
|
+
// fields existed reads as "no declared role" rather than a broken view.
|
|
2619
|
+
roleAbout: member.roleAbout,
|
|
2620
|
+
tags: Array.isArray(member.tags) ? [...member.tags] : [],
|
|
2621
|
+
profileUpdatedAt: member.profileUpdatedAt,
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
/** Public view of a feed entry: epoch ms plus ISO, because this is a boundary. */
|
|
2626
|
+
function publicEntry(entry) {
|
|
2627
|
+
const view = {
|
|
2628
|
+
seq: entry.seq,
|
|
2629
|
+
at: entry.at,
|
|
2630
|
+
atIso: new Date(entry.at).toISOString(),
|
|
2631
|
+
msgId: entry.msgId,
|
|
2632
|
+
kind: entry.kind,
|
|
2633
|
+
from: { ...entry.from },
|
|
2634
|
+
text: entry.text,
|
|
2635
|
+
};
|
|
2636
|
+
if (entry.payload !== undefined) view.payload = entry.payload;
|
|
2637
|
+
// Who this post addressed. Omitted when nobody was, so a reader can tell an
|
|
2638
|
+
// ordinary message from one that summons the whole group.
|
|
2639
|
+
if (Array.isArray(entry.mentions) && entry.mentions.length > 0) view.mentions = [...entry.mentions];
|
|
2640
|
+
return view;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/** Group summary with the recent tail, used by `join` so a new member sees context. */
|
|
2644
|
+
function withFeedTail(group, at) {
|
|
2645
|
+
return {
|
|
2646
|
+
...publicGroup(group),
|
|
2647
|
+
onlineCount: group.members.filter((member) => at - member.lastSeenAt <= ONLINE_WINDOW_MS).length,
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// -------------------------------------------------------------------------
|
|
2652
|
+
// Config and coercion
|
|
2653
|
+
// -------------------------------------------------------------------------
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* Read the group settings from either a normalized plugin config or a flat object.
|
|
2657
|
+
*
|
|
2658
|
+
* `inviteExpiresInHours` accepts a number of hours, or `null` for "invites minted
|
|
2659
|
+
* here never expire unless the caller says otherwise". It is read with
|
|
2660
|
+
* `Object.hasOwn` semantics — an absent key means the 7-day default, while an
|
|
2661
|
+
* explicit `null` means permanent. Collapsing the two would make a typo in the
|
|
2662
|
+
* config silently mint permanent public links.
|
|
2663
|
+
*/
|
|
2664
|
+
function readGroupConfig(raw) {
|
|
2665
|
+
const source = isPlainObject(raw?.groups) ? raw.groups : isPlainObject(raw) ? raw : {};
|
|
2666
|
+
const rawExpiry = source.inviteExpiresInHours;
|
|
2667
|
+
const inviteExpiresInHours = rawExpiry === null
|
|
2668
|
+
? null
|
|
2669
|
+
: positiveNumber(rawExpiry, 168);
|
|
2670
|
+
return {
|
|
2671
|
+
inviteExpiresInHours,
|
|
2672
|
+
maxPostChars: positiveInteger(source.maxPostChars, 32_000),
|
|
2673
|
+
maxPostsPerMinute: positiveInteger(source.maxPostsPerMinute, 20),
|
|
2674
|
+
retentionDays: positiveNumber(source.retentionDays, 30),
|
|
2675
|
+
wakeMembers: source.wakeMembers !== false,
|
|
2676
|
+
gatewayBase: typeof source.gatewayBase === 'string' && source.gatewayBase !== '' ? source.gatewayBase : undefined,
|
|
2677
|
+
agentName: typeof source.agentName === 'string' && source.agentName !== '' ? source.agentName : undefined,
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/** Per-group settings, defaulted from the store settings and capped. */
|
|
2682
|
+
function mergeSettings(raw) {
|
|
2683
|
+
const source = isPlainObject(raw) ? raw : {};
|
|
2684
|
+
return {
|
|
2685
|
+
maxPostChars: clampInteger(source.maxPostChars, settings.maxPostChars, 1, 1_048_576),
|
|
2686
|
+
maxPostsPerMinute: clampInteger(source.maxPostsPerMinute, settings.maxPostsPerMinute, 1, 10_000),
|
|
2687
|
+
retentionDays: clampInteger(source.retentionDays, settings.retentionDays, 1, 3650),
|
|
2688
|
+
wakeMembers: typeof source.wakeMembers === 'boolean' ? source.wakeMembers : settings.wakeMembers,
|
|
2689
|
+
/**
|
|
2690
|
+
* Whether the group appears in a channel directory.
|
|
2691
|
+
*
|
|
2692
|
+
* `public` means: listed by name, topic, member COUNT and join policy —
|
|
2693
|
+
* never by member identities or feed content. `unlisted` means a host will
|
|
2694
|
+
* not even confirm it exists to someone who is not a member; joining still
|
|
2695
|
+
* works from an invite link, which is the ordinary case for a sensitive
|
|
2696
|
+
* project.
|
|
2697
|
+
*
|
|
2698
|
+
* Defaulted to `public` deliberately: directories are useful only when they
|
|
2699
|
+
* are populated, and a group whose existence is a secret can be marked so at
|
|
2700
|
+
* creation time.
|
|
2701
|
+
*/
|
|
2702
|
+
visibility: source.visibility === 'unlisted' ? 'unlisted' : 'public',
|
|
2703
|
+
/**
|
|
2704
|
+
* Whether the group is archived: readable, not writable, absent from the directory.
|
|
2705
|
+
*
|
|
2706
|
+
* Not a delete. A finished project's conversation is worth keeping, and re-opening
|
|
2707
|
+
* an archive by mistake must be possible — a delete is not.
|
|
2708
|
+
*/
|
|
2709
|
+
archived: source.archived === true,
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
/** Normalize a loaded group record, or drop it when it cannot be trusted. */
|
|
2714
|
+
function sanitizeGroup(raw) {
|
|
2715
|
+
if (!isPlainObject(raw)) return undefined;
|
|
2716
|
+
const groupId = typeof raw.groupId === 'string' ? raw.groupId : undefined;
|
|
2717
|
+
if (groupId === undefined || !Array.isArray(raw.members) || !Array.isArray(raw.feed)) return undefined;
|
|
2718
|
+
const members = raw.members.filter(
|
|
2719
|
+
(member) =>
|
|
2720
|
+
isPlainObject(member) &&
|
|
2721
|
+
typeof member.personId === 'string' &&
|
|
2722
|
+
typeof member.keyHash === 'string' &&
|
|
2723
|
+
member.keyHash.length === 64,
|
|
2724
|
+
);
|
|
2725
|
+
if (members.length === 0) return undefined; // a group with no valid member cannot authenticate anyone
|
|
2726
|
+
const nextSeqCandidate = Number.isInteger(raw.nextSeq) ? raw.nextSeq : 1;
|
|
2727
|
+
const highestSeq = raw.feed.reduce((max, entry) => (Number.isInteger(entry?.seq) && entry.seq > max ? entry.seq : max), 0);
|
|
2728
|
+
return {
|
|
2729
|
+
groupId,
|
|
2730
|
+
name: typeof raw.name === 'string' ? raw.name : groupId,
|
|
2731
|
+
topic: typeof raw.topic === 'string' ? raw.topic : '',
|
|
2732
|
+
createdAt: Number.isFinite(raw.createdAt) ? raw.createdAt : now(),
|
|
2733
|
+
ownerPersonId: members.some((member) => member.personId === raw.ownerPersonId)
|
|
2734
|
+
? raw.ownerPersonId
|
|
2735
|
+
: members.find((member) => member.role === 'owner')?.personId,
|
|
2736
|
+
settings: mergeSettings(raw.settings),
|
|
2737
|
+
invites: Array.isArray(raw.invites)
|
|
2738
|
+
? raw.invites.filter((invite) => isPlainObject(invite) && typeof invite.tokenHash === 'string' && invite.tokenHash.length === 64)
|
|
2739
|
+
: [],
|
|
2740
|
+
// The application queue survives a restart: a person who applied and is
|
|
2741
|
+
// waiting must not be silently dropped because the host rebooted. Only rows
|
|
2742
|
+
// that carry the fields an approval needs are kept — a half-written row could
|
|
2743
|
+
// never be acted on, and keeping it would show the owner a name with no way
|
|
2744
|
+
// to say yes.
|
|
2745
|
+
requests: Array.isArray(raw.requests)
|
|
2746
|
+
? raw.requests.filter((request) => isPlainObject(request)
|
|
2747
|
+
&& typeof request.requestId === 'string'
|
|
2748
|
+
&& request.requestId !== ''
|
|
2749
|
+
&& typeof request.displayName === 'string'
|
|
2750
|
+
&& typeof request.claimHash === 'string'
|
|
2751
|
+
&& request.claimHash.length === 64
|
|
2752
|
+
&& Number.isFinite(request.createdAt)
|
|
2753
|
+
&& Number.isFinite(request.expiresAt)
|
|
2754
|
+
&& typeof request.status === 'string')
|
|
2755
|
+
: [],
|
|
2756
|
+
members,
|
|
2757
|
+
// A feed entry survives a reload with its mentions intact; without this the
|
|
2758
|
+
// addressing would be lost on every restart and every wake decision would fall
|
|
2759
|
+
// back to "ordinary message".
|
|
2760
|
+
feed: raw.feed
|
|
2761
|
+
.filter((entry) => isPlainObject(entry) && Number.isInteger(entry.seq))
|
|
2762
|
+
.map((entry) => {
|
|
2763
|
+
if (!Array.isArray(entry.mentions)) return entry;
|
|
2764
|
+
const mentions = entry.mentions.filter((item) => typeof item === 'string' && item !== '');
|
|
2765
|
+
return mentions.length === 0 ? { ...entry, mentions: undefined } : { ...entry, mentions };
|
|
2766
|
+
}),
|
|
2767
|
+
nextSeq: Math.max(nextSeqCandidate, highestSeq + 1),
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// ---------------------------------------------------------------------------
|
|
2773
|
+
// Module-level helpers (no store state)
|
|
2774
|
+
// ---------------------------------------------------------------------------
|
|
2775
|
+
|
|
2776
|
+
/**
|
|
2777
|
+
* Write a file atomically: temp file in the same directory, fsync, rename.
|
|
2778
|
+
*
|
|
2779
|
+
* `rename` within a directory is atomic on Windows and POSIX, so a reader (or a
|
|
2780
|
+
* crash) sees either the old file or the new one — never a half-written
|
|
2781
|
+
* `groups.json`, which for this file would mean losing credentials. The temp
|
|
2782
|
+
* file is created `0o600` so there is no instant where the content exists with
|
|
2783
|
+
* looser permissions, and it is removed on failure so a crash loop cannot litter
|
|
2784
|
+
* the directory with credential copies.
|
|
2785
|
+
*
|
|
2786
|
+
* @param {string} file destination path
|
|
2787
|
+
* @param {string} content the serialized state
|
|
2788
|
+
*/
|
|
2789
|
+
export function writeFileAtomic(file, content) {
|
|
2790
|
+
const temp = `${file}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
2791
|
+
let fd;
|
|
2792
|
+
try {
|
|
2793
|
+
fd = openSync(temp, 'wx', 0o600);
|
|
2794
|
+
writeSync(fd, content, null, 'utf8');
|
|
2795
|
+
fsyncSync(fd);
|
|
2796
|
+
closeSync(fd);
|
|
2797
|
+
fd = undefined;
|
|
2798
|
+
renameSync(temp, file);
|
|
2799
|
+
} catch (error) {
|
|
2800
|
+
if (fd !== undefined) {
|
|
2801
|
+
try {
|
|
2802
|
+
closeSync(fd);
|
|
2803
|
+
} catch {
|
|
2804
|
+
/* best effort */
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
try {
|
|
2808
|
+
unlinkSync(temp);
|
|
2809
|
+
} catch {
|
|
2810
|
+
/* best effort */
|
|
2811
|
+
}
|
|
2812
|
+
throw error;
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
/** `chmod` that ignores failure: Windows has no POSIX mode, and that is fine. */
|
|
2817
|
+
function bestEffortChmod(path, mode) {
|
|
2818
|
+
try {
|
|
2819
|
+
chmodSync(path, mode);
|
|
2820
|
+
} catch {
|
|
2821
|
+
/* Windows: mode is advisory; the code still passes it for POSIX hosts. */
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
/** Resolve the state directory from config, mirroring `config.resolveHome`. */
|
|
2826
|
+
function resolveDirectory(raw) {
|
|
2827
|
+
const source = isPlainObject(raw?.groups) ? raw.groups : isPlainObject(raw) ? raw : {};
|
|
2828
|
+
if (typeof source.home === 'string' && source.home !== '') return source.home;
|
|
2829
|
+
const env = process.env;
|
|
2830
|
+
const base = typeof env.DSH_HOME === 'string' && env.DSH_HOME !== '' ? env.DSH_HOME : process.cwd();
|
|
2831
|
+
return `${base.replace(/[\\/]+$/u, '')}/a2a`;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/** A logger that tolerates a missing or partially-shaped one. */
|
|
2835
|
+
function createLogger(logger) {
|
|
2836
|
+
const call = (level, message, details) => {
|
|
2837
|
+
const fn = logger?.[level];
|
|
2838
|
+
if (typeof fn !== 'function') return;
|
|
2839
|
+
try {
|
|
2840
|
+
fn.call(logger, message, details);
|
|
2841
|
+
} catch {
|
|
2842
|
+
/* logging must never break a state transition */
|
|
2843
|
+
}
|
|
2844
|
+
};
|
|
2845
|
+
return {
|
|
2846
|
+
info: (message, details) => call('info', message, details),
|
|
2847
|
+
warn: (message, details) => call('warn', message, details),
|
|
2848
|
+
error: (message, details) => call('error', message, details),
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/**
|
|
2853
|
+
* A secret in a log line: `sha256:` + first 8 hex characters.
|
|
2854
|
+
*
|
|
2855
|
+
* Enough to correlate two log lines about the same token, useless for using it.
|
|
2856
|
+
* This is the contract's §0.6 rule, implemented once so no call site can forget.
|
|
2857
|
+
*
|
|
2858
|
+
* @param {unknown} secret
|
|
2859
|
+
* @returns {string}
|
|
2860
|
+
*/
|
|
2861
|
+
export function redact(secret) {
|
|
2862
|
+
if (typeof secret !== 'string' || secret === '') return 'sha256:';
|
|
2863
|
+
return `sha256:${sha256Hex(secret).slice(0, 8)}`;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/**
|
|
2867
|
+
* Clean a member's self-declared tags.
|
|
2868
|
+
*
|
|
2869
|
+
* Tags are free text on purpose: "兼产品", "临时支援" and "PM" are all legitimate in
|
|
2870
|
+
* a real team, and forcing a fixed vocabulary would make people pick the wrong
|
|
2871
|
+
* one. What is enforced is only what would break the display or the file:
|
|
2872
|
+
* trimming, case-insensitive de-duplication (keeping the first spelling), the
|
|
2873
|
+
* count cap, and the length cap. An empty array is a legitimate value meaning "no
|
|
2874
|
+
* tags", which is why `[]` clears rather than being ignored.
|
|
2875
|
+
*
|
|
2876
|
+
* @param {unknown} value - the caller's tags.
|
|
2877
|
+
* @returns {string[]} normalized tags; `[]` for anything unusable.
|
|
2878
|
+
*/
|
|
2879
|
+
export function normalizeTags(value) {
|
|
2880
|
+
if (!Array.isArray(value)) return [];
|
|
2881
|
+
const seen = new Set();
|
|
2882
|
+
const out = [];
|
|
2883
|
+
for (const item of value) {
|
|
2884
|
+
if (typeof item !== 'string') continue;
|
|
2885
|
+
const tag = item.trim();
|
|
2886
|
+
if (tag === '' || tag.length > MAX_TAG_CHARS) continue;
|
|
2887
|
+
const key = tag.toLowerCase();
|
|
2888
|
+
if (seen.has(key)) continue;
|
|
2889
|
+
seen.add(key);
|
|
2890
|
+
out.push(tag);
|
|
2891
|
+
if (out.length >= MAX_TAGS) break;
|
|
2892
|
+
}
|
|
2893
|
+
return out;
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
/** Require a non-blank, length-capped string; throws a readable group error. */
|
|
2897
|
+
function requireText(value, field, maxChars, label) { if (typeof value !== 'string' || value.trim() === '') {
|
|
2898
|
+
throw new GroupError('bad_argument', `${label}(${field})不能为空。`, { field });
|
|
2899
|
+
}
|
|
2900
|
+
const trimmed = value.trim();
|
|
2901
|
+
if (trimmed.length > maxChars) {
|
|
2902
|
+
throw new GroupError('bad_argument', `${label}过长:最多 ${maxChars} 字。`, { field, length: trimmed.length });
|
|
2903
|
+
}
|
|
2904
|
+
return trimmed;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
/** Optional string: `undefined` when absent or blank, an error when over-long. */
|
|
2908
|
+
function optionalText(value, maxChars, label) {
|
|
2909
|
+
if (value === undefined || value === null) return undefined;
|
|
2910
|
+
if (typeof value !== 'string') throw new GroupError('bad_argument', `${label}必须是字符串。`);
|
|
2911
|
+
const trimmed = value.trim();
|
|
2912
|
+
if (trimmed === '') return undefined;
|
|
2913
|
+
if (trimmed.length > maxChars) {
|
|
2914
|
+
throw new GroupError('bad_argument', `${label}过长:最多 ${maxChars} 字。`, { length: trimmed.length });
|
|
2915
|
+
}
|
|
2916
|
+
return trimmed;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
/** A positive number, or the fallback when unusable. */
|
|
2920
|
+
function positiveNumber(value, fallback) {
|
|
2921
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
2922
|
+
if (!Number.isFinite(number) || number <= 0) return fallback;
|
|
2923
|
+
return number;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** A positive integer, or the fallback when unusable. */
|
|
2927
|
+
function positiveInteger(value, fallback) {
|
|
2928
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
2929
|
+
if (!Number.isFinite(number) || number <= 0) return fallback;
|
|
2930
|
+
return Math.trunc(number);
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
/** An integer clamped into `[min, max]`, falling back when not a number. */
|
|
2934
|
+
function clampInteger(value, fallback, min, max) {
|
|
2935
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
2936
|
+
if (!Number.isFinite(number)) return fallback;
|
|
2937
|
+
return Math.min(max, Math.max(min, Math.trunc(number)));
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** `read`'s page size: default when absent, clamped when silly. */
|
|
2941
|
+
function clampLimit(value) {
|
|
2942
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
2943
|
+
if (!Number.isFinite(number) || number <= 0) return READ_LIMIT_DEFAULT;
|
|
2944
|
+
return Math.min(READ_LIMIT_MAX, Math.max(1, Math.trunc(number)));
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
/** A number from an optional input, falling back when absent or invalid. */
|
|
2948
|
+
function numberOf(value, fallback) {
|
|
2949
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
2950
|
+
return Number.isFinite(number) ? number : fallback;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
/**
|
|
2954
|
+
* Reject a payload that could not survive `JSON.stringify`.
|
|
2955
|
+
*
|
|
2956
|
+
* `payload` is caller-supplied structure that gets persisted verbatim. A cyclic
|
|
2957
|
+
* object or a BigInt would throw from inside `persist()` *after* the feed was
|
|
2958
|
+
* mutated in memory, which is the worst moment to fail: the caller sees an error,
|
|
2959
|
+
* the file disagrees, and the next write silently drops the entry. Checking it
|
|
2960
|
+
* before the mutation keeps memory and disk in step.
|
|
2961
|
+
*
|
|
2962
|
+
* @param {unknown} payload
|
|
2963
|
+
*/
|
|
2964
|
+
function validatePayload(payload) {
|
|
2965
|
+
if (payload === undefined) return;
|
|
2966
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
2967
|
+
throw new GroupError('bad_argument', 'payload 必须是对象。');
|
|
2968
|
+
}
|
|
2969
|
+
let serialized;
|
|
2970
|
+
try {
|
|
2971
|
+
serialized = JSON.stringify(payload);
|
|
2972
|
+
} catch {
|
|
2973
|
+
throw new GroupError('bad_argument', 'payload 里含有无法序列化为 JSON 的值(循环引用或 BigInt)。');
|
|
2974
|
+
}
|
|
2975
|
+
if (typeof serialized !== 'string') {
|
|
2976
|
+
throw new GroupError('bad_argument', 'payload 必须是可序列化为 JSON 对象的对象。');
|
|
2977
|
+
}
|
|
2978
|
+
if (serialized.length > MAX_PAYLOAD_BYTES) {
|
|
2979
|
+
throw new GroupError('too_long', `payload 超过 ${MAX_PAYLOAD_BYTES} 字节上限,请改用附件路径。`, {
|
|
2980
|
+
bytes: serialized.length,
|
|
2981
|
+
maxBytes: MAX_PAYLOAD_BYTES,
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
/** Whether a value is a plain JSON object (not an array, not null). */
|
|
2987
|
+
function isPlainObject(value) {
|
|
2988
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
/**
|
|
2992
|
+
* Freeze a public view, recursively.
|
|
2993
|
+
*
|
|
2994
|
+
* The views this module returns are the boundary between "verified" and "caller
|
|
2995
|
+
* controlled": a caller that holds a member view from `authenticate()` and can
|
|
2996
|
+
* still assign to it is a caller that can try to rename the identity it was
|
|
2997
|
+
* verified as. `null` prototype is not used (a view is a normal object for
|
|
2998
|
+
* JSON.stringify and for the UI), freezing is enough — the views never need to
|
|
2999
|
+
* be mutated by anyone, including us.
|
|
3000
|
+
*
|
|
3001
|
+
* @template T
|
|
3002
|
+
* @param {T} value
|
|
3003
|
+
* @returns {T}
|
|
3004
|
+
*/
|
|
3005
|
+
function deepFreeze(value) {
|
|
3006
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
3007
|
+
Object.freeze(value);
|
|
3008
|
+
for (const item of Object.values(value)) deepFreeze(item);
|
|
3009
|
+
return value;
|
|
3010
|
+
}
|