@mh-gg/event 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 +60 -0
- package/src/canonicalJson.cjs +63 -0
- package/src/canonicalJson.d.ts +2 -0
- package/src/canonicalJson.js +58 -0
- package/src/chat.cjs +28 -0
- package/src/constants.cjs +59 -0
- package/src/encoding.cjs +19 -0
- package/src/encryption.cjs +141 -0
- package/src/epochFilter.cjs +14 -0
- package/src/identity.cjs +16 -0
- package/src/ids.cjs +9 -0
- package/src/index.cjs +16 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +6 -0
- package/src/matterhornOperationBuilder.cjs +101 -0
- package/src/matterhornOperationBuilder.d.ts +39 -0
- package/src/matterhornOperationBuilder.js +9 -0
- package/src/ngramIndex.cjs +198 -0
- package/src/ngramIndex.d.ts +48 -0
- package/src/ngramIndex.js +17 -0
- package/src/nostrMapping.cjs +287 -0
- package/src/payload.cjs +21 -0
- package/src/relayPolicy.cjs +35 -0
- package/src/results.cjs +12 -0
- package/src/signing.cjs +97 -0
- package/src/validation.cjs +344 -0
- package/test/canonical-depth.test.cjs +37 -0
- package/test/control-plane.test.cjs +143 -0
- package/test/coverage-edges.test.cjs +186 -0
- package/test/encryption-cache.test.cjs +95 -0
- package/test/matterhorn-event.test.cjs +466 -0
- package/test/ngram-index.test.cjs +62 -0
- package/test/relay-policy-edge.test.cjs +82 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { createSignedPartyEvent } = require("./signing.cjs");
|
|
3
|
+
const { partyEventToNostrEvent } = require("./nostrMapping.cjs");
|
|
4
|
+
|
|
5
|
+
const ROOM_INDEX_NGRAM_PAYLOAD_KIND = "matterhorn.room-index-ngram";
|
|
6
|
+
const ROOM_INDEX_NGRAM_PAYLOAD_VERSION = 1;
|
|
7
|
+
const ROOM_INDEX_NGRAM_SUITE = "matterhorn.ngram.hmac-sha256.v1";
|
|
8
|
+
const DEFAULT_NGRAM_SIZE = 3;
|
|
9
|
+
const DEFAULT_MAX_TOKENS_PER_EVENT = 96;
|
|
10
|
+
const MAX_NGRAM_SIZE = 8;
|
|
11
|
+
|
|
12
|
+
function keyBytes(roomIndexKey) {
|
|
13
|
+
const value = String(roomIndexKey || "").trim();
|
|
14
|
+
if (/^[0-9a-f]{64}$/i.test(value)) return Buffer.from(value, "hex");
|
|
15
|
+
return Buffer.from(value, "utf8");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeSearchText(value) {
|
|
19
|
+
return String(value || "")
|
|
20
|
+
.normalize("NFKD")
|
|
21
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
|
|
24
|
+
.replace(/\s+/g, " ")
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function integerOption(value, fallback, min, max) {
|
|
29
|
+
if (!Number.isInteger(value)) return fallback;
|
|
30
|
+
return Math.min(max, Math.max(min, value));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ngramOptions(options = {}) {
|
|
34
|
+
const minGram = integerOption(options.minGram, DEFAULT_NGRAM_SIZE, 1, MAX_NGRAM_SIZE);
|
|
35
|
+
const maxGram = integerOption(options.maxGram, options.minGram === undefined ? DEFAULT_NGRAM_SIZE : minGram, minGram, MAX_NGRAM_SIZE);
|
|
36
|
+
return { minGram, maxGram };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ngramsForText(value, options = {}) {
|
|
40
|
+
const { minGram, maxGram } = ngramOptions(options);
|
|
41
|
+
const normalized = normalizeSearchText(value);
|
|
42
|
+
if (!normalized) return [];
|
|
43
|
+
const grams = new Set();
|
|
44
|
+
|
|
45
|
+
for (let width = minGram; width <= maxGram; width += 1) {
|
|
46
|
+
if (normalized.length >= width) {
|
|
47
|
+
for (let index = 0; index <= normalized.length - width; index += 1) {
|
|
48
|
+
grams.add(`c${width}:${normalized.slice(index, index + width)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Short exact-word sentinels keep one- and two-character searches usable
|
|
54
|
+
// without forcing every one-character substring of every document into the index.
|
|
55
|
+
const words = normalized.split(" ").filter(Boolean);
|
|
56
|
+
for (const word of words) {
|
|
57
|
+
if (word.length < minGram) grams.add(`w:${word}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If the whole query/body is shorter than the configured n-gram size, add a
|
|
61
|
+
// full-string token so exact short phrases can still be searched.
|
|
62
|
+
if (normalized.length < minGram) grams.add(`s:${normalized}`);
|
|
63
|
+
|
|
64
|
+
return Array.from(grams).sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function roomIndexKeyId(roomIndexKey) {
|
|
68
|
+
const hash = crypto.createHash("sha256")
|
|
69
|
+
.update(Buffer.from("matterhorn.room-index-key-id.v1\0", "utf8"))
|
|
70
|
+
.update(keyBytes(roomIndexKey))
|
|
71
|
+
.digest();
|
|
72
|
+
return hash.subarray(0, 16).toString("base64url");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function roomIndexToken(input) {
|
|
76
|
+
const roomIndexKey = input?.roomIndexKey;
|
|
77
|
+
const roomName = String(input?.roomName || "");
|
|
78
|
+
const gram = String(input?.gram || "");
|
|
79
|
+
const suite = input?.suite || ROOM_INDEX_NGRAM_SUITE;
|
|
80
|
+
if (!roomIndexKey) throw new Error("roomIndexKey is required");
|
|
81
|
+
if (!roomName) throw new Error("roomName is required");
|
|
82
|
+
if (!gram) throw new Error("gram is required");
|
|
83
|
+
return crypto.createHmac("sha256", keyBytes(roomIndexKey))
|
|
84
|
+
.update(suite)
|
|
85
|
+
.update("\0")
|
|
86
|
+
.update(roomName)
|
|
87
|
+
.update("\0")
|
|
88
|
+
.update(gram)
|
|
89
|
+
.digest("base64url");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function roomIndexTokensForText(input = {}) {
|
|
93
|
+
const roomIndexKey = input.roomIndexKey;
|
|
94
|
+
const roomName = String(input.roomName || "");
|
|
95
|
+
if (!roomIndexKey) throw new Error("roomIndexKey is required");
|
|
96
|
+
if (!roomName) throw new Error("roomName is required");
|
|
97
|
+
const suite = input.suite || ROOM_INDEX_NGRAM_SUITE;
|
|
98
|
+
const grams = ngramsForText(input.text, input);
|
|
99
|
+
return {
|
|
100
|
+
suite,
|
|
101
|
+
keyId: roomIndexKeyId(roomIndexKey),
|
|
102
|
+
minGram: ngramOptions(input).minGram,
|
|
103
|
+
maxGram: ngramOptions(input).maxGram,
|
|
104
|
+
grams,
|
|
105
|
+
tokens: grams.map((gram) => roomIndexToken({ roomIndexKey, roomName, gram, suite }))
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function digestTokens(tokens) {
|
|
110
|
+
const hash = crypto.createHash("sha256");
|
|
111
|
+
for (const token of tokens.slice().sort()) {
|
|
112
|
+
hash.update(String(token));
|
|
113
|
+
hash.update("\0");
|
|
114
|
+
}
|
|
115
|
+
return `sha256:${hash.digest("hex")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function chunkArray(values, size) {
|
|
119
|
+
const chunks = [];
|
|
120
|
+
for (let index = 0; index < values.length; index += size) chunks.push(values.slice(index, index + size));
|
|
121
|
+
return chunks.length > 0 ? chunks : [[]];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildRoomIndexNgramPayloads(input = {}) {
|
|
125
|
+
const roomName = String(input.roomName || "");
|
|
126
|
+
const targetEventId = String(input.targetEventId || "");
|
|
127
|
+
if (!roomName) throw new Error("roomName is required");
|
|
128
|
+
if (!/^[0-9a-f]{64}$/i.test(targetEventId)) throw new Error("targetEventId must be a Nostr event id");
|
|
129
|
+
const maxTokensPerEvent = integerOption(input.maxTokensPerEvent, DEFAULT_MAX_TOKENS_PER_EVENT, 1, 512);
|
|
130
|
+
const indexed = roomIndexTokensForText(input);
|
|
131
|
+
const tokens = Array.from(new Set(indexed.tokens)).sort();
|
|
132
|
+
if (tokens.length === 0) return [];
|
|
133
|
+
const chunks = chunkArray(tokens, maxTokensPerEvent);
|
|
134
|
+
const createdAt = Number.isInteger(input.createdAt) ? input.createdAt : Date.now();
|
|
135
|
+
const tokenDigest = digestTokens(tokens);
|
|
136
|
+
return chunks.map((chunkTokens, index) => ({
|
|
137
|
+
kind: ROOM_INDEX_NGRAM_PAYLOAD_KIND,
|
|
138
|
+
version: ROOM_INDEX_NGRAM_PAYLOAD_VERSION,
|
|
139
|
+
suite: indexed.suite,
|
|
140
|
+
roomName,
|
|
141
|
+
keyId: indexed.keyId,
|
|
142
|
+
targetEventId,
|
|
143
|
+
...(input.targetOperationId ? { targetOperationId: String(input.targetOperationId) } : {}),
|
|
144
|
+
...(input.targetHlc ? { targetHlc: String(input.targetHlc) } : {}),
|
|
145
|
+
...(input.stream ? { stream: String(input.stream) } : {}),
|
|
146
|
+
...(input.field ? { field: String(input.field) } : {}),
|
|
147
|
+
minGram: indexed.minGram,
|
|
148
|
+
maxGram: indexed.maxGram,
|
|
149
|
+
tokenCount: tokens.length,
|
|
150
|
+
tokenDigest,
|
|
151
|
+
chunk: { index, count: chunks.length },
|
|
152
|
+
tokens: chunkTokens,
|
|
153
|
+
createdAt
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createRoomIndexNgramEvents(input = {}) {
|
|
158
|
+
const identity = input.identity;
|
|
159
|
+
const roomName = String(input.roomName || "");
|
|
160
|
+
if (!identity) throw new Error("identity is required");
|
|
161
|
+
const payloads = buildRoomIndexNgramPayloads(input);
|
|
162
|
+
return payloads.map((payload, index) => partyEventToNostrEvent(createSignedPartyEvent({
|
|
163
|
+
kind: "room.index.ngram",
|
|
164
|
+
partyId: roomName,
|
|
165
|
+
identity,
|
|
166
|
+
payload,
|
|
167
|
+
createdAt: (Number.isInteger(input.createdAt) ? input.createdAt : Date.now()) + index
|
|
168
|
+
})));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function roomIndexSearchQuery(input = {}) {
|
|
172
|
+
const indexed = roomIndexTokensForText(input);
|
|
173
|
+
return {
|
|
174
|
+
suite: indexed.suite,
|
|
175
|
+
keyId: indexed.keyId,
|
|
176
|
+
minGram: indexed.minGram,
|
|
177
|
+
maxGram: indexed.maxGram,
|
|
178
|
+
tokens: Array.from(new Set(indexed.tokens)).sort()
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
DEFAULT_MAX_TOKENS_PER_EVENT,
|
|
184
|
+
DEFAULT_NGRAM_SIZE,
|
|
185
|
+
MAX_NGRAM_SIZE,
|
|
186
|
+
ROOM_INDEX_NGRAM_PAYLOAD_KIND,
|
|
187
|
+
ROOM_INDEX_NGRAM_PAYLOAD_VERSION,
|
|
188
|
+
ROOM_INDEX_NGRAM_SUITE,
|
|
189
|
+
buildRoomIndexNgramPayloads,
|
|
190
|
+
createRoomIndexNgramEvents,
|
|
191
|
+
digestTokens,
|
|
192
|
+
ngramsForText,
|
|
193
|
+
normalizeSearchText,
|
|
194
|
+
roomIndexKeyId,
|
|
195
|
+
roomIndexSearchQuery,
|
|
196
|
+
roomIndexToken,
|
|
197
|
+
roomIndexTokensForText
|
|
198
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const ROOM_INDEX_NGRAM_PAYLOAD_KIND: "matterhorn.room-index-ngram";
|
|
2
|
+
export const ROOM_INDEX_NGRAM_PAYLOAD_VERSION: 1;
|
|
3
|
+
export const ROOM_INDEX_NGRAM_SUITE: "matterhorn.ngram.hmac-sha256.v1";
|
|
4
|
+
export const DEFAULT_NGRAM_SIZE: number;
|
|
5
|
+
export const DEFAULT_MAX_TOKENS_PER_EVENT: number;
|
|
6
|
+
export const MAX_NGRAM_SIZE: number;
|
|
7
|
+
|
|
8
|
+
export type RoomIndexNgramOptions = {
|
|
9
|
+
minGram?: number;
|
|
10
|
+
maxGram?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RoomIndexTextInput = RoomIndexNgramOptions & {
|
|
14
|
+
roomIndexKey: string;
|
|
15
|
+
roomName: string;
|
|
16
|
+
text: string;
|
|
17
|
+
suite?: typeof ROOM_INDEX_NGRAM_SUITE;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RoomIndexTokens = {
|
|
21
|
+
suite: typeof ROOM_INDEX_NGRAM_SUITE;
|
|
22
|
+
keyId: string;
|
|
23
|
+
minGram: number;
|
|
24
|
+
maxGram: number;
|
|
25
|
+
grams: string[];
|
|
26
|
+
tokens: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type BuildRoomIndexNgramPayloadInput = RoomIndexTextInput & {
|
|
30
|
+
identity?: unknown;
|
|
31
|
+
targetEventId: string;
|
|
32
|
+
targetOperationId?: string;
|
|
33
|
+
targetHlc?: string;
|
|
34
|
+
stream?: string;
|
|
35
|
+
field?: string;
|
|
36
|
+
maxTokensPerEvent?: number;
|
|
37
|
+
createdAt?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function normalizeSearchText(value: unknown): string;
|
|
41
|
+
export function ngramsForText(value: unknown, options?: RoomIndexNgramOptions): string[];
|
|
42
|
+
export function roomIndexKeyId(roomIndexKey: string): string;
|
|
43
|
+
export function roomIndexToken(input: { roomIndexKey: string; roomName: string; gram: string; suite?: string }): string;
|
|
44
|
+
export function roomIndexTokensForText(input: RoomIndexTextInput): RoomIndexTokens;
|
|
45
|
+
export function digestTokens(tokens: string[]): string;
|
|
46
|
+
export function roomIndexSearchQuery(input: RoomIndexTextInput): { suite: string; keyId: string; minGram: number; maxGram: number; tokens: string[] };
|
|
47
|
+
export function buildRoomIndexNgramPayloads(input: BuildRoomIndexNgramPayloadInput): unknown[];
|
|
48
|
+
export function createRoomIndexNgramEvents(input: BuildRoomIndexNgramPayloadInput & { identity: unknown }): unknown[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import ngramIndex from "./ngramIndex.cjs";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MAX_TOKENS_PER_EVENT = ngramIndex.DEFAULT_MAX_TOKENS_PER_EVENT;
|
|
4
|
+
export const DEFAULT_NGRAM_SIZE = ngramIndex.DEFAULT_NGRAM_SIZE;
|
|
5
|
+
export const MAX_NGRAM_SIZE = ngramIndex.MAX_NGRAM_SIZE;
|
|
6
|
+
export const ROOM_INDEX_NGRAM_PAYLOAD_KIND = ngramIndex.ROOM_INDEX_NGRAM_PAYLOAD_KIND;
|
|
7
|
+
export const ROOM_INDEX_NGRAM_PAYLOAD_VERSION = ngramIndex.ROOM_INDEX_NGRAM_PAYLOAD_VERSION;
|
|
8
|
+
export const ROOM_INDEX_NGRAM_SUITE = ngramIndex.ROOM_INDEX_NGRAM_SUITE;
|
|
9
|
+
export const buildRoomIndexNgramPayloads = ngramIndex.buildRoomIndexNgramPayloads;
|
|
10
|
+
export const createRoomIndexNgramEvents = ngramIndex.createRoomIndexNgramEvents;
|
|
11
|
+
export const digestTokens = ngramIndex.digestTokens;
|
|
12
|
+
export const ngramsForText = ngramIndex.ngramsForText;
|
|
13
|
+
export const normalizeSearchText = ngramIndex.normalizeSearchText;
|
|
14
|
+
export const roomIndexKeyId = ngramIndex.roomIndexKeyId;
|
|
15
|
+
export const roomIndexSearchQuery = ngramIndex.roomIndexSearchQuery;
|
|
16
|
+
export const roomIndexToken = ngramIndex.roomIndexToken;
|
|
17
|
+
export const roomIndexTokensForText = ngramIndex.roomIndexTokensForText;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { getEventHash } = require("nostr-tools/pure");
|
|
3
|
+
const { canonicalJson } = require("./canonicalJson.cjs");
|
|
4
|
+
const {
|
|
5
|
+
MATTERHORN_EVENT_KIND_BY_NOSTR_KIND,
|
|
6
|
+
MATTERHORN_EVENT_KINDS,
|
|
7
|
+
MATTERHORN_NOSTR_KIND_BY_EVENT_KIND,
|
|
8
|
+
PROTOCOL,
|
|
9
|
+
VERSION
|
|
10
|
+
} = require("./constants.cjs");
|
|
11
|
+
|
|
12
|
+
function roomChannelEventId(partyId) {
|
|
13
|
+
return crypto.createHash("sha256").update(`matterhorn:nip28:channel:${partyId}`).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function unsignedFields(event) {
|
|
17
|
+
return {
|
|
18
|
+
protocol: event.protocol,
|
|
19
|
+
version: event.version,
|
|
20
|
+
kind: event.kind,
|
|
21
|
+
partyId: event.partyId,
|
|
22
|
+
pubkey: event.pubkey,
|
|
23
|
+
createdAt: event.createdAt,
|
|
24
|
+
payload: event.payload,
|
|
25
|
+
header: event.header
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dateSeconds(value) {
|
|
30
|
+
if (Number.isInteger(value)) return value > 10_000_000_000 ? Math.floor(value / 1000) : value;
|
|
31
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
32
|
+
const parsed = Date.parse(value);
|
|
33
|
+
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stringTag(tags, name, value, max = 500) {
|
|
37
|
+
if (typeof value !== "string") return;
|
|
38
|
+
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
39
|
+
if (text) tags.push([name, text.slice(0, max)]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stringListTag(tags, name, values = []) {
|
|
43
|
+
if (!Array.isArray(values) || values.length === 0) return;
|
|
44
|
+
const filtered = values.filter((v) => typeof v === "string" && v.length > 0);
|
|
45
|
+
if (filtered.length === 0) return;
|
|
46
|
+
tags.push([name, ...filtered]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function partyCreatedTags(unsigned, tags) {
|
|
50
|
+
const payload = unsigned.payload || {};
|
|
51
|
+
const start = dateSeconds(payload.start)
|
|
52
|
+
?? dateSeconds(payload.startAt)
|
|
53
|
+
?? dateSeconds(payload.date)
|
|
54
|
+
?? Math.floor(unsigned.createdAt / 1000);
|
|
55
|
+
const end = dateSeconds(payload.end) ?? dateSeconds(payload.endAt);
|
|
56
|
+
tags.push(["title", typeof payload.title === "string" && payload.title.trim() ? payload.title.trim().slice(0, 200) : unsigned.partyId]);
|
|
57
|
+
tags.push(["start", String(start)]);
|
|
58
|
+
tags.push(["D", String(Math.floor(start / 86400))]);
|
|
59
|
+
stringTag(tags, "summary", payload.summary || payload.description, 700);
|
|
60
|
+
stringTag(tags, "location", payload.location, 300);
|
|
61
|
+
stringTag(tags, "g", payload.geohash || payload.geoHash, 80);
|
|
62
|
+
stringTag(tags, "start_tzid", payload.startTzid || payload.timezone, 80);
|
|
63
|
+
if (end && end > start) tags.push(["end", String(end)]);
|
|
64
|
+
stringTag(tags, "end_tzid", payload.endTzid || payload.timezone, 80);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function roomOperationTags(unsigned, tags) {
|
|
68
|
+
const h = unsigned.header || {};
|
|
69
|
+
tags.push(["scheme", "matterhorn.operation.v2"]);
|
|
70
|
+
stringTag(tags, "op-id", h.opId, 80);
|
|
71
|
+
stringTag(tags, "hlc", h.hlc, 80);
|
|
72
|
+
stringTag(tags, "plugin", h.plugin, 200);
|
|
73
|
+
stringTag(tags, "type", h.type, 200);
|
|
74
|
+
if (h.action) stringTag(tags, "action", h.action, 200);
|
|
75
|
+
stringTag(tags, "member", h.member, 200);
|
|
76
|
+
stringTag(tags, "device", h.device, 200);
|
|
77
|
+
stringTag(tags, "role", h.role, 80);
|
|
78
|
+
stringListTag(tags, "grants", h.grants);
|
|
79
|
+
stringTag(tags, "epoch", h.epoch, 80);
|
|
80
|
+
stringTag(tags, "stream", h.stream, 200);
|
|
81
|
+
if (typeof h.seq === "number" && Number.isInteger(h.seq) && h.seq >= 0) {
|
|
82
|
+
tags.push(["seq", String(h.seq)]);
|
|
83
|
+
}
|
|
84
|
+
stringTag(tags, "app-pack-id", h.appPackId, 200);
|
|
85
|
+
stringTag(tags, "app-pack-hash", h.appPackHash, 100);
|
|
86
|
+
stringTag(tags, "payload-hash", h.payloadHash, 80);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function roomKeyEpochTags(unsigned, tags) {
|
|
90
|
+
const p = unsigned.payload || {};
|
|
91
|
+
stringTag(tags, "epoch", p.id, 80);
|
|
92
|
+
if (typeof p.index === "number" && Number.isInteger(p.index) && p.index >= 0) {
|
|
93
|
+
tags.push(["epoch-index", String(p.index)]);
|
|
94
|
+
}
|
|
95
|
+
stringTag(tags, "member", p.createdBy, 200);
|
|
96
|
+
stringTag(tags, "room", unsigned.partyId, 200);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function roomKeyEpochGrantTags(unsigned, tags) {
|
|
100
|
+
const p = unsigned.payload || {};
|
|
101
|
+
stringTag(tags, "epoch", p.epochId, 80);
|
|
102
|
+
stringTag(tags, "member", p.recipientMemberId, 200);
|
|
103
|
+
stringTag(tags, "device", p.recipientDeviceId, 200);
|
|
104
|
+
stringTag(tags, "key", p.recipientEncryptionKeyId, 80);
|
|
105
|
+
stringTag(tags, "room", unsigned.partyId, 200);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function roomIndexKeyGrantTags(unsigned, tags) {
|
|
109
|
+
const p = unsigned.payload || {};
|
|
110
|
+
stringTag(tags, "member", p.recipientMemberId, 200);
|
|
111
|
+
stringTag(tags, "device", p.recipientDeviceId, 200);
|
|
112
|
+
stringTag(tags, "key", p.recipientEncryptionKeyId, 80);
|
|
113
|
+
stringTag(tags, "room", unsigned.partyId, 200);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
function roomIndexNgramTags(unsigned, tags) {
|
|
118
|
+
const p = unsigned.payload || {};
|
|
119
|
+
stringTag(tags, "room", p.roomName || unsigned.partyId, 200);
|
|
120
|
+
stringTag(tags, "suite", p.suite, 120);
|
|
121
|
+
stringTag(tags, "key", p.keyId, 80);
|
|
122
|
+
stringTag(tags, "target", p.targetEventId, 128);
|
|
123
|
+
stringTag(tags, "target-operation-id", p.targetOperationId, 128);
|
|
124
|
+
stringTag(tags, "target-hlc", p.targetHlc, 128);
|
|
125
|
+
stringTag(tags, "stream", p.stream, 300);
|
|
126
|
+
stringTag(tags, "field", p.field, 120);
|
|
127
|
+
if (p.chunk && typeof p.chunk === "object") {
|
|
128
|
+
if (Number.isInteger(p.chunk.index) && p.chunk.index >= 0) tags.push(["chunk", String(p.chunk.index), String(p.chunk.count || 1)]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function partyNostrTags(unsigned) {
|
|
133
|
+
const tags = [
|
|
134
|
+
["protocol", PROTOCOL],
|
|
135
|
+
["version", String(VERSION)],
|
|
136
|
+
["d", unsigned.partyId],
|
|
137
|
+
["created-at-ms", String(unsigned.createdAt)]
|
|
138
|
+
];
|
|
139
|
+
if (unsigned.kind === "party.created") {
|
|
140
|
+
partyCreatedTags(unsigned, tags);
|
|
141
|
+
}
|
|
142
|
+
if (unsigned.kind === "chat.message") {
|
|
143
|
+
tags.push(["e", roomChannelEventId(unsigned.partyId), "", "root"]);
|
|
144
|
+
if (typeof unsigned.payload?.replyTo === "string") tags.push(["e", unsigned.payload.replyTo, "", "reply"]);
|
|
145
|
+
}
|
|
146
|
+
if (unsigned.kind === "chat.delete" && typeof unsigned.payload?.targetEventId === "string") {
|
|
147
|
+
tags.push(["e", unsigned.payload.targetEventId]);
|
|
148
|
+
tags.push(["k", String(MATTERHORN_NOSTR_KIND_BY_EVENT_KIND["chat.message"])]);
|
|
149
|
+
}
|
|
150
|
+
if (unsigned.kind === "relay.announce" && typeof unsigned.payload?.relayUrl === "string") {
|
|
151
|
+
tags.push(["r", unsigned.payload.relayUrl]);
|
|
152
|
+
}
|
|
153
|
+
if (unsigned.kind === "room.operation" && unsigned.header && typeof unsigned.header === "object") {
|
|
154
|
+
roomOperationTags(unsigned, tags);
|
|
155
|
+
}
|
|
156
|
+
if (unsigned.kind === "room.key.epoch") {
|
|
157
|
+
roomKeyEpochTags(unsigned, tags);
|
|
158
|
+
}
|
|
159
|
+
if (unsigned.kind === "room.key.epoch.grant") {
|
|
160
|
+
roomKeyEpochGrantTags(unsigned, tags);
|
|
161
|
+
}
|
|
162
|
+
if (unsigned.kind === "room.index.key.grant") {
|
|
163
|
+
roomIndexKeyGrantTags(unsigned, tags);
|
|
164
|
+
}
|
|
165
|
+
if (unsigned.kind === "room.index.ngram") {
|
|
166
|
+
roomIndexNgramTags(unsigned, tags);
|
|
167
|
+
}
|
|
168
|
+
return tags;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function tagValue(tags, name) {
|
|
172
|
+
const tag = tags.find((item) => Array.isArray(item) && item[0] === name);
|
|
173
|
+
return tag ? tag[1] : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function tagValues(tags, name) {
|
|
177
|
+
const vals = [];
|
|
178
|
+
for (const item of tags) {
|
|
179
|
+
if (Array.isArray(item) && item[0] === name && item.length > 1) {
|
|
180
|
+
vals.push(...item.slice(1));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return vals;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function matterhornKindFromNostrEvent(nostrEvent, tags) {
|
|
187
|
+
const legacyKind = tagValue(tags, "k");
|
|
188
|
+
if (legacyKind && MATTERHORN_EVENT_KINDS.has(legacyKind)) return legacyKind;
|
|
189
|
+
return MATTERHORN_EVENT_KIND_BY_NOSTR_KIND[nostrEvent.kind];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function headerFromTags(tags) {
|
|
193
|
+
const grants = tagValues(tags, "grants");
|
|
194
|
+
const header = {
|
|
195
|
+
scheme: tagValue(tags, "scheme") || undefined,
|
|
196
|
+
opId: tagValue(tags, "op-id") || undefined,
|
|
197
|
+
hlc: tagValue(tags, "hlc") || undefined,
|
|
198
|
+
plugin: tagValue(tags, "plugin") || undefined,
|
|
199
|
+
type: tagValue(tags, "type") || undefined,
|
|
200
|
+
action: tagValue(tags, "action") || undefined,
|
|
201
|
+
member: tagValue(tags, "member") || undefined,
|
|
202
|
+
device: tagValue(tags, "device") || undefined,
|
|
203
|
+
role: tagValue(tags, "role") || undefined,
|
|
204
|
+
grants: grants.length > 0 ? grants : undefined,
|
|
205
|
+
epoch: tagValue(tags, "epoch") || undefined,
|
|
206
|
+
stream: tagValue(tags, "stream") || undefined,
|
|
207
|
+
seq: (() => {
|
|
208
|
+
const raw = tagValue(tags, "seq");
|
|
209
|
+
if (!raw) return undefined;
|
|
210
|
+
const n = Number(raw);
|
|
211
|
+
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
|
212
|
+
})(),
|
|
213
|
+
appPackId: tagValue(tags, "app-pack-id") || undefined,
|
|
214
|
+
appPackHash: tagValue(tags, "app-pack-hash") || undefined,
|
|
215
|
+
payloadHash: tagValue(tags, "payload-hash") || undefined
|
|
216
|
+
};
|
|
217
|
+
// Drop undefined values for canonical compactness.
|
|
218
|
+
const out = {};
|
|
219
|
+
for (const [k, v] of Object.entries(header)) {
|
|
220
|
+
if (v !== undefined) out[k] = v;
|
|
221
|
+
}
|
|
222
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isControlPlaneKind(kind) {
|
|
226
|
+
return kind === "room.key.epoch" || kind === "room.key.epoch.grant" || kind === "room.index.key.grant" || kind === "room.index.ngram";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function partyEventToNostrTemplate(unsigned) {
|
|
230
|
+
const nostrKind = MATTERHORN_NOSTR_KIND_BY_EVENT_KIND[unsigned.kind];
|
|
231
|
+
if (!nostrKind) throw new Error("Unknown party event kind");
|
|
232
|
+
return {
|
|
233
|
+
kind: nostrKind,
|
|
234
|
+
created_at: Math.floor(unsigned.createdAt / 1000),
|
|
235
|
+
tags: partyNostrTags(unsigned),
|
|
236
|
+
content: isControlPlaneKind(unsigned.kind) ? canonicalJson(unsigned.payload || {}) : canonicalJson({ payload: unsigned.payload })
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function partyEventToNostrEvent(event) {
|
|
241
|
+
return {
|
|
242
|
+
...partyEventToNostrTemplate(unsignedFields(event)),
|
|
243
|
+
id: event.id,
|
|
244
|
+
pubkey: event.pubkey,
|
|
245
|
+
sig: event.sig
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function nostrEventToPartyEvent(nostrEvent) {
|
|
250
|
+
const tags = Array.isArray(nostrEvent?.tags) ? nostrEvent.tags : [];
|
|
251
|
+
const content = JSON.parse(nostrEvent.content || "{}");
|
|
252
|
+
const kind = matterhornKindFromNostrEvent(nostrEvent, tags);
|
|
253
|
+
const header = kind === "room.operation" ? headerFromTags(tags) : undefined;
|
|
254
|
+
return {
|
|
255
|
+
protocol: tagValue(tags, "protocol"),
|
|
256
|
+
version: Number(tagValue(tags, "version")),
|
|
257
|
+
id: nostrEvent.id,
|
|
258
|
+
kind,
|
|
259
|
+
partyId: tagValue(tags, "d"),
|
|
260
|
+
pubkey: nostrEvent.pubkey,
|
|
261
|
+
createdAt: Number(tagValue(tags, "created-at-ms")),
|
|
262
|
+
payload: isControlPlaneKind(kind) ? content : content.payload,
|
|
263
|
+
header,
|
|
264
|
+
sig: nostrEvent.sig
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function eventIdForUnsigned(unsigned) {
|
|
269
|
+
return getEventHash({
|
|
270
|
+
...partyEventToNostrTemplate(unsigned),
|
|
271
|
+
pubkey: unsigned.pubkey
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
eventIdForUnsigned,
|
|
277
|
+
headerFromTags,
|
|
278
|
+
nostrEventToPartyEvent,
|
|
279
|
+
partyEventToNostrEvent,
|
|
280
|
+
partyEventToNostrTemplate,
|
|
281
|
+
partyNostrTags,
|
|
282
|
+
roomChannelEventId,
|
|
283
|
+
roomOperationTags,
|
|
284
|
+
tagValue,
|
|
285
|
+
tagValues,
|
|
286
|
+
unsignedFields
|
|
287
|
+
};
|
package/src/payload.cjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { ENCRYPTION_ALG } = require("./constants.cjs");
|
|
2
|
+
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isEncryptedPayload(value) {
|
|
8
|
+
return (
|
|
9
|
+
isPlainObject(value) &&
|
|
10
|
+
value.encrypted === true &&
|
|
11
|
+
value.alg === ENCRYPTION_ALG &&
|
|
12
|
+
(value.epochId === undefined || typeof value.epochId === "string") &&
|
|
13
|
+
typeof value.iv === "string" &&
|
|
14
|
+
typeof value.data === "string"
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
isEncryptedPayload,
|
|
20
|
+
isPlainObject
|
|
21
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { MATTERHORN_NOSTR_KINDS } = require("./constants.cjs");
|
|
2
|
+
const { nostrEventToPartyEvent } = require("./nostrMapping.cjs");
|
|
3
|
+
const { validatePartyEvent } = require("./validation.cjs");
|
|
4
|
+
|
|
5
|
+
const MATTERHORN_EVENT_REQUIRED_NIPS = Object.freeze(["nip01", "nip09", "nip28", "nip52", "nip65", "nip94"]);
|
|
6
|
+
|
|
7
|
+
function createPartyEventPolicy(validationOptions = {}) {
|
|
8
|
+
function partyEventPolicy(nostrEvent) {
|
|
9
|
+
if (!nostrEvent || typeof nostrEvent !== "object" || !MATTERHORN_NOSTR_KINDS.includes(nostrEvent.kind)) {
|
|
10
|
+
return { ok: false, code: "invalid", message: "Only matterhorn Nostr events are accepted" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let partyEvent;
|
|
14
|
+
try {
|
|
15
|
+
partyEvent = nostrEventToPartyEvent(nostrEvent);
|
|
16
|
+
} catch {
|
|
17
|
+
return { ok: false, code: "invalid", message: "Invalid matterhorn Nostr event" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const validation = validatePartyEvent(partyEvent, validationOptions);
|
|
21
|
+
if (!validation.ok) return validation;
|
|
22
|
+
return { ok: true, event: partyEvent };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
partyEventPolicy.feature = Object.freeze({
|
|
26
|
+
name: "matterhorn-event",
|
|
27
|
+
requiredNips: MATTERHORN_EVENT_REQUIRED_NIPS
|
|
28
|
+
});
|
|
29
|
+
return partyEventPolicy;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
MATTERHORN_EVENT_REQUIRED_NIPS,
|
|
34
|
+
createPartyEventPolicy
|
|
35
|
+
};
|