@mh-gg/room-security 0.1.1-alpha.20260613T085325975Z

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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@mh-gg/room-security",
3
+ "version": "0.1.1-alpha.20260613T085325975Z",
4
+ "description": "Standalone room membership, invite, ban, and key epoch security library for matterhorn.",
5
+ "type": "commonjs",
6
+ "main": "src/index.cjs",
7
+ "exports": {
8
+ ".": "./src/index.cjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.12"
12
+ },
13
+ "dependencies": {
14
+ "@mh-gg/protocol": "^0.1.1-alpha.20260613T085325975Z"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test test/*.test.cjs",
18
+ "coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-include=src/*.cjs test/*.test.cjs"
19
+ }
20
+ }
package/src/auth.cjs ADDED
@@ -0,0 +1,93 @@
1
+ const { findInviteByProof } = require("./invites.cjs");
2
+ const { createJoinRequest } = require("./joinRequests.cjs");
3
+ const {
4
+ createMemberCredential,
5
+ issueMemberSecret,
6
+ memberForProfile,
7
+ normalizeMemberSecret,
8
+ verifyMemberProof
9
+ } = require("./members.cjs");
10
+ const { ensureRoomSecurity } = require("./store.cjs");
11
+ const { text } = require("./values.cjs");
12
+
13
+ function sanitizeProfile(profile, clientId) {
14
+ const callPubkey = typeof profile?.callPubkey === "string" && /^[0-9a-f]{64}$/i.test(profile.callPubkey)
15
+ ? profile.callPubkey.toLowerCase()
16
+ : undefined;
17
+ const sanitized = {
18
+ id: text(profile?.id || clientId, 120),
19
+ name: text(profile?.name || "Guest", 80),
20
+ avatar: text(profile?.avatar || "✨", 64)
21
+ };
22
+ if (callPubkey) sanitized.callPubkey = callPubkey;
23
+ return sanitized;
24
+ }
25
+
26
+ function authenticateMember(store, message, profile, now) {
27
+ const member = store.members?.[message.auth.id];
28
+ if (!member || member.profileId !== message.clientId) {
29
+ return { ok: false, code: "bad-member", message: "Member credential was not recognized." };
30
+ }
31
+ if (member.bannedAt) return { ok: false, code: "banned", message: "This member is banned from the room." };
32
+
33
+ const verification = verifyMemberProof(member, message.auth.proof, store.roomName, message.clientId, profile.callPubkey);
34
+ if (!verification.ok) return { ok: false, code: "bad-member", message: "Member credential proof did not match." };
35
+
36
+ member.lastSeenAt = now;
37
+ return { ok: true, profile, member, memberSecret: verification.memberSecret };
38
+ }
39
+
40
+ function authenticateInvite(store, message, profile, now) {
41
+ const invite = findInviteByProof(store, message.auth, store.roomName, message.clientId, profile.callPubkey);
42
+ if (!invite) return { ok: false, code: "bad-secret", message: "Room invite proof did not match." };
43
+ if (invite.status === "disabled" || invite.status === "removed") {
44
+ return { ok: false, code: "invite-disabled", message: "This invite is no longer active." };
45
+ }
46
+ if (invite.status === "retired") {
47
+ const request = createJoinRequest(store, invite, profile, now);
48
+ return { ok: false, joinRequested: true, request };
49
+ }
50
+ if ((invite.type || "public") === "member" && invite.claimedBy) {
51
+ return { ok: false, code: "invite-claimed", message: "This invite has already been claimed." };
52
+ }
53
+
54
+ let member = memberForProfile(store, message.clientId);
55
+ if (member?.bannedAt) return { ok: false, code: "banned", message: "This member is banned from the room." };
56
+
57
+ let memberSecret;
58
+ if (!member) {
59
+ const credential = createMemberCredential(store, profile, "guest", now);
60
+ member = credential.member;
61
+ memberSecret = credential.memberSecret;
62
+ } else {
63
+ normalizeMemberSecret(member);
64
+ memberSecret = issueMemberSecret(member);
65
+ member.lastSeenAt = now;
66
+ }
67
+
68
+ if ((invite.type || "public") === "member") {
69
+ invite.claimedBy = member.id;
70
+ invite.status = "claimed";
71
+ invite.claimedAt = now;
72
+ }
73
+ return { ok: true, profile, member, memberSecret };
74
+ }
75
+
76
+ function authenticateClientHello(store, message, now = Date.now()) {
77
+ ensureRoomSecurity(store, now);
78
+ const profile = sanitizeProfile(message.profile, message.clientId);
79
+ if (profile.id !== message.clientId) {
80
+ return { ok: false, code: "bad-profile", message: "Client profile did not match the connection." };
81
+ }
82
+ if (store.state.guests[profile.id]?.bannedAt) {
83
+ return { ok: false, code: "banned", message: "This member is banned from the room." };
84
+ }
85
+
86
+ if (message.auth?.type === "member") return authenticateMember(store, message, profile, now);
87
+ return authenticateInvite(store, message, profile, now);
88
+ }
89
+
90
+ module.exports = {
91
+ authenticateClientHello,
92
+ sanitizeProfile
93
+ };
@@ -0,0 +1,7 @@
1
+ const RETIRED_KEY_TTL_MS = 90 * 24 * 60 * 60 * 1000;
2
+ const DEFAULT_HISTORY_VISIBILITY = "retained-keyring";
3
+
4
+ module.exports = {
5
+ DEFAULT_HISTORY_VISIBILITY,
6
+ RETIRED_KEY_TTL_MS
7
+ };
package/src/crypto.cjs ADDED
@@ -0,0 +1,65 @@
1
+ const crypto = require("node:crypto");
2
+ const { createSnowflakeGenerator, prefixedSnowflakeId } = require("@mh-gg/protocol");
3
+
4
+ const snowflakes = createSnowflakeGenerator({ nodeId: crypto.randomBytes(2).readUInt16BE(0) & 1023 });
5
+
6
+ function randomId(prefix = "id") {
7
+ return prefixedSnowflakeId(prefix, snowflakes.next());
8
+ }
9
+
10
+ function randomSecret(byteLength = 24) {
11
+ return crypto.randomBytes(byteLength).toString("base64url");
12
+ }
13
+
14
+ function sha256Hex(value) {
15
+ return crypto.createHash("sha256").update(value).digest("hex");
16
+ }
17
+
18
+ function normalizeProofString(value) {
19
+ return String(value || "").normalize("NFC");
20
+ }
21
+
22
+ function normalizeCallPubkey(callPubkey) {
23
+ return callPubkey ? normalizeProofString(callPubkey).toLowerCase() : null;
24
+ }
25
+
26
+ function proofPreimage(purpose, secret, roomName, clientId, callPubkey) {
27
+ return JSON.stringify([
28
+ "matterhorn.secret-proof.v2",
29
+ normalizeProofString(purpose),
30
+ normalizeProofString(secret),
31
+ normalizeProofString(roomName),
32
+ normalizeProofString(clientId),
33
+ normalizeCallPubkey(callPubkey)
34
+ ]);
35
+ }
36
+
37
+ function secretProof(secret, roomName, clientId, callPubkey) {
38
+ return sha256Hex(proofPreimage("invite", secret, roomName, clientId, callPubkey));
39
+ }
40
+
41
+ function memberSecretHash(memberSecret) {
42
+ return sha256Hex(JSON.stringify([
43
+ "matterhorn.member-secret-hash.v2",
44
+ normalizeProofString(memberSecret)
45
+ ]));
46
+ }
47
+
48
+ function memberSecretProofFromHash(secretHash, roomName, clientId, callPubkey) {
49
+ return sha256Hex(proofPreimage("member", secretHash, roomName, clientId, callPubkey));
50
+ }
51
+
52
+ function memberSecretProof(memberSecret, roomName, clientId, callPubkey) {
53
+ return memberSecretProofFromHash(memberSecretHash(memberSecret), roomName, clientId, callPubkey);
54
+ }
55
+
56
+ module.exports = {
57
+ memberSecretHash,
58
+ memberSecretProof,
59
+ memberSecretProofFromHash,
60
+ proofPreimage,
61
+ randomId,
62
+ randomSecret,
63
+ secretProof,
64
+ sha256Hex
65
+ };
package/src/index.cjs ADDED
@@ -0,0 +1,15 @@
1
+ module.exports = {
2
+ ...require("./auth.cjs"),
3
+ ...require("./constants.cjs"),
4
+ ...require("./crypto.cjs"),
5
+ ...require("./invites.cjs"),
6
+ ...require("./joinRequests.cjs"),
7
+ ...require("./keys.cjs"),
8
+ ...require("./members.cjs"),
9
+ ...require("./moderation.cjs"),
10
+ ...require("./roomIndexKey.cjs"),
11
+ ...require("./rotation.cjs"),
12
+ ...require("./store.cjs"),
13
+ ...require("./timing.cjs"),
14
+ ...require("./views.cjs")
15
+ };
@@ -0,0 +1,75 @@
1
+ const { randomId, randomSecret, secretProof } = require("./crypto.cjs");
2
+ const { constantTimeEqualHex } = require("./timing.cjs");
3
+ const { fail } = require("./values.cjs");
4
+
5
+ function publicInviteForClient(invite) {
6
+ if (!invite) return undefined;
7
+ return {
8
+ id: invite.id,
9
+ type: invite.type || "public",
10
+ status: invite.status,
11
+ secret: invite.secret,
12
+ createdAt: invite.createdAt,
13
+ retiredAt: invite.retiredAt,
14
+ disabledAt: invite.disabledAt,
15
+ removedAt: invite.removedAt,
16
+ claimedBy: invite.claimedBy
17
+ };
18
+ }
19
+
20
+ function activePublicInvite(store) {
21
+ return (store.publicInvites || []).find((invite) => (invite.type || "public") === "public" && invite.status === "active");
22
+ }
23
+
24
+ function createPublicInvite(store, now = Date.now(), type = "public") {
25
+ const invite = {
26
+ id: randomId("invite"),
27
+ type,
28
+ secret: randomSecret(),
29
+ status: "active",
30
+ createdAt: now
31
+ };
32
+ store.publicInvites.push(invite);
33
+ if (type === "public") store.roomSecret = invite.secret;
34
+ return invite;
35
+ }
36
+
37
+ function disableInvite(store, inviteId, now = Date.now()) {
38
+ const invite = (store.publicInvites || []).find((item) => item.id === inviteId);
39
+ if (!invite || invite.status === "removed") return fail("Invite not found.");
40
+ if (invite.status === "active") {
41
+ invite.status = "disabled";
42
+ invite.disabledAt = now;
43
+ }
44
+ return { ok: true, invite };
45
+ }
46
+
47
+ function removeInvite(store, inviteId, now = Date.now()) {
48
+ const invite = (store.publicInvites || []).find((item) => item.id === inviteId);
49
+ if (!invite) return fail("Invite not found.");
50
+ invite.status = "removed";
51
+ invite.removedAt = now;
52
+ return { ok: true, invite };
53
+ }
54
+
55
+ function findInviteByProof(store, auth, roomName, clientId, callPubkey) {
56
+ const invites = store.publicInvites || [];
57
+ if (auth?.type !== "invite" || !auth.proof) return undefined;
58
+
59
+ if (auth.id) {
60
+ const invite = invites.find((item) => item.id === auth.id);
61
+ if (!invite) return undefined;
62
+ return constantTimeEqualHex(secretProof(invite.secret, roomName, clientId, callPubkey), auth.proof) ? invite : undefined;
63
+ }
64
+
65
+ return invites.find((invite) => constantTimeEqualHex(secretProof(invite.secret, roomName, clientId, callPubkey), auth.proof));
66
+ }
67
+
68
+ module.exports = {
69
+ activePublicInvite,
70
+ createPublicInvite,
71
+ disableInvite,
72
+ findInviteByProof,
73
+ publicInviteForClient,
74
+ removeInvite
75
+ };
@@ -0,0 +1,55 @@
1
+ const { randomId } = require("./crypto.cjs");
2
+ const { createMemberCredential, memberForProfile } = require("./members.cjs");
3
+ const { fail, text } = require("./values.cjs");
4
+
5
+ function createJoinRequest(store, invite, profile, now = Date.now()) {
6
+ const existing = Object.values(store.joinRequests || {}).find((request) => {
7
+ return request.status === "pending" && request.inviteId === invite.id && request.profileId === profile.id;
8
+ });
9
+ if (existing) return existing;
10
+
11
+ const request = {
12
+ id: randomId("join"),
13
+ inviteId: invite.id,
14
+ profileId: profile.id,
15
+ profile,
16
+ status: "pending",
17
+ createdAt: now
18
+ };
19
+ store.joinRequests[request.id] = request;
20
+ return request;
21
+ }
22
+
23
+ function approveJoinRequest(store, requestId, now = Date.now()) {
24
+ const request = store.joinRequests[text(requestId, 120)];
25
+ if (!request || request.status !== "pending") return fail("Join request not found.");
26
+ let member = memberForProfile(store, request.profileId);
27
+ if (member?.bannedAt) return fail("This member is banned from the room.");
28
+
29
+ let memberSecret;
30
+ if (!member) {
31
+ const credential = createMemberCredential(store, request.profile, "guest", now);
32
+ member = credential.member;
33
+ memberSecret = credential.memberSecret;
34
+ }
35
+
36
+ request.status = "approved";
37
+ request.decidedAt = now;
38
+ request.memberId = member.id;
39
+ if (memberSecret) request.memberSecret = memberSecret;
40
+ return { ok: true, request, member, memberSecret };
41
+ }
42
+
43
+ function denyJoinRequest(store, requestId, now = Date.now()) {
44
+ const request = store.joinRequests[text(requestId, 120)];
45
+ if (!request || request.status !== "pending") return fail("Join request not found.");
46
+ request.status = "denied";
47
+ request.decidedAt = now;
48
+ return { ok: true, request };
49
+ }
50
+
51
+ module.exports = {
52
+ approveJoinRequest,
53
+ createJoinRequest,
54
+ denyJoinRequest
55
+ };
package/src/keys.cjs ADDED
@@ -0,0 +1,190 @@
1
+ const crypto = require("node:crypto");
2
+ const { DEFAULT_HISTORY_VISIBILITY, RETIRED_KEY_TTL_MS } = require("./constants.cjs");
3
+ const { randomId, randomSecret } = require("./crypto.cjs");
4
+
5
+ const KEY_EPOCH_GRANT_KIND = "matterhorn.key-epoch-grant";
6
+ const KEY_EPOCH_GRANT_VERSION = 1;
7
+ const KEY_EPOCH_WRAP_ALG = "x25519+hkdf-sha256+aes-256-gcm";
8
+
9
+ function base64url(bytes) {
10
+ return Buffer.from(bytes).toString("base64url");
11
+ }
12
+
13
+ function fromBase64url(value) {
14
+ return Buffer.from(String(value || ""), "base64url");
15
+ }
16
+
17
+ function epochStatus(epoch) {
18
+ return epoch?.retiredAt ? "retired" : "active";
19
+ }
20
+
21
+ function keyEpochMetadata(epoch) {
22
+ if (!epoch) return undefined;
23
+ const metadata = {
24
+ id: epoch.id,
25
+ index: epoch.index,
26
+ createdAt: epoch.createdAt,
27
+ status: epochStatus(epoch)
28
+ };
29
+ if (epoch.retiredAt !== undefined) metadata.retiredAt = epoch.retiredAt;
30
+ if (epoch.historyVisibility) metadata.historyVisibility = epoch.historyVisibility;
31
+ return metadata;
32
+ }
33
+
34
+ function grantAad(grant) {
35
+ return Buffer.from(JSON.stringify([
36
+ "matterhorn.key-epoch-grant.v1",
37
+ grant.roomName,
38
+ grant.epochId,
39
+ grant.recipientMemberId,
40
+ grant.recipientDeviceId,
41
+ grant.recipientEncryptionKeyId
42
+ ]), "utf8");
43
+ }
44
+
45
+ function grantKey(sharedSecret, grant) {
46
+ return Buffer.from(crypto.hkdfSync(
47
+ "sha256",
48
+ sharedSecret,
49
+ grantAad(grant),
50
+ Buffer.from("matterhorn:key-epoch-grant:v1", "utf8"),
51
+ 32
52
+ ));
53
+ }
54
+
55
+ function publicKeyFromSpkiBase64url(value) {
56
+ return crypto.createPublicKey({ key: fromBase64url(value), type: "spki", format: "der" });
57
+ }
58
+
59
+ function privateKeyFromPkcs8Base64url(value) {
60
+ return crypto.createPrivateKey({ key: fromBase64url(value), type: "pkcs8", format: "der" });
61
+ }
62
+
63
+ function createKeyEpochGrant(store, epoch, recipient, now = Date.now()) {
64
+ if (!epoch?.epochKey) throw new Error("epochKey is required");
65
+ if (!recipient?.memberId || !recipient?.deviceId || !recipient?.encryptionKeyId || !recipient?.encryptionPublicKey) {
66
+ throw new Error("recipient encryption key is required");
67
+ }
68
+ const recipientPublicKey = publicKeyFromSpkiBase64url(recipient.encryptionPublicKey);
69
+ const ephemeral = crypto.generateKeyPairSync("x25519");
70
+ const sharedSecret = crypto.diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientPublicKey });
71
+ const grant = {
72
+ kind: KEY_EPOCH_GRANT_KIND,
73
+ version: KEY_EPOCH_GRANT_VERSION,
74
+ roomName: store.roomName,
75
+ epochId: epoch.id,
76
+ epochIndex: epoch.index,
77
+ recipientMemberId: recipient.memberId,
78
+ recipientDeviceId: recipient.deviceId,
79
+ recipientEncryptionKeyId: recipient.encryptionKeyId,
80
+ createdAt: now,
81
+ wrap: {
82
+ alg: KEY_EPOCH_WRAP_ALG,
83
+ ephemeralPublicKey: base64url(ephemeral.publicKey.export({ type: "spki", format: "der" }))
84
+ }
85
+ };
86
+ const iv = crypto.randomBytes(12);
87
+ const cipher = crypto.createCipheriv("aes-256-gcm", grantKey(sharedSecret, grant), iv);
88
+ cipher.setAAD(grantAad(grant));
89
+ const encrypted = Buffer.concat([cipher.update(Buffer.from(epoch.epochKey, "utf8")), cipher.final(), cipher.getAuthTag()]);
90
+ grant.wrap.iv = base64url(iv);
91
+ grant.wrappedEpochKey = base64url(encrypted);
92
+ return grant;
93
+ }
94
+
95
+ function unwrapKeyEpochGrant(grant, recipientIdentity) {
96
+ if (!grant || grant.kind !== KEY_EPOCH_GRANT_KIND || grant.version !== KEY_EPOCH_GRANT_VERSION) throw new Error("Key epoch grant is invalid.");
97
+ if (grant.wrap?.alg !== KEY_EPOCH_WRAP_ALG) throw new Error("Key epoch grant wrap algorithm is invalid.");
98
+ if (recipientIdentity?.keyId !== grant.recipientEncryptionKeyId) throw new Error("Key epoch grant recipient does not match.");
99
+ const privateKey = privateKeyFromPkcs8Base64url(recipientIdentity.privateKey);
100
+ const ephemeralPublicKey = publicKeyFromSpkiBase64url(grant.wrap.ephemeralPublicKey);
101
+ const sharedSecret = crypto.diffieHellman({ privateKey, publicKey: ephemeralPublicKey });
102
+ const bytes = fromBase64url(grant.wrappedEpochKey);
103
+ if (bytes.length < 17) throw new Error("Key epoch grant ciphertext is invalid.");
104
+ const ciphertext = bytes.subarray(0, bytes.length - 16);
105
+ const authTag = bytes.subarray(bytes.length - 16);
106
+ const decipher = crypto.createDecipheriv("aes-256-gcm", grantKey(sharedSecret, grant), fromBase64url(grant.wrap.iv));
107
+ decipher.setAAD(grantAad(grant));
108
+ decipher.setAuthTag(authTag);
109
+ const epochKey = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
110
+ return {
111
+ id: grant.epochId,
112
+ index: grant.epochIndex,
113
+ epochKey,
114
+ createdAt: grant.createdAt
115
+ };
116
+ }
117
+
118
+ function activeKeyEpoch(store) {
119
+ return (store.keyEpochs || []).find((epoch) => !epoch.retiredAt) || store.keyEpochs?.[store.keyEpochs.length - 1];
120
+ }
121
+
122
+ function isKeyEpoch(epoch) {
123
+ return Boolean(epoch)
124
+ && typeof epoch.id === "string"
125
+ && typeof epoch.epochKey === "string"
126
+ && epoch.epochKey.length > 0
127
+ && Number.isFinite(epoch.createdAt);
128
+ }
129
+
130
+ function normalizeKeyEpochs(store) {
131
+ let nextIndex = 1;
132
+ store.keyEpochs = (store.keyEpochs || [])
133
+ .filter(isKeyEpoch)
134
+ .map((epoch) => {
135
+ const index = Number.isInteger(epoch.index) && epoch.index > 0 ? epoch.index : nextIndex;
136
+ nextIndex = Math.max(nextIndex, index + 1);
137
+ return { ...epoch, index };
138
+ });
139
+ return store.keyEpochs;
140
+ }
141
+
142
+ function retainedKeyEpochs(store, now = Date.now()) {
143
+ return (store.keyEpochs || []).filter((epoch) => !epoch.retiredAt || now - epoch.retiredAt <= RETIRED_KEY_TTL_MS);
144
+ }
145
+
146
+ function pruneRetiredKeyEpochs(store, now = Date.now()) {
147
+ const active = activeKeyEpoch(store);
148
+ store.keyEpochs = retainedKeyEpochs(store, now);
149
+ if (active && !store.keyEpochs.some((epoch) => epoch.id === active.id)) store.keyEpochs.push(active);
150
+ }
151
+
152
+ function createKeyEpoch(store, now = Date.now(), epochKey = randomSecret(32), historyVisibility = store?.historyVisibility || DEFAULT_HISTORY_VISIBILITY) {
153
+ const nextIndex = (store.keyEpochs || []).reduce((max, epoch) => Math.max(max, Number.isInteger(epoch.index) ? epoch.index : 0), 0) + 1;
154
+ const epoch = {
155
+ id: randomId("epoch"),
156
+ index: nextIndex,
157
+ epochKey,
158
+ createdAt: now,
159
+ historyVisibility
160
+ };
161
+ store.keyEpochs.push(epoch);
162
+ return epoch;
163
+ }
164
+
165
+ function keyringForClient(store, recipient, now = Date.now()) {
166
+ const retained = retainedKeyEpochs(store);
167
+ const grants = recipient?.encryptionPublicKey
168
+ ? retained.map((epoch) => createKeyEpochGrant(store, epoch, recipient, now))
169
+ : [];
170
+ return {
171
+ activeKeyEpoch: keyEpochMetadata(activeKeyEpoch(store)),
172
+ keyEpochs: retained.map(keyEpochMetadata),
173
+ keyEpochGrants: grants
174
+ };
175
+ }
176
+
177
+ module.exports = {
178
+ KEY_EPOCH_GRANT_KIND,
179
+ KEY_EPOCH_GRANT_VERSION,
180
+ KEY_EPOCH_WRAP_ALG,
181
+ activeKeyEpoch,
182
+ createKeyEpoch,
183
+ createKeyEpochGrant,
184
+ keyEpochMetadata,
185
+ keyringForClient,
186
+ normalizeKeyEpochs,
187
+ pruneRetiredKeyEpochs,
188
+ retainedKeyEpochs,
189
+ unwrapKeyEpochGrant
190
+ };
@@ -0,0 +1,74 @@
1
+ const { memberSecretHash, memberSecretProofFromHash, randomId, randomSecret } = require("./crypto.cjs");
2
+ const { constantTimeEqualHex } = require("./timing.cjs");
3
+
4
+ function memberForProfile(store, profileId) {
5
+ return Object.values(store.members || {}).find((member) => member.profileId === profileId);
6
+ }
7
+
8
+ function issueMemberSecret(member) {
9
+ const memberSecret = randomSecret();
10
+ member.secretHash = memberSecretHash(memberSecret);
11
+ delete member.secret;
12
+ return memberSecret;
13
+ }
14
+
15
+ function normalizeMemberSecret(member) {
16
+ if (!member) return undefined;
17
+ if (!member.secretHash && member.secret) member.secretHash = memberSecretHash(member.secret);
18
+ delete member.secret;
19
+ return member.secretHash;
20
+ }
21
+
22
+ function normalizeMembers(store) {
23
+ for (const member of Object.values(store.members || {})) {
24
+ normalizeMemberSecret(member);
25
+ }
26
+ }
27
+
28
+ function createMemberCredential(store, profile, role = "guest", now = Date.now()) {
29
+ const member = {
30
+ id: randomId("member"),
31
+ profileId: profile.id,
32
+ role,
33
+ createdAt: now,
34
+ lastSeenAt: now
35
+ };
36
+ const memberSecret = issueMemberSecret(member);
37
+ store.members[member.id] = member;
38
+ return { member, memberSecret };
39
+ }
40
+
41
+ function verifyMemberProof(member, proof, roomName, clientId, callPubkey) {
42
+ const secretHash = normalizeMemberSecret(member);
43
+ if (secretHash && constantTimeEqualHex(memberSecretProofFromHash(secretHash, roomName, clientId, callPubkey), proof)) return { ok: true };
44
+ return { ok: false };
45
+ }
46
+
47
+ function activeAdminProfileIds(store) {
48
+ const adminIds = new Set(store.state.adminIds || []);
49
+ for (const member of Object.values(store.members || {})) {
50
+ if (member.role === "admin") adminIds.add(member.profileId);
51
+ }
52
+ return Array.from(adminIds).filter((profileId) => {
53
+ const guest = store.state.guests[profileId];
54
+ const member = memberForProfile(store, profileId);
55
+ return !guest?.bannedAt && !member?.bannedAt;
56
+ });
57
+ }
58
+
59
+ function setMemberAdmin(store, member, enabled) {
60
+ member.role = enabled ? "admin" : "guest";
61
+ if (enabled && !store.state.adminIds.includes(member.profileId)) store.state.adminIds.push(member.profileId);
62
+ if (!enabled) store.state.adminIds = store.state.adminIds.filter((id) => id !== member.profileId);
63
+ }
64
+
65
+ module.exports = {
66
+ activeAdminProfileIds,
67
+ createMemberCredential,
68
+ issueMemberSecret,
69
+ memberForProfile,
70
+ normalizeMemberSecret,
71
+ normalizeMembers,
72
+ setMemberAdmin,
73
+ verifyMemberProof
74
+ };
@@ -0,0 +1,56 @@
1
+ const { activeAdminProfileIds, memberForProfile } = require("./members.cjs");
2
+ const { rotatePublicInviteAndKey } = require("./rotation.cjs");
3
+ const { ensureRoomSecurity } = require("./store.cjs");
4
+ const { fail, text } = require("./values.cjs");
5
+
6
+ function banMember(store, targetProfileId, options = {}) {
7
+ ensureRoomSecurity(store, options.now || Date.now());
8
+ const target = store.state.guests[text(targetProfileId, 120)];
9
+ if (!target) return fail("Guest not found.");
10
+ if (options.actorProfileId && target.id === options.actorProfileId) return fail("Admins cannot ban themselves.");
11
+ if (activeAdminProfileIds(store).includes(target.id) && activeAdminProfileIds(store).length <= 1) {
12
+ return fail("Cannot ban the last active admin.");
13
+ }
14
+
15
+ const now = options.now || Date.now();
16
+ const targetMember = target.memberId ? store.members[target.memberId] : memberForProfile(store, target.id);
17
+ if (targetMember) {
18
+ targetMember.bannedAt = now;
19
+ targetMember.banReason = text(options.reason, 300);
20
+ targetMember.role = "guest";
21
+ }
22
+ target.bannedAt = now;
23
+ target.banReason = text(options.reason, 300);
24
+ target.chatDisabled = true;
25
+ target.role = "guest";
26
+ delete store.admins[target.id];
27
+ store.state.adminIds = store.state.adminIds.filter((id) => id !== target.id);
28
+
29
+ const result = { ok: true, closeProfileIds: [target.id] };
30
+ if (options.rotateSecrets) {
31
+ rotatePublicInviteAndKey(store, now);
32
+ result.rotated = true;
33
+ }
34
+ return result;
35
+ }
36
+
37
+ function unbanMember(store, targetProfileId, options = {}) {
38
+ ensureRoomSecurity(store, options.now || Date.now());
39
+ const target = store.state.guests[text(targetProfileId, 120)];
40
+ if (!target) return fail("Guest not found.");
41
+
42
+ const targetMember = target.memberId ? store.members[target.memberId] : memberForProfile(store, target.id);
43
+ if (targetMember) {
44
+ delete targetMember.bannedAt;
45
+ delete targetMember.banReason;
46
+ }
47
+ delete target.bannedAt;
48
+ delete target.banReason;
49
+ target.chatDisabled = false;
50
+ return { ok: true, openProfileIds: [target.id] };
51
+ }
52
+
53
+ module.exports = {
54
+ banMember,
55
+ unbanMember
56
+ };