@mh-gg/host-store 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 +22 -0
- package/src/index.cjs +156 -0
- package/src/operationSigning.cjs +58 -0
- package/src/schema.cjs +47 -0
- package/test/host-store.test.cjs +277 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/host-store",
|
|
3
|
+
"version": "0.1.1-alpha.20260613T085325975Z",
|
|
4
|
+
"description": "Node host room store persistence for matterhorn.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@mh-gg/room-link": "^0.1.1-alpha.20260613T085325975Z",
|
|
12
|
+
"@mh-gg/room-security": "^0.1.1-alpha.20260613T085325975Z",
|
|
13
|
+
"@mh-gg/room-state": "^0.1.1-alpha.20260613T085325975Z"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.12"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/*.test.cjs",
|
|
20
|
+
"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"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { roomToPeerId } = require("@mh-gg/room-link");
|
|
5
|
+
const { activeAdminProfileIds, ensureRoomSecurity } = require("@mh-gg/room-security");
|
|
6
|
+
const { createAdminBootstrap, defaultState } = require("@mh-gg/room-state");
|
|
7
|
+
const {
|
|
8
|
+
CURRENT_STORE_SCHEMA_VERSION,
|
|
9
|
+
migrateStore,
|
|
10
|
+
prepareStoreForSave
|
|
11
|
+
} = require("./schema.cjs");
|
|
12
|
+
const { ensureOperationSigningKey, operationKeyPin, operationPublicKeyHash } = require("./operationSigning.cjs");
|
|
13
|
+
|
|
14
|
+
const PRIVATE_DIR_MODE = 0o700;
|
|
15
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
16
|
+
|
|
17
|
+
function attachDataDir(store, dataDir) {
|
|
18
|
+
Object.defineProperty(store, "dataDir", {
|
|
19
|
+
value: dataDir,
|
|
20
|
+
configurable: true,
|
|
21
|
+
writable: true
|
|
22
|
+
});
|
|
23
|
+
return store;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function randomSecret() {
|
|
27
|
+
return crypto.randomBytes(24).toString("base64url");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attachAdminBootstrapToken(store, token) {
|
|
31
|
+
Object.defineProperty(store, "adminBootstrapToken", {
|
|
32
|
+
value: token,
|
|
33
|
+
configurable: true,
|
|
34
|
+
writable: true
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stateFile(dataDir, roomName) {
|
|
39
|
+
return path.join(dataDir, `${roomName}.json`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function chmodPrivate(file, mode) {
|
|
43
|
+
try {
|
|
44
|
+
fs.chmodSync(file, mode);
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ensurePrivateDir(dir) {
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
50
|
+
chmodPrivate(dir, PRIVATE_DIR_MODE);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fsyncDir(dir) {
|
|
54
|
+
if (process.platform === "win32") return;
|
|
55
|
+
let fd;
|
|
56
|
+
try {
|
|
57
|
+
fd = fs.openSync(dir, "r");
|
|
58
|
+
fs.fsyncSync(fd);
|
|
59
|
+
} catch {
|
|
60
|
+
} finally {
|
|
61
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function atomicWritePrivateFile(file, data) {
|
|
66
|
+
ensurePrivateDir(path.dirname(file));
|
|
67
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
68
|
+
let fd;
|
|
69
|
+
try {
|
|
70
|
+
fd = fs.openSync(tmp, "wx", PRIVATE_FILE_MODE);
|
|
71
|
+
fs.writeFileSync(fd, data);
|
|
72
|
+
fs.fsyncSync(fd);
|
|
73
|
+
fs.closeSync(fd);
|
|
74
|
+
fd = undefined;
|
|
75
|
+
chmodPrivate(tmp, PRIVATE_FILE_MODE);
|
|
76
|
+
fs.renameSync(tmp, file);
|
|
77
|
+
chmodPrivate(file, PRIVATE_FILE_MODE);
|
|
78
|
+
fsyncDir(path.dirname(file));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (fd !== undefined) {
|
|
81
|
+
try { fs.closeSync(fd); } catch {}
|
|
82
|
+
}
|
|
83
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasStoredSecurity(store) {
|
|
89
|
+
return (Array.isArray(store.publicInvites) && store.publicInvites.length > 0)
|
|
90
|
+
|| (Array.isArray(store.keyEpochs) && store.keyEpochs.length > 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ensureAdminBootstrap(store) {
|
|
94
|
+
if (activeAdminProfileIds(store).length > 0) return false;
|
|
95
|
+
if (store.adminBootstrap?.tokenHash && store.adminBootstrapToken) return false;
|
|
96
|
+
attachAdminBootstrapToken(store, createAdminBootstrap(store));
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadStore(args) {
|
|
101
|
+
ensurePrivateDir(args.dataDir);
|
|
102
|
+
const file = stateFile(args.dataDir, args.room);
|
|
103
|
+
if (fs.existsSync(file)) {
|
|
104
|
+
const store = attachDataDir(migrateStore(JSON.parse(fs.readFileSync(file, "utf8")), args), args.dataDir);
|
|
105
|
+
if (args.roomSecret && args.roomSecret !== store.roomSecret) {
|
|
106
|
+
if (hasStoredSecurity(store)) {
|
|
107
|
+
console.warn("Ignoring ROOM_SECRET/--secret because this room already has membership credentials.");
|
|
108
|
+
} else {
|
|
109
|
+
console.warn("Using ROOM_SECRET/--secret from CLI because this room has no membership credentials yet.");
|
|
110
|
+
store.roomSecret = args.roomSecret;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
ensureRoomSecurity(store);
|
|
114
|
+
if (ensureAdminBootstrap(store)) saveStore(store);
|
|
115
|
+
return store;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const store = attachDataDir({
|
|
119
|
+
schemaVersion: CURRENT_STORE_SCHEMA_VERSION,
|
|
120
|
+
roomName: args.room,
|
|
121
|
+
roomPeerId: roomToPeerId(args.room),
|
|
122
|
+
roomSecret: args.roomSecret || randomSecret(),
|
|
123
|
+
admins: {},
|
|
124
|
+
seenMutations: [],
|
|
125
|
+
state: defaultState(args)
|
|
126
|
+
}, args.dataDir);
|
|
127
|
+
ensureRoomSecurity(store);
|
|
128
|
+
ensureAdminBootstrap(store);
|
|
129
|
+
saveStore(store);
|
|
130
|
+
return store;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function saveStore(store) {
|
|
134
|
+
atomicWritePrivateFile(stateFile(store.dataDir, store.roomName), JSON.stringify(prepareStoreForSave(store), null, 2));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function touchState(store, now = Date.now()) {
|
|
138
|
+
ensureRoomSecurity(store, now);
|
|
139
|
+
store.state.version += 1;
|
|
140
|
+
store.state.updatedAt = now;
|
|
141
|
+
saveStore(store);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
CURRENT_STORE_SCHEMA_VERSION,
|
|
146
|
+
attachDataDir,
|
|
147
|
+
ensureOperationSigningKey,
|
|
148
|
+
operationKeyPin,
|
|
149
|
+
operationPublicKeyHash,
|
|
150
|
+
loadStore,
|
|
151
|
+
migrateStore,
|
|
152
|
+
randomSecret,
|
|
153
|
+
saveStore,
|
|
154
|
+
stateFile,
|
|
155
|
+
touchState
|
|
156
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
|
|
3
|
+
function operationPublicKeyHash(publicKeyPem) {
|
|
4
|
+
return `sha256-${crypto.createHash("sha256").update(publicKeyPem).digest("hex")}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function operationSignerId(publicKeyPem) {
|
|
8
|
+
const digest = crypto.createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 16);
|
|
9
|
+
return `host_${digest}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function operationKeyPin(signingKey) {
|
|
13
|
+
if (!isOperationSigningKey(signingKey)) throw new Error("Operation signing key is invalid");
|
|
14
|
+
return {
|
|
15
|
+
alg: signingKey.alg,
|
|
16
|
+
signer: signingKey.id,
|
|
17
|
+
publicKeyHash: operationPublicKeyHash(signingKey.publicKeyPem)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createOperationSigningKey(now = Date.now()) {
|
|
22
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
23
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
|
24
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
25
|
+
return {
|
|
26
|
+
id: operationSignerId(publicKeyPem),
|
|
27
|
+
alg: "ed25519",
|
|
28
|
+
publicKeyPem,
|
|
29
|
+
privateKeyPem,
|
|
30
|
+
createdAt: now
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isOperationSigningKey(value) {
|
|
35
|
+
return Boolean(value)
|
|
36
|
+
&& typeof value === "object"
|
|
37
|
+
&& value.alg === "ed25519"
|
|
38
|
+
&& typeof value.id === "string"
|
|
39
|
+
&& value.id.length > 0
|
|
40
|
+
&& typeof value.publicKeyPem === "string"
|
|
41
|
+
&& value.publicKeyPem.length > 0
|
|
42
|
+
&& typeof value.privateKeyPem === "string"
|
|
43
|
+
&& value.privateKeyPem.length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensureOperationSigningKey(store) {
|
|
47
|
+
if (isOperationSigningKey(store.operationSigningKey)) return store.operationSigningKey;
|
|
48
|
+
store.operationSigningKey = createOperationSigningKey();
|
|
49
|
+
return store.operationSigningKey;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
createOperationSigningKey,
|
|
54
|
+
ensureOperationSigningKey,
|
|
55
|
+
operationKeyPin,
|
|
56
|
+
operationPublicKeyHash,
|
|
57
|
+
operationSignerId
|
|
58
|
+
};
|
package/src/schema.cjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const { roomToPeerId } = require("@mh-gg/room-link");
|
|
2
|
+
|
|
3
|
+
const CURRENT_STORE_SCHEMA_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
function unsupportedSchemaVersion(version) {
|
|
6
|
+
throw new Error(`Unsupported room store schema version ${version}; current version is ${CURRENT_STORE_SCHEMA_VERSION}.`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function schemaVersionFor(store) {
|
|
10
|
+
if (store.schemaVersion === undefined) return 0;
|
|
11
|
+
if (!Number.isInteger(store.schemaVersion) || store.schemaVersion < 0) unsupportedSchemaVersion(String(store.schemaVersion));
|
|
12
|
+
if (store.schemaVersion > CURRENT_STORE_SCHEMA_VERSION) unsupportedSchemaVersion(store.schemaVersion);
|
|
13
|
+
return store.schemaVersion;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function migrateLegacyStoreToV1(store, args = {}) {
|
|
17
|
+
if (!store.roomName && args.room) store.roomName = args.room;
|
|
18
|
+
if (!store.roomPeerId && store.roomName) store.roomPeerId = roomToPeerId(store.roomName);
|
|
19
|
+
store.schemaVersion = 1;
|
|
20
|
+
return store;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function migrateStore(store, args = {}) {
|
|
24
|
+
if (!store || typeof store !== "object" || Array.isArray(store)) {
|
|
25
|
+
throw new Error("Invalid room store file.");
|
|
26
|
+
}
|
|
27
|
+
const version = schemaVersionFor(store);
|
|
28
|
+
if (version === 0) return migrateLegacyStoreToV1(store, args);
|
|
29
|
+
return store;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function prepareStoreForSave(store) {
|
|
33
|
+
if (!store || typeof store !== "object" || Array.isArray(store)) {
|
|
34
|
+
throw new Error("Invalid room store file.");
|
|
35
|
+
}
|
|
36
|
+
const version = schemaVersionFor(store);
|
|
37
|
+
if (version < CURRENT_STORE_SCHEMA_VERSION) migrateLegacyStoreToV1(store);
|
|
38
|
+
if (!store.roomPeerId && store.roomName) store.roomPeerId = roomToPeerId(store.roomName);
|
|
39
|
+
store.schemaVersion = CURRENT_STORE_SCHEMA_VERSION;
|
|
40
|
+
return store;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
CURRENT_STORE_SCHEMA_VERSION,
|
|
45
|
+
migrateStore,
|
|
46
|
+
prepareStoreForSave
|
|
47
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const test = require("node:test");
|
|
6
|
+
const { activePublicInvite } = require("@mh-gg/room-security");
|
|
7
|
+
const { CURRENT_STORE_SCHEMA_VERSION, attachDataDir, ensureOperationSigningKey, operationKeyPin, operationPublicKeyHash, loadStore, saveStore, stateFile, touchState } = require("..");
|
|
8
|
+
|
|
9
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "matterhorn-host-store-"));
|
|
10
|
+
|
|
11
|
+
test.after(() => {
|
|
12
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function args(roomName) {
|
|
16
|
+
return {
|
|
17
|
+
room: roomName,
|
|
18
|
+
title: "Store Test",
|
|
19
|
+
date: "2026-06-20",
|
|
20
|
+
time: "8:00 PM",
|
|
21
|
+
location: "Test Venue",
|
|
22
|
+
description: "",
|
|
23
|
+
roomSecret: "store-secret",
|
|
24
|
+
dataDir: fs.mkdtempSync(path.join(tmpRoot, "room-")),
|
|
25
|
+
theme: "sunset"
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function captureWarnings(callback) {
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const previousWarn = console.warn;
|
|
32
|
+
console.warn = (message) => warnings.push(message);
|
|
33
|
+
try {
|
|
34
|
+
return {
|
|
35
|
+
value: callback(),
|
|
36
|
+
warnings
|
|
37
|
+
};
|
|
38
|
+
} finally {
|
|
39
|
+
console.warn = previousWarn;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function legacyStore(config, overrides = {}) {
|
|
44
|
+
return {
|
|
45
|
+
roomName: config.room,
|
|
46
|
+
roomSecret: "saved-bootstrap-secret",
|
|
47
|
+
admins: {},
|
|
48
|
+
seenMutations: [],
|
|
49
|
+
state: {
|
|
50
|
+
version: 0,
|
|
51
|
+
updatedAt: 0,
|
|
52
|
+
details: {
|
|
53
|
+
id: `event_${config.room}`,
|
|
54
|
+
roomName: config.room,
|
|
55
|
+
title: "Credential Bootstrap Store Test",
|
|
56
|
+
date: "2026-06-20",
|
|
57
|
+
time: "8:00 PM",
|
|
58
|
+
location: "Test Venue",
|
|
59
|
+
description: "",
|
|
60
|
+
coverEmoji: "*",
|
|
61
|
+
dressCode: "",
|
|
62
|
+
hostNote: "",
|
|
63
|
+
theme: "sunset"
|
|
64
|
+
},
|
|
65
|
+
guests: {},
|
|
66
|
+
posts: [],
|
|
67
|
+
comments: [],
|
|
68
|
+
adminIds: []
|
|
69
|
+
},
|
|
70
|
+
...overrides
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeStore(config, store) {
|
|
75
|
+
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(stateFile(config.dataDir, config.room), JSON.stringify(store, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeUnsecuredStore(config) {
|
|
80
|
+
writeStore(config, legacyStore(config));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertPrivatePathModes(dir, file) {
|
|
84
|
+
if (process.platform === "win32") return;
|
|
85
|
+
assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
|
|
86
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test("creates and persists secured room stores", () => {
|
|
90
|
+
const config = args("store-room");
|
|
91
|
+
const store = loadStore(config);
|
|
92
|
+
const file = stateFile(config.dataDir, config.room);
|
|
93
|
+
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
94
|
+
|
|
95
|
+
assert.equal(fs.existsSync(file), true);
|
|
96
|
+
assertPrivatePathModes(config.dataDir, file);
|
|
97
|
+
assert.equal(fs.readdirSync(config.dataDir).some((name) => name.endsWith(".tmp")), false);
|
|
98
|
+
assert.equal(store.schemaVersion, CURRENT_STORE_SCHEMA_VERSION);
|
|
99
|
+
assert.equal(saved.schemaVersion, CURRENT_STORE_SCHEMA_VERSION);
|
|
100
|
+
assert.equal(store.roomPeerId, "matterhorn-store-room");
|
|
101
|
+
assert.equal(store.roomSecret, "store-secret");
|
|
102
|
+
assert.equal(saved.dataDir, undefined);
|
|
103
|
+
assert.equal(typeof activePublicInvite(store).id, "string");
|
|
104
|
+
assert.equal(typeof store.adminBootstrapToken, "string");
|
|
105
|
+
assert.equal(typeof saved.adminBootstrap.tokenHash, "string");
|
|
106
|
+
assert.equal(saved.adminBootstrapToken, undefined);
|
|
107
|
+
const firstBootstrapHash = store.adminBootstrap.tokenHash;
|
|
108
|
+
|
|
109
|
+
store.state.details.title = "Changed Store Test";
|
|
110
|
+
saveStore(store);
|
|
111
|
+
assertPrivatePathModes(config.dataDir, file);
|
|
112
|
+
assert.equal(fs.readdirSync(config.dataDir).some((name) => name.endsWith(".tmp")), false);
|
|
113
|
+
const reloaded = loadStore({ ...config, roomSecret: "" });
|
|
114
|
+
assert.equal(reloaded.schemaVersion, CURRENT_STORE_SCHEMA_VERSION);
|
|
115
|
+
assert.equal(reloaded.state.details.title, "Changed Store Test");
|
|
116
|
+
assert.equal(typeof reloaded.adminBootstrapToken, "string");
|
|
117
|
+
assert.notEqual(reloaded.adminBootstrap.tokenHash, firstBootstrapHash);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("migrates legacy stores and preserves security fields", () => {
|
|
121
|
+
const config = args("legacy-secured-store-room");
|
|
122
|
+
const sharedSecret = "legacy-shared-secret";
|
|
123
|
+
writeStore(config, legacyStore(config, {
|
|
124
|
+
roomPeerId: "matterhorn-legacy-secured-store-room",
|
|
125
|
+
roomSecret: sharedSecret,
|
|
126
|
+
relayAddress: "peerjs:legacy-relay",
|
|
127
|
+
admins: {
|
|
128
|
+
admin: { tokenHash: "legacy-admin-hash", grantedAt: 10 }
|
|
129
|
+
},
|
|
130
|
+
adminBootstrap: {
|
|
131
|
+
tokenHash: "legacy-bootstrap-hash",
|
|
132
|
+
createdAt: 11
|
|
133
|
+
},
|
|
134
|
+
members: {
|
|
135
|
+
member_admin: {
|
|
136
|
+
id: "member_admin",
|
|
137
|
+
profileId: "admin",
|
|
138
|
+
role: "admin",
|
|
139
|
+
secret: "legacy-member-secret",
|
|
140
|
+
createdAt: 12,
|
|
141
|
+
lastSeenAt: 13
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
publicInvites: [{
|
|
145
|
+
id: "invite_legacy",
|
|
146
|
+
type: "public",
|
|
147
|
+
status: "active",
|
|
148
|
+
secret: sharedSecret,
|
|
149
|
+
createdAt: 14
|
|
150
|
+
}],
|
|
151
|
+
keyEpochs: [{
|
|
152
|
+
id: "epoch_legacy",
|
|
153
|
+
secret: sharedSecret,
|
|
154
|
+
createdAt: 15
|
|
155
|
+
}],
|
|
156
|
+
state: {
|
|
157
|
+
...legacyStore(config).state,
|
|
158
|
+
adminIds: ["admin"],
|
|
159
|
+
guests: {
|
|
160
|
+
admin: {
|
|
161
|
+
id: "admin",
|
|
162
|
+
memberId: "member_admin",
|
|
163
|
+
name: "Admin",
|
|
164
|
+
avatar: "*",
|
|
165
|
+
rsvp: "yes",
|
|
166
|
+
role: "admin",
|
|
167
|
+
joinedAt: 12,
|
|
168
|
+
lastSeenAt: 13
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
const store = loadStore(config);
|
|
175
|
+
assert.equal(store.schemaVersion, CURRENT_STORE_SCHEMA_VERSION);
|
|
176
|
+
assert.equal(store.relayAddress, "peerjs:legacy-relay");
|
|
177
|
+
assert.equal(store.admins.admin.tokenHash, "legacy-admin-hash");
|
|
178
|
+
assert.equal(store.adminBootstrap.tokenHash, "legacy-bootstrap-hash");
|
|
179
|
+
assert.equal(store.members.member_admin.role, "admin");
|
|
180
|
+
assert.equal(store.members.member_admin.secret, undefined);
|
|
181
|
+
assert.equal(typeof store.members.member_admin.secretHash, "string");
|
|
182
|
+
assert.equal(activePublicInvite(store).id, "invite_legacy");
|
|
183
|
+
assert.notEqual(store.keyEpochs[0].id, "epoch_legacy");
|
|
184
|
+
assert.equal(typeof store.keyEpochs[0].epochKey, "string");
|
|
185
|
+
assert.equal(store.security?.legacySharedInviteKey, undefined);
|
|
186
|
+
|
|
187
|
+
saveStore(store);
|
|
188
|
+
const saved = JSON.parse(fs.readFileSync(stateFile(config.dataDir, config.room), "utf8"));
|
|
189
|
+
assert.equal(saved.schemaVersion, CURRENT_STORE_SCHEMA_VERSION);
|
|
190
|
+
assert.equal(saved.relayAddress, "peerjs:legacy-relay");
|
|
191
|
+
assert.equal(saved.admins.admin.tokenHash, "legacy-admin-hash");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("rejects future store schema versions", () => {
|
|
195
|
+
const config = args("future-store-room");
|
|
196
|
+
writeStore(config, legacyStore(config, { schemaVersion: CURRENT_STORE_SCHEMA_VERSION + 1 }));
|
|
197
|
+
|
|
198
|
+
assert.throws(
|
|
199
|
+
() => loadStore(config),
|
|
200
|
+
/Unsupported room store schema version/
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("rejects invalid store schema versions", () => {
|
|
205
|
+
const config = args("invalid-schema-store-room");
|
|
206
|
+
writeStore(config, legacyStore(config, { schemaVersion: "1" }));
|
|
207
|
+
|
|
208
|
+
assert.throws(
|
|
209
|
+
() => loadStore(config),
|
|
210
|
+
/Unsupported room store schema version/
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("saveStore upgrades in-memory legacy stores", () => {
|
|
215
|
+
const config = args("save-legacy-store-room");
|
|
216
|
+
const store = attachDataDir(legacyStore(config), config.dataDir);
|
|
217
|
+
saveStore(store);
|
|
218
|
+
|
|
219
|
+
const saved = JSON.parse(fs.readFileSync(stateFile(config.dataDir, config.room), "utf8"));
|
|
220
|
+
assert.equal(saved.schemaVersion, CURRENT_STORE_SCHEMA_VERSION);
|
|
221
|
+
assert.equal(saved.roomPeerId, "matterhorn-save-legacy-store-room");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("ignores CLI secret overrides for stores with membership credentials", () => {
|
|
225
|
+
const config = args("modern-store-room");
|
|
226
|
+
loadStore(config);
|
|
227
|
+
const { value: store, warnings } = captureWarnings(() => loadStore({ ...config, roomSecret: "override-secret" }));
|
|
228
|
+
|
|
229
|
+
assert.equal(store.roomSecret, "store-secret");
|
|
230
|
+
assert.deepEqual(warnings, ["Ignoring ROOM_SECRET/--secret because this room already has membership credentials."]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("applies CLI secret overrides for stores without membership credentials", () => {
|
|
234
|
+
const config = args("credential-bootstrap-store-room");
|
|
235
|
+
writeUnsecuredStore(config);
|
|
236
|
+
const { value: store, warnings } = captureWarnings(() => loadStore({ ...config, roomSecret: "override-secret" }));
|
|
237
|
+
|
|
238
|
+
assert.equal(store.schemaVersion, CURRENT_STORE_SCHEMA_VERSION);
|
|
239
|
+
assert.equal(store.roomSecret, "override-secret");
|
|
240
|
+
assert.equal(activePublicInvite(store).secret, "override-secret");
|
|
241
|
+
assert.deepEqual(warnings, ["Using ROOM_SECRET/--secret from CLI because this room has no membership credentials yet."]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("touchState updates version, timestamp, and persisted file", () => {
|
|
245
|
+
const config = args("touch-store-room");
|
|
246
|
+
const store = loadStore(config);
|
|
247
|
+
touchState(store, 12345);
|
|
248
|
+
|
|
249
|
+
const saved = JSON.parse(fs.readFileSync(stateFile(config.dataDir, config.room), "utf8"));
|
|
250
|
+
assert.equal(saved.state.version, 1);
|
|
251
|
+
assert.equal(saved.state.updatedAt, 12345);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("operation signing keys are generated once and persisted", () => {
|
|
255
|
+
const config = args("operation-signing-store-room");
|
|
256
|
+
const store = loadStore(config);
|
|
257
|
+
|
|
258
|
+
const key = ensureOperationSigningKey(store);
|
|
259
|
+
const savedAgain = ensureOperationSigningKey(store);
|
|
260
|
+
saveStore(store);
|
|
261
|
+
const reloaded = loadStore({ ...config, roomSecret: "" });
|
|
262
|
+
|
|
263
|
+
assert.equal(key.id.startsWith("host_"), true);
|
|
264
|
+
assert.equal(key.alg, "ed25519");
|
|
265
|
+
assert.equal(key.publicKeyPem.includes("PUBLIC KEY"), true);
|
|
266
|
+
assert.equal(key.privateKeyPem.includes("PRIVATE KEY"), true);
|
|
267
|
+
assert.equal(savedAgain.id, key.id);
|
|
268
|
+
assert.deepEqual(ensureOperationSigningKey(reloaded), key);
|
|
269
|
+
|
|
270
|
+
const pin = operationKeyPin(key);
|
|
271
|
+
assert.deepEqual(pin, {
|
|
272
|
+
alg: "ed25519",
|
|
273
|
+
signer: key.id,
|
|
274
|
+
publicKeyHash: operationPublicKeyHash(key.publicKeyPem)
|
|
275
|
+
});
|
|
276
|
+
assert.equal(pin.publicKeyHash.startsWith("sha256-"), true);
|
|
277
|
+
});
|