@mh-gg/protocol 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 +21 -0
- package/src/assertions.cjs +52 -0
- package/src/constants.cjs +35 -0
- package/src/errors.cjs +23 -0
- package/src/index.cjs +14 -0
- package/src/operations/identity.cjs +141 -0
- package/src/operations/roomDeviceSigning.cjs +159 -0
- package/src/operations/roomDeviceSigningConstants.cjs +9 -0
- package/src/operations/roomDeviceSigningValidation.cjs +63 -0
- package/src/operations/snowflake.cjs +166 -0
- package/src/operations/snowflakeParts.cjs +71 -0
- package/src/parsers/hostOperationBatch.cjs +49 -0
- package/src/validators/bounds.cjs +77 -0
- package/src/validators/client.cjs +129 -0
- package/src/validators/host.cjs +60 -0
- package/src/validators/operations.cjs +110 -0
- package/src/validators/relay.cjs +86 -0
- package/src/validators/shared.cjs +41 -0
- package/test/matterhorn-protocol.test.cjs +421 -0
- package/test/operation-bounds.test.cjs +87 -0
- package/test/operation-identity.test.cjs +101 -0
- package/test/room-device-signing-roundtrip.test.cjs +267 -0
- package/test/snowflake.test.cjs +119 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const {
|
|
2
|
+
DEFAULT_SNOWFLAKE_EPOCH,
|
|
3
|
+
SNOWFLAKE_MAX_ID,
|
|
4
|
+
SNOWFLAKE_MAX_NODE_ID,
|
|
5
|
+
SNOWFLAKE_MAX_SEQUENCE,
|
|
6
|
+
SNOWFLAKE_NODE_SHIFT,
|
|
7
|
+
SNOWFLAKE_RE,
|
|
8
|
+
SNOWFLAKE_TIMESTAMP_SHIFT,
|
|
9
|
+
finiteInteger,
|
|
10
|
+
normalizeSnowflakeEpoch,
|
|
11
|
+
normalizeSnowflakeTimestamp,
|
|
12
|
+
stableSnowflakeNodeId,
|
|
13
|
+
stableSnowflakeSequence
|
|
14
|
+
} = require("./snowflakeParts.cjs");
|
|
15
|
+
|
|
16
|
+
function snowflakeIdFromParts({ timestampMs = Date.now(), nodeId = 0, sequence = 0, epoch = DEFAULT_SNOWFLAKE_EPOCH } = {}) {
|
|
17
|
+
const normalizedEpoch = normalizeSnowflakeEpoch(epoch);
|
|
18
|
+
const { elapsed } = normalizeSnowflakeTimestamp(timestampMs, normalizedEpoch);
|
|
19
|
+
const normalizedNodeId = stableSnowflakeNodeId(nodeId);
|
|
20
|
+
const normalizedSequence = stableSnowflakeSequence(sequence);
|
|
21
|
+
return ((elapsed << SNOWFLAKE_TIMESTAMP_SHIFT) | (BigInt(normalizedNodeId) << SNOWFLAKE_NODE_SHIFT) | BigInt(normalizedSequence)).toString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function snowflakeIdFromTimestamp(timestampMs = Date.now(), options = {}) {
|
|
25
|
+
return snowflakeIdFromParts({ timestampMs, ...options });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isSnowflakeId(value) {
|
|
29
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") return false;
|
|
30
|
+
const text = String(value);
|
|
31
|
+
if (!SNOWFLAKE_RE.test(text)) return false;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = BigInt(text);
|
|
34
|
+
return parsed >= 0n && parsed <= SNOWFLAKE_MAX_ID;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseSnowflakeId(value, options = {}) {
|
|
41
|
+
if (!isSnowflakeId(value)) return undefined;
|
|
42
|
+
const epoch = normalizeSnowflakeEpoch(options.epoch);
|
|
43
|
+
const id = BigInt(String(value));
|
|
44
|
+
const elapsed = id >> SNOWFLAKE_TIMESTAMP_SHIFT;
|
|
45
|
+
const timestampMs = Number(elapsed) + epoch;
|
|
46
|
+
const nodeId = Number((id >> SNOWFLAKE_NODE_SHIFT) & BigInt(SNOWFLAKE_MAX_NODE_ID));
|
|
47
|
+
const sequence = Number(id & BigInt(SNOWFLAKE_MAX_SEQUENCE));
|
|
48
|
+
return { id: id.toString(), timestampMs, nodeId, sequence, epoch };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function snowflakeTimestamp(value, options = {}) {
|
|
52
|
+
return parseSnowflakeId(value, options)?.timestampMs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function compareSnowflakeIds(left, right) {
|
|
56
|
+
const a = BigInt(String(left));
|
|
57
|
+
const b = BigInt(String(right));
|
|
58
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function compareMaybeSnowflakeIds(left, right) {
|
|
62
|
+
if (isSnowflakeId(left) && isSnowflakeId(right)) return compareSnowflakeIds(left, right);
|
|
63
|
+
const a = String(left || "");
|
|
64
|
+
const b = String(right || "");
|
|
65
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractSnowflakeId(value) {
|
|
69
|
+
if (isSnowflakeId(value)) return String(value);
|
|
70
|
+
if (typeof value !== "string") return undefined;
|
|
71
|
+
const match = /(?:^|_)([1-9][0-9]{12,18}|0)$/.exec(value);
|
|
72
|
+
return match && isSnowflakeId(match[1]) ? match[1] : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function compareEntityIdsBySnowflake(left, right) {
|
|
76
|
+
const a = extractSnowflakeId(left);
|
|
77
|
+
const b = extractSnowflakeId(right);
|
|
78
|
+
if (a && b) return compareSnowflakeIds(a, b);
|
|
79
|
+
if (a) return -1;
|
|
80
|
+
if (b) return 1;
|
|
81
|
+
return compareMaybeSnowflakeIds(left, right);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function snowflakeBoundsForTimestamp(timestampMs, options = {}) {
|
|
85
|
+
const epoch = normalizeSnowflakeEpoch(options.epoch);
|
|
86
|
+
return {
|
|
87
|
+
min: snowflakeIdFromParts({ timestampMs, nodeId: 0, sequence: 0, epoch }),
|
|
88
|
+
max: snowflakeIdFromParts({ timestampMs, nodeId: SNOWFLAKE_MAX_NODE_ID, sequence: SNOWFLAKE_MAX_SEQUENCE, epoch })
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createSnowflakeGenerator(options = {}) {
|
|
93
|
+
const epoch = normalizeSnowflakeEpoch(options.epoch);
|
|
94
|
+
const nodeId = stableSnowflakeNodeId(options.nodeId ?? "matterhorn-sdk");
|
|
95
|
+
const now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
96
|
+
let lastTimestamp = -1;
|
|
97
|
+
let sequence = 0;
|
|
98
|
+
return {
|
|
99
|
+
epoch,
|
|
100
|
+
nodeId,
|
|
101
|
+
next(timestampMs) {
|
|
102
|
+
const wall = Number.isFinite(Number(timestampMs)) ? finiteInteger(timestampMs, "timestampMs") : finiteInteger(now(), "now");
|
|
103
|
+
let timestamp = Math.max(wall, lastTimestamp, epoch);
|
|
104
|
+
if (timestamp === lastTimestamp) {
|
|
105
|
+
sequence += 1;
|
|
106
|
+
if (sequence > SNOWFLAKE_MAX_SEQUENCE) {
|
|
107
|
+
timestamp += 1;
|
|
108
|
+
sequence = 0;
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
sequence = 0;
|
|
112
|
+
}
|
|
113
|
+
lastTimestamp = timestamp;
|
|
114
|
+
return snowflakeIdFromParts({ timestampMs: timestamp, nodeId, sequence, epoch });
|
|
115
|
+
},
|
|
116
|
+
fromTimestamp(timestampMs, sequenceHint = 0) {
|
|
117
|
+
return snowflakeIdFromParts({ timestampMs, nodeId, sequence: sequenceHint, epoch });
|
|
118
|
+
},
|
|
119
|
+
inspect(id) {
|
|
120
|
+
return parseSnowflakeId(id, { epoch });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function snowflakeIdForOperation(operation = {}, options = {}) {
|
|
126
|
+
if (operation.ledgerId && isSnowflakeId(operation.ledgerId)) return String(operation.ledgerId);
|
|
127
|
+
if (operation.snowflakeId && isSnowflakeId(operation.snowflakeId)) return String(operation.snowflakeId);
|
|
128
|
+
const epoch = normalizeSnowflakeEpoch(options.epoch);
|
|
129
|
+
const rawTimestampMs = Number.isFinite(Number(operation.createdAt)) ? Number(operation.createdAt) : Date.now();
|
|
130
|
+
const timestampMs = Math.max(rawTimestampMs, epoch);
|
|
131
|
+
const nodeSource = options.nodeId ?? operation.actor?.deviceId ?? operation.actor?.memberId ?? operation.roomId ?? operation.id ?? "operation";
|
|
132
|
+
const sequenceSource = options.sequence ?? operation.id ?? operation.clientOperationId ?? operation.hlc ?? `${operation.type || "operation"}:${timestampMs}`;
|
|
133
|
+
return snowflakeIdFromParts({
|
|
134
|
+
timestampMs,
|
|
135
|
+
nodeId: stableSnowflakeNodeId(nodeSource),
|
|
136
|
+
sequence: stableSnowflakeSequence(sequenceSource),
|
|
137
|
+
epoch
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function prefixedSnowflakeId(prefix = "id", snowflakeId) {
|
|
142
|
+
const cleanPrefix = String(prefix || "id").replace(/[^A-Za-z0-9_-]/g, "_") || "id";
|
|
143
|
+
const id = snowflakeId && isSnowflakeId(snowflakeId) ? String(snowflakeId) : snowflakeIdFromTimestamp(Date.now());
|
|
144
|
+
return `${cleanPrefix}_${id}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
DEFAULT_SNOWFLAKE_EPOCH,
|
|
149
|
+
SNOWFLAKE_MAX_NODE_ID,
|
|
150
|
+
SNOWFLAKE_MAX_SEQUENCE,
|
|
151
|
+
compareEntityIdsBySnowflake,
|
|
152
|
+
compareMaybeSnowflakeIds,
|
|
153
|
+
compareSnowflakeIds,
|
|
154
|
+
createSnowflakeGenerator,
|
|
155
|
+
extractSnowflakeId,
|
|
156
|
+
isSnowflakeId,
|
|
157
|
+
parseSnowflakeId,
|
|
158
|
+
prefixedSnowflakeId,
|
|
159
|
+
snowflakeBoundsForTimestamp,
|
|
160
|
+
snowflakeIdForOperation,
|
|
161
|
+
snowflakeIdFromParts,
|
|
162
|
+
snowflakeIdFromTimestamp,
|
|
163
|
+
snowflakeTimestamp,
|
|
164
|
+
stableSnowflakeNodeId,
|
|
165
|
+
stableSnowflakeSequence
|
|
166
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const DEFAULT_SNOWFLAKE_EPOCH = Date.UTC(2024, 0, 1);
|
|
2
|
+
const SNOWFLAKE_TIMESTAMP_BITS = 41n;
|
|
3
|
+
const SNOWFLAKE_NODE_BITS = 10n;
|
|
4
|
+
const SNOWFLAKE_SEQUENCE_BITS = 12n;
|
|
5
|
+
const SNOWFLAKE_NODE_SHIFT = SNOWFLAKE_SEQUENCE_BITS;
|
|
6
|
+
const SNOWFLAKE_TIMESTAMP_SHIFT = SNOWFLAKE_NODE_BITS + SNOWFLAKE_SEQUENCE_BITS;
|
|
7
|
+
const SNOWFLAKE_MAX_TIMESTAMP = (1n << SNOWFLAKE_TIMESTAMP_BITS) - 1n;
|
|
8
|
+
const SNOWFLAKE_MAX_NODE_ID = Number((1n << SNOWFLAKE_NODE_BITS) - 1n);
|
|
9
|
+
const SNOWFLAKE_MAX_SEQUENCE = Number((1n << SNOWFLAKE_SEQUENCE_BITS) - 1n);
|
|
10
|
+
const SNOWFLAKE_MAX_ID = (1n << 63n) - 1n;
|
|
11
|
+
const SNOWFLAKE_RE = /^(0|[1-9][0-9]{0,18})$/;
|
|
12
|
+
|
|
13
|
+
function finiteInteger(value, name) {
|
|
14
|
+
const number = Number(value);
|
|
15
|
+
if (!Number.isFinite(number) || !Number.isInteger(number)) throw new Error(`${name} must be an integer`);
|
|
16
|
+
return number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clampInteger(value, min, max, name) {
|
|
20
|
+
const number = finiteInteger(value, name);
|
|
21
|
+
if (number < min || number > max) throw new Error(`${name} must be between ${min} and ${max}`);
|
|
22
|
+
return number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fnv1a32(value) {
|
|
26
|
+
const text = String(value || "");
|
|
27
|
+
let hash = 0x811c9dc5;
|
|
28
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
29
|
+
hash ^= text.charCodeAt(index);
|
|
30
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
31
|
+
}
|
|
32
|
+
return hash >>> 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stableSnowflakeNodeId(value) {
|
|
36
|
+
if (Number.isFinite(Number(value)) && String(value).trim() !== "") return clampInteger(Number(value), 0, SNOWFLAKE_MAX_NODE_ID, "nodeId");
|
|
37
|
+
return fnv1a32(value) & SNOWFLAKE_MAX_NODE_ID;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stableSnowflakeSequence(value) {
|
|
41
|
+
if (Number.isFinite(Number(value)) && String(value).trim() !== "") return clampInteger(Number(value), 0, SNOWFLAKE_MAX_SEQUENCE, "sequence");
|
|
42
|
+
return fnv1a32(value) & SNOWFLAKE_MAX_SEQUENCE;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeSnowflakeEpoch(epoch) {
|
|
46
|
+
return Number.isFinite(Number(epoch)) ? finiteInteger(epoch, "epoch") : DEFAULT_SNOWFLAKE_EPOCH;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeSnowflakeTimestamp(timestampMs, epoch = DEFAULT_SNOWFLAKE_EPOCH) {
|
|
50
|
+
const timestamp = finiteInteger(timestampMs, "timestampMs");
|
|
51
|
+
const elapsed = BigInt(timestamp - normalizeSnowflakeEpoch(epoch));
|
|
52
|
+
if (elapsed < 0n) throw new Error("timestampMs is before the Snowflake epoch");
|
|
53
|
+
if (elapsed > SNOWFLAKE_MAX_TIMESTAMP) throw new Error("timestampMs exceeds the Snowflake timestamp range");
|
|
54
|
+
return { timestamp, elapsed };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
DEFAULT_SNOWFLAKE_EPOCH,
|
|
59
|
+
SNOWFLAKE_MAX_ID,
|
|
60
|
+
SNOWFLAKE_MAX_NODE_ID,
|
|
61
|
+
SNOWFLAKE_MAX_SEQUENCE,
|
|
62
|
+
SNOWFLAKE_NODE_SHIFT,
|
|
63
|
+
SNOWFLAKE_RE,
|
|
64
|
+
SNOWFLAKE_SEQUENCE_BITS,
|
|
65
|
+
SNOWFLAKE_TIMESTAMP_SHIFT,
|
|
66
|
+
finiteInteger,
|
|
67
|
+
normalizeSnowflakeEpoch,
|
|
68
|
+
normalizeSnowflakeTimestamp,
|
|
69
|
+
stableSnowflakeNodeId,
|
|
70
|
+
stableSnowflakeSequence
|
|
71
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const { HOST_OPERATION_BATCH_KIND, HOST_SNAPSHOT_KIND } = require("../constants.cjs");
|
|
2
|
+
const { assertArray, assertInteger, assertRecord, assertString } = require("../assertions.cjs");
|
|
3
|
+
const { invalid } = require("../errors.cjs");
|
|
4
|
+
const { validateRoomOperation } = require("../validators/operations.cjs");
|
|
5
|
+
|
|
6
|
+
function validateHostOperationBatch(message) {
|
|
7
|
+
const value = assertRecord(message, "host-operation-batch");
|
|
8
|
+
if (value.kind !== undefined && value.kind !== HOST_OPERATION_BATCH_KIND) throw invalid("host-operation-batch.kind", "is invalid");
|
|
9
|
+
assertString(value.roomId, "host-operation-batch.roomId");
|
|
10
|
+
assertString(value.appPackId, "host-operation-batch.appPackId");
|
|
11
|
+
assertString(value.appPackHash, "host-operation-batch.appPackHash");
|
|
12
|
+
if (value.baseVersion !== undefined) assertInteger(value.baseVersion, "host-operation-batch.baseVersion", 0);
|
|
13
|
+
if (value.headVersion !== undefined) assertInteger(value.headVersion, "host-operation-batch.headVersion", 0);
|
|
14
|
+
assertArray(value.operations, "host-operation-batch.operations").forEach((op, index) => {
|
|
15
|
+
validateRoomOperation(op, {
|
|
16
|
+
roomId: value.roomId,
|
|
17
|
+
appPackId: value.appPackId,
|
|
18
|
+
appPackHash: value.appPackHash
|
|
19
|
+
});
|
|
20
|
+
if (op.seq !== undefined) assertInteger(op.seq, `host-operation-batch.operations[${index}].seq`, 1);
|
|
21
|
+
});
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseHostOperationBatch(message) {
|
|
26
|
+
return validateHostOperationBatch(message);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateHostSnapshot(message) {
|
|
30
|
+
const value = assertRecord(message, "host-snapshot");
|
|
31
|
+
if (value.kind !== undefined && value.kind !== HOST_SNAPSHOT_KIND) throw invalid("host-snapshot.kind", "is invalid");
|
|
32
|
+
assertString(value.roomId, "host-snapshot.roomId");
|
|
33
|
+
assertString(value.appPackId, "host-snapshot.appPackId");
|
|
34
|
+
assertString(value.appPackHash, "host-snapshot.appPackHash");
|
|
35
|
+
assertInteger(value.version, "host-snapshot.version", 0);
|
|
36
|
+
assertRecord(value.state, "host-snapshot.state");
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseHostSnapshot(message) {
|
|
41
|
+
return validateHostSnapshot(message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
parseHostOperationBatch,
|
|
46
|
+
parseHostSnapshot,
|
|
47
|
+
validateHostOperationBatch,
|
|
48
|
+
validateHostSnapshot
|
|
49
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { MAX_OPERATION_BYTES, MAX_OPERATION_DEPTH, MAX_PUSH_PAYLOAD_BYTES } = require("../constants.cjs");
|
|
2
|
+
const { invalid } = require("../errors.cjs");
|
|
3
|
+
|
|
4
|
+
function optionLimit(value, fallback) {
|
|
5
|
+
if (value === false || value === Infinity) return Infinity;
|
|
6
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
7
|
+
return fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isObjectLike(value) {
|
|
11
|
+
return Boolean(value) && typeof value === "object";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assertJsonDepth(value, path = "value", maxDepth = MAX_OPERATION_DEPTH) {
|
|
15
|
+
const limit = optionLimit(maxDepth, MAX_OPERATION_DEPTH);
|
|
16
|
+
if (!Number.isFinite(limit)) return;
|
|
17
|
+
const stack = [{ value, depth: 0 }];
|
|
18
|
+
const seen = new WeakSet();
|
|
19
|
+
while (stack.length) {
|
|
20
|
+
const current = stack.pop();
|
|
21
|
+
const item = current.value;
|
|
22
|
+
if (item === null || item === undefined) continue;
|
|
23
|
+
const type = typeof item;
|
|
24
|
+
if (type === "string" || type === "number" || type === "boolean") continue;
|
|
25
|
+
if (type === "bigint" || type === "function" || type === "symbol") throw invalid(path, "must be JSON serializable");
|
|
26
|
+
if (!isObjectLike(item)) continue;
|
|
27
|
+
if (current.depth > limit) throw invalid(path, "max depth exceeded");
|
|
28
|
+
if (seen.has(item)) throw invalid(path, "must be JSON serializable");
|
|
29
|
+
seen.add(item);
|
|
30
|
+
const nextDepth = current.depth + 1;
|
|
31
|
+
if (Array.isArray(item)) {
|
|
32
|
+
for (let index = item.length - 1; index >= 0; index -= 1) stack.push({ value: item[index], depth: nextDepth });
|
|
33
|
+
} else {
|
|
34
|
+
for (const key of Object.keys(item)) stack.push({ value: item[key], depth: nextDepth });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function jsonByteLength(value, path = "value") {
|
|
40
|
+
let encoded;
|
|
41
|
+
try {
|
|
42
|
+
encoded = JSON.stringify(value);
|
|
43
|
+
} catch {
|
|
44
|
+
throw invalid(path, "must be JSON serializable");
|
|
45
|
+
}
|
|
46
|
+
if (encoded === undefined) throw invalid(path, "must be JSON serializable");
|
|
47
|
+
return Buffer.byteLength(encoded, "utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertJsonByteLength(value, path = "value", maxBytes = MAX_OPERATION_BYTES) {
|
|
51
|
+
const limit = optionLimit(maxBytes, MAX_OPERATION_BYTES);
|
|
52
|
+
if (!Number.isFinite(limit)) return jsonByteLength(value, path);
|
|
53
|
+
const bytes = jsonByteLength(value, path);
|
|
54
|
+
if (bytes > limit) throw invalid(path, "is too large");
|
|
55
|
+
return bytes;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertJsonBounds(value, path = "value", options = {}) {
|
|
59
|
+
assertJsonDepth(value, path, options.maxDepth);
|
|
60
|
+
return assertJsonByteLength(value, path, options.maxBytes);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertOperationBounds(operation, options = {}) {
|
|
64
|
+
return assertJsonBounds(operation, "operation", {
|
|
65
|
+
maxBytes: options.maxOperationBytes,
|
|
66
|
+
maxDepth: options.maxOperationDepth
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
assertJsonBounds,
|
|
72
|
+
assertJsonByteLength,
|
|
73
|
+
assertJsonDepth,
|
|
74
|
+
assertOperationBounds,
|
|
75
|
+
jsonByteLength,
|
|
76
|
+
MAX_PUSH_PAYLOAD_BYTES
|
|
77
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const { assertInteger, assertNumber, assertOptionalString, assertProtocol, assertRecord, assertString, isRecord } = require("../assertions.cjs");
|
|
2
|
+
const { CLIENT_PUSH_GRANT, CLIENT_PUSH_REGISTER } = require("../constants.cjs");
|
|
3
|
+
const { invalid } = require("../errors.cjs");
|
|
4
|
+
const { validateProfile, validateRelayAddresses } = require("./shared.cjs");
|
|
5
|
+
|
|
6
|
+
function validateClientHello(message) {
|
|
7
|
+
const value = assertRecord(message, "client/hello");
|
|
8
|
+
if (value.type !== "client/hello") throw invalid("client/hello.type", "is invalid");
|
|
9
|
+
assertProtocol(value, "client/hello");
|
|
10
|
+
assertString(value.roomName, "client/hello.roomName");
|
|
11
|
+
assertString(value.clientId, "client/hello.clientId");
|
|
12
|
+
if (value.profile !== undefined) validateProfile(value.profile, "client/hello.profile");
|
|
13
|
+
if (value.relayHints !== undefined) validateRelayAddresses(value.relayHints, "client/hello.relayHints");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateClientMutation(message) {
|
|
18
|
+
const value = assertRecord(message, "client/mutation");
|
|
19
|
+
if (value.type !== "client/mutation") throw invalid("client/mutation.type", "is invalid");
|
|
20
|
+
assertProtocol(value, "client/mutation");
|
|
21
|
+
assertString(value.roomName, "client/mutation.roomName");
|
|
22
|
+
const mutation = assertRecord(value.mutation, "client/mutation.mutation");
|
|
23
|
+
assertString(mutation.id, "client/mutation.mutation.id");
|
|
24
|
+
assertString(mutation.clientId, "client/mutation.mutation.clientId");
|
|
25
|
+
assertInteger(mutation.seq, "client/mutation.mutation.seq", 1);
|
|
26
|
+
assertNumber(mutation.ts, "client/mutation.mutation.ts");
|
|
27
|
+
assertString(mutation.op, "client/mutation.mutation.op");
|
|
28
|
+
if (mutation.payload !== undefined && !isRecord(mutation.payload)) throw invalid("client/mutation.mutation.payload", "must be an object");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateClientSnapshotRequest(message) {
|
|
33
|
+
const value = assertRecord(message, "client/snapshot-request");
|
|
34
|
+
if (value.type !== "client/snapshot-request") throw invalid("client/snapshot-request.type", "is invalid");
|
|
35
|
+
assertProtocol(value, "client/snapshot-request");
|
|
36
|
+
assertString(value.roomName, "client/snapshot-request.roomName");
|
|
37
|
+
assertString(value.clientId, "client/snapshot-request.clientId");
|
|
38
|
+
assertOptionalString(value.reason, "client/snapshot-request.reason");
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateCallSignal(signal, path) {
|
|
43
|
+
const value = assertRecord(signal, path);
|
|
44
|
+
assertString(value.type, `${path}.type`);
|
|
45
|
+
assertString(value.sessionId, `${path}.sessionId`);
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validateClientPeerSignal(message) {
|
|
50
|
+
const value = assertRecord(message, "client/peer-signal");
|
|
51
|
+
if (value.type !== "client/peer-signal") throw invalid("client/peer-signal.type", "is invalid");
|
|
52
|
+
assertProtocol(value, "client/peer-signal");
|
|
53
|
+
assertString(value.roomName, "client/peer-signal.roomName");
|
|
54
|
+
assertString(value.clientId, "client/peer-signal.clientId");
|
|
55
|
+
if (value.targetClientId === undefined && value.targetPeerId === undefined) throw invalid("client/peer-signal.target", "is required");
|
|
56
|
+
assertOptionalString(value.targetClientId, "client/peer-signal.targetClientId");
|
|
57
|
+
assertOptionalString(value.targetPeerId, "client/peer-signal.targetPeerId");
|
|
58
|
+
validateCallSignal(value.signal, "client/peer-signal.signal");
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validatePushSubscription(subscription, path) {
|
|
63
|
+
const value = assertRecord(subscription, path);
|
|
64
|
+
assertString(value.endpoint, `${path}.endpoint`, 2048);
|
|
65
|
+
const keys = assertRecord(value.keys, `${path}.keys`);
|
|
66
|
+
assertString(keys.p256dh, `${path}.keys.p256dh`, 512);
|
|
67
|
+
assertString(keys.auth, `${path}.keys.auth`, 512);
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateVapidGrant(grant, path) {
|
|
72
|
+
const value = assertRecord(grant, path);
|
|
73
|
+
if (value.kind !== "matterhorn.vapid-grant") throw invalid(`${path}.kind`, "is invalid");
|
|
74
|
+
if (value.version !== 1) throw invalid(`${path}.version`, "is invalid");
|
|
75
|
+
assertString(value.relayPublicKey, `${path}.relayPublicKey`, 2048);
|
|
76
|
+
assertNumber(value.createdAt, `${path}.createdAt`);
|
|
77
|
+
const wrap = assertRecord(value.wrap, `${path}.wrap`);
|
|
78
|
+
if (wrap.alg !== "x25519+hkdf-sha256+aes-256-gcm") throw invalid(`${path}.wrap.alg`, "is invalid");
|
|
79
|
+
assertString(wrap.ephemeralPublicKey, `${path}.wrap.ephemeralPublicKey`, 2048);
|
|
80
|
+
assertString(wrap.iv, `${path}.wrap.iv`, 128);
|
|
81
|
+
assertString(value.wrappedVapidPrivateKey, `${path}.wrappedVapidPrivateKey`, 1024);
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateClientPushRegister(message) {
|
|
86
|
+
const value = assertRecord(message, CLIENT_PUSH_REGISTER);
|
|
87
|
+
if (value.type !== CLIENT_PUSH_REGISTER) throw invalid(`${CLIENT_PUSH_REGISTER}.type`, "is invalid");
|
|
88
|
+
assertProtocol(value, CLIENT_PUSH_REGISTER);
|
|
89
|
+
assertString(value.roomName, `${CLIENT_PUSH_REGISTER}.roomName`);
|
|
90
|
+
assertString(value.clientId, `${CLIENT_PUSH_REGISTER}.clientId`);
|
|
91
|
+
assertString(value.userId, `${CLIENT_PUSH_REGISTER}.userId`);
|
|
92
|
+
validatePushSubscription(value.subscription, `${CLIENT_PUSH_REGISTER}.subscription`);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateClientPushGrant(message) {
|
|
97
|
+
const value = assertRecord(message, CLIENT_PUSH_GRANT);
|
|
98
|
+
if (value.type !== CLIENT_PUSH_GRANT) throw invalid(`${CLIENT_PUSH_GRANT}.type`, "is invalid");
|
|
99
|
+
assertProtocol(value, CLIENT_PUSH_GRANT);
|
|
100
|
+
assertString(value.clientId, `${CLIENT_PUSH_GRANT}.clientId`);
|
|
101
|
+
assertString(value.userId, `${CLIENT_PUSH_GRANT}.userId`);
|
|
102
|
+
assertString(value.relayId, `${CLIENT_PUSH_GRANT}.relayId`);
|
|
103
|
+
validateVapidGrant(value.grant, `${CLIENT_PUSH_GRANT}.grant`);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateClientMessage(message) {
|
|
108
|
+
const value = assertRecord(message, "client");
|
|
109
|
+
switch (value.type) {
|
|
110
|
+
case "client/hello": return validateClientHello(value);
|
|
111
|
+
case "client/mutation": return validateClientMutation(value);
|
|
112
|
+
case "client/snapshot-request": return validateClientSnapshotRequest(value);
|
|
113
|
+
case "client/peer-signal": return validateClientPeerSignal(value);
|
|
114
|
+
case CLIENT_PUSH_REGISTER: return validateClientPushRegister(value);
|
|
115
|
+
case CLIENT_PUSH_GRANT: return validateClientPushGrant(value);
|
|
116
|
+
default: throw invalid("client.type", "is invalid");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
validateCallSignal,
|
|
122
|
+
validateClientHello,
|
|
123
|
+
validateClientMessage,
|
|
124
|
+
validateClientMutation,
|
|
125
|
+
validateClientPeerSignal,
|
|
126
|
+
validateClientPushGrant,
|
|
127
|
+
validateClientPushRegister,
|
|
128
|
+
validateClientSnapshotRequest
|
|
129
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const { assertArray, assertInteger, assertNumber, assertProtocol, assertRecord, assertString, isRecord } = require("../assertions.cjs");
|
|
2
|
+
const { invalid } = require("../errors.cjs");
|
|
3
|
+
const { validateOperationKeyPin, validateRelayAddresses } = require("./shared.cjs");
|
|
4
|
+
const { validateHostOperationEntry } = require("./operations.cjs");
|
|
5
|
+
|
|
6
|
+
function validateHostInfo(message) {
|
|
7
|
+
const value = assertRecord(message, "host-info");
|
|
8
|
+
if (value.kind !== "matterhorn.host-info") throw invalid("host-info.kind", "is invalid");
|
|
9
|
+
assertString(value.version, "host-info.version");
|
|
10
|
+
assertString(value.roomId, "host-info.roomId");
|
|
11
|
+
assertInteger(value.roomVersion, "host-info.roomVersion", 0);
|
|
12
|
+
const appPack = assertRecord(value.appPack, "host-info.appPack");
|
|
13
|
+
assertString(appPack.id, "host-info.appPack.id");
|
|
14
|
+
assertString(appPack.hash, "host-info.appPack.hash");
|
|
15
|
+
assertString(appPack.protocolHash, "host-info.appPack.protocolHash");
|
|
16
|
+
assertArray(value.plugins, "host-info.plugins");
|
|
17
|
+
validateRelayAddresses(value.relays || [], "host-info.relays");
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateOperationTrustPin(value, path = "operationTrustPin") {
|
|
22
|
+
const pin = assertRecord(value, path);
|
|
23
|
+
if (pin.kind !== "matterhorn.operation-key-pin") throw invalid(`${path}.kind`, "is invalid");
|
|
24
|
+
if (pin.version !== 1) throw invalid(`${path}.version`, "is invalid");
|
|
25
|
+
assertString(pin.roomName, `${path}.roomName`);
|
|
26
|
+
assertString(pin.signer, `${path}.signer`);
|
|
27
|
+
if (pin.alg !== "ed25519") throw invalid(`${path}.alg`, "is invalid");
|
|
28
|
+
assertString(pin.publicKeyPem, `${path}.publicKeyPem`, 8192);
|
|
29
|
+
assertString(pin.fingerprint, `${path}.fingerprint`);
|
|
30
|
+
assertNumber(pin.pinnedAt, `${path}.pinnedAt`);
|
|
31
|
+
return pin;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validateHostOperations(message) {
|
|
35
|
+
const value = assertRecord(message, "host/operations");
|
|
36
|
+
if (value.type !== "host/operations") throw invalid("host/operations.type", "is invalid");
|
|
37
|
+
assertProtocol(value, "host/operations");
|
|
38
|
+
assertInteger(value.baseVersion, "host/operations.baseVersion", 0);
|
|
39
|
+
assertArray(value.operations, "host/operations.operations").forEach((op, index) => validateHostOperationEntry(op, `host/operations.operations[${index}]`));
|
|
40
|
+
if (value.publicKeys !== undefined && !isRecord(value.publicKeys)) throw invalid("host/operations.publicKeys", "must be an object");
|
|
41
|
+
if (value.operationKeyPin !== undefined) validateOperationKeyPin(value.operationKeyPin, "host/operations.operationKeyPin");
|
|
42
|
+
if (value.operationTrustPin !== undefined) validateOperationTrustPin(value.operationTrustPin, "host/operations.operationTrustPin");
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateHostMessage(message, path = "host") {
|
|
47
|
+
const value = assertRecord(message, path);
|
|
48
|
+
const type = assertString(value.type, `${path}.type`);
|
|
49
|
+
if (!type.startsWith("host/")) throw invalid(`${path}.type`, "is invalid");
|
|
50
|
+
if (value.protocol !== undefined) assertProtocol(value, path);
|
|
51
|
+
if (type === "host/operations") validateHostOperations(value);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
validateHostInfo,
|
|
57
|
+
validateHostMessage,
|
|
58
|
+
validateHostOperations,
|
|
59
|
+
validateOperationTrustPin
|
|
60
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const { LEGACY_ROOM_OPERATION_KIND, LEGACY_ROOM_OPERATION_VERSION } = require("../constants.cjs");
|
|
2
|
+
const { operationContentHash, validateHlc } = require("../operations/identity.cjs");
|
|
3
|
+
const { isSnowflakeId } = require("../operations/snowflake.cjs");
|
|
4
|
+
const { assertOperationBounds } = require("./bounds.cjs");
|
|
5
|
+
const { assertInteger, assertNumber, assertRecord, assertString, isRecord } = require("../assertions.cjs");
|
|
6
|
+
const { invalid } = require("../errors.cjs");
|
|
7
|
+
const { MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME } = require("../operations/roomDeviceSigning.cjs");
|
|
8
|
+
|
|
9
|
+
function validateRoomOperation(operation, options = {}) {
|
|
10
|
+
const op = assertRecord(operation, "operation");
|
|
11
|
+
assertOperationBounds(op, options);
|
|
12
|
+
assertString(op.id, "operation.id");
|
|
13
|
+
assertString(op.hlc, "operation.hlc");
|
|
14
|
+
const hlcValidation = validateHlc(op.hlc, { now: options.now, maxFutureSkewMs: options.maxFutureSkewMs });
|
|
15
|
+
if (!hlcValidation.ok) throw invalid("operation.hlc", hlcValidation.error.replace(/^operation\.hlc /, ""));
|
|
16
|
+
assertString(op.roomId, "operation.roomId");
|
|
17
|
+
assertString(op.appPackId, "operation.appPackId");
|
|
18
|
+
assertString(op.appPackHash, "operation.appPackHash");
|
|
19
|
+
assertString(op.pluginId, "operation.pluginId");
|
|
20
|
+
if (op.ledgerId !== undefined) {
|
|
21
|
+
assertString(op.ledgerId, "operation.ledgerId");
|
|
22
|
+
if (!isSnowflakeId(op.ledgerId)) throw invalid("operation.ledgerId", "must be a Snowflake ID");
|
|
23
|
+
}
|
|
24
|
+
if (op.snowflakeId !== undefined) {
|
|
25
|
+
assertString(op.snowflakeId, "operation.snowflakeId");
|
|
26
|
+
if (!isSnowflakeId(op.snowflakeId)) throw invalid("operation.snowflakeId", "must be a Snowflake ID");
|
|
27
|
+
}
|
|
28
|
+
assertString(op.type, "operation.type");
|
|
29
|
+
if (op.schemaAction !== undefined) assertString(op.schemaAction, "operation.schemaAction");
|
|
30
|
+
const actor = assertRecord(op.actor, "operation.actor");
|
|
31
|
+
assertString(actor.memberId, "operation.actor.memberId");
|
|
32
|
+
assertString(actor.deviceId, "operation.actor.deviceId");
|
|
33
|
+
assertString(actor.role, "operation.actor.role");
|
|
34
|
+
assertInteger(op.seq, "operation.seq", 1);
|
|
35
|
+
assertNumber(op.createdAt, "operation.createdAt");
|
|
36
|
+
if (op.payload !== undefined && !isRecord(op.payload)) throw invalid("operation.payload", "must be an object");
|
|
37
|
+
const auth = assertRecord(op.auth, "operation.auth");
|
|
38
|
+
if (auth.scheme === MATTERHORN_ROOM_DEVICE_SIGNING_SCHEME) {
|
|
39
|
+
assertString(auth.scheme, "operation.auth.scheme");
|
|
40
|
+
assertString(auth.alg, "operation.auth.alg");
|
|
41
|
+
assertString(auth.credentialId, "operation.auth.credentialId");
|
|
42
|
+
assertString(auth.memberId, "operation.auth.memberId");
|
|
43
|
+
assertString(auth.deviceId, "operation.auth.deviceId");
|
|
44
|
+
assertString(auth.keyId, "operation.auth.keyId");
|
|
45
|
+
assertString(auth.rootPublicKey, "operation.auth.rootPublicKey");
|
|
46
|
+
assertString(auth.publicKey, "operation.auth.publicKey");
|
|
47
|
+
assertString(auth.publicKeyFingerprint, "operation.auth.publicKeyFingerprint");
|
|
48
|
+
assertString(auth.signature, "operation.auth.signature");
|
|
49
|
+
if (auth.signatureEventId !== undefined) assertString(auth.signatureEventId, "operation.auth.signatureEventId");
|
|
50
|
+
if (auth.eventId !== undefined) assertString(auth.eventId, "operation.auth.eventId");
|
|
51
|
+
if (auth.signatureEventId === undefined && auth.eventId === undefined) throw invalid("operation.auth.signatureEventId", "is required");
|
|
52
|
+
assertNumber(auth.issuedAt, "operation.auth.issuedAt");
|
|
53
|
+
assertNumber(auth.signedAt, "operation.auth.signedAt");
|
|
54
|
+
if (auth.claim !== undefined && !isRecord(auth.claim)) throw invalid("operation.auth.claim", "must be an object");
|
|
55
|
+
if (auth.memberId !== actor.memberId) throw invalid("operation.auth.memberId", "does not match actor memberId");
|
|
56
|
+
if (auth.deviceId !== actor.deviceId) throw invalid("operation.auth.deviceId", "does not match actor deviceId");
|
|
57
|
+
} else {
|
|
58
|
+
assertString(auth.credentialId, "operation.auth.credentialId");
|
|
59
|
+
assertString(auth.signature, "operation.auth.signature");
|
|
60
|
+
if (auth.kind !== undefined) assertString(auth.kind, "operation.auth.kind");
|
|
61
|
+
if (auth.alg !== undefined) assertString(auth.alg, "operation.auth.alg");
|
|
62
|
+
if (auth.keyRole !== undefined) assertString(auth.keyRole, "operation.auth.keyRole");
|
|
63
|
+
if (auth.publicKeyFingerprint !== undefined) assertString(auth.publicKeyFingerprint, "operation.auth.publicKeyFingerprint");
|
|
64
|
+
if (auth.issuedAt !== undefined) assertNumber(auth.issuedAt, "operation.auth.issuedAt");
|
|
65
|
+
}
|
|
66
|
+
const hashOperation = typeof options.operationContentHash === "function" ? options.operationContentHash : operationContentHash;
|
|
67
|
+
if (options.validateId !== false && op.id !== hashOperation(op)) throw invalid("operation.id", "does not match operation content hash");
|
|
68
|
+
if (options.roomId && op.roomId !== options.roomId) throw invalid("operation.roomId", "does not match expected room");
|
|
69
|
+
if (options.appPackId && op.appPackId !== options.appPackId) throw invalid("operation.appPackId", "does not match expected app pack");
|
|
70
|
+
if (options.appPackHash && op.appPackHash !== options.appPackHash) throw invalid("operation.appPackHash", "does not match expected app pack hash");
|
|
71
|
+
return op;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateLegacyRoomOperation(operation, path = "operation") {
|
|
75
|
+
const op = assertRecord(operation, path);
|
|
76
|
+
if (op.kind !== LEGACY_ROOM_OPERATION_KIND) throw invalid(`${path}.kind`, "is invalid");
|
|
77
|
+
if (op.version !== LEGACY_ROOM_OPERATION_VERSION) throw invalid(`${path}.version`, "is invalid");
|
|
78
|
+
assertString(op.roomName, `${path}.roomName`);
|
|
79
|
+
assertInteger(op.baseVersion, `${path}.baseVersion`, 0);
|
|
80
|
+
assertInteger(op.sequence, `${path}.sequence`, 1);
|
|
81
|
+
assertNumber(op.issuedAt, `${path}.issuedAt`);
|
|
82
|
+
assertString(op.signer, `${path}.signer`);
|
|
83
|
+
if (op.role !== "admin" && op.role !== "guest") throw invalid(`${path}.role`, "is invalid");
|
|
84
|
+
const mutation = assertRecord(op.mutation, `${path}.mutation`);
|
|
85
|
+
assertString(mutation.id, `${path}.mutation.id`);
|
|
86
|
+
assertString(mutation.clientId, `${path}.mutation.clientId`);
|
|
87
|
+
assertInteger(mutation.seq, `${path}.mutation.seq`, 1);
|
|
88
|
+
assertNumber(mutation.ts, `${path}.mutation.ts`);
|
|
89
|
+
assertString(mutation.op, `${path}.mutation.op`);
|
|
90
|
+
if (mutation.payload !== undefined && !isRecord(mutation.payload)) throw invalid(`${path}.mutation.payload`, "must be an object");
|
|
91
|
+
assertString(op.signature, `${path}.signature`, 8192);
|
|
92
|
+
return op;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateHostOperationEntry(operation, path) {
|
|
96
|
+
const op = assertRecord(operation, path);
|
|
97
|
+
if (op.kind === LEGACY_ROOM_OPERATION_KIND) return validateLegacyRoomOperation(op, path);
|
|
98
|
+
return validateRoomOperation(op);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseRoomOperation(operation, options = {}) {
|
|
102
|
+
return validateRoomOperation(operation, options);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
parseRoomOperation,
|
|
107
|
+
validateHostOperationEntry,
|
|
108
|
+
validateLegacyRoomOperation,
|
|
109
|
+
validateRoomOperation
|
|
110
|
+
};
|