@mh-gg/host-runtime 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 +23 -0
- package/src/constants.cjs +9 -0
- package/src/errors.cjs +39 -0
- package/src/host/installAppPack.cjs +35 -0
- package/src/host/migrations.cjs +21 -0
- package/src/host/runtimeFromPack.cjs +25 -0
- package/src/host/startRoomHost.cjs +95 -0
- package/src/index.cjs +23 -0
- package/src/memory.cjs +81 -0
- package/src/plugins/definition.cjs +19 -0
- package/src/plugins/install.cjs +90 -0
- package/src/plugins/migrations.cjs +27 -0
- package/src/plugins/operationDescriptors.cjs +138 -0
- package/src/runtime/HostPluginRuntime.cjs +85 -0
- package/src/runtime/authorityReplay/applyAuthority.cjs +146 -0
- package/src/runtime/authorityReplay/applyContent.cjs +49 -0
- package/src/runtime/authorityReplay/index.cjs +70 -0
- package/src/runtime/authorityReplay/state.cjs +56 -0
- package/src/runtime/context.cjs +65 -0
- package/src/runtime/coreOperations.cjs +169 -0
- package/src/runtime/corePayloads.cjs +66 -0
- package/src/runtime/directMessages/commit.cjs +50 -0
- package/src/runtime/directMessages/constants.cjs +20 -0
- package/src/runtime/directMessages/helpers.cjs +59 -0
- package/src/runtime/directMessages/payloads.cjs +74 -0
- package/src/runtime/directMessages/state.cjs +168 -0
- package/src/runtime/directMessages.cjs +6 -0
- package/src/runtime/lifecycle.cjs +166 -0
- package/src/runtime/memberProfiles.cjs +74 -0
- package/src/runtime/methods.cjs +10 -0
- package/src/runtime/operations.cjs +166 -0
- package/src/runtime/queries.cjs +146 -0
- package/src/runtime/readTags.cjs +171 -0
- package/src/runtime/scopedRoleOperations.cjs +97 -0
- package/src/runtime/snowflake.cjs +43 -0
- package/src/security/authority/constants.cjs +10 -0
- package/src/security/authority/resolve/operations.cjs +7 -0
- package/src/security/authority/resolve/policy.cjs +7 -0
- package/src/security/authority/resolve/voids.cjs +8 -0
- package/src/security/authorization/coreGate.cjs +63 -0
- package/src/security/authorization/schemaActions.cjs +75 -0
- package/src/security/roleKeys/authenticator.cjs +36 -0
- package/src/security/roleKeys/authorities/index.cjs +4 -0
- package/src/security/roleKeys/authorities/shapes.cjs +98 -0
- package/src/security/roleKeys/authorities/signing.cjs +121 -0
- package/src/security/roleKeys/constants.cjs +15 -0
- package/src/security/roleKeys/fingerprints.cjs +24 -0
- package/src/security/roleKeys/grants.cjs +93 -0
- package/src/security/roleKeys/index.cjs +10 -0
- package/src/security/roleKeys/roles.cjs +21 -0
- package/src/security/roleKeys/signatures.cjs +126 -0
- package/src/security/roles.cjs +10 -0
- package/src/security/roomDeviceKeys.cjs +41 -0
- package/src/security/scopedRoles/access.cjs +123 -0
- package/src/security/scopedRoles/constants.cjs +23 -0
- package/src/security/scopedRoles/metadata.cjs +39 -0
- package/src/security/scopedRoles/normalize.cjs +179 -0
- package/src/security/scopedRoles/publicView.cjs +31 -0
- package/src/security/scopedRoles/stateOps.cjs +167 -0
- package/src/security/scopedRoles.cjs +7 -0
- package/src/security/standingAuthority.cjs +76 -0
- package/src/shared.cjs +14 -0
- package/src/state.cjs +54 -0
- package/test/authority-ordering-hardening.test.cjs +101 -0
- package/test/authorization-gate.test.cjs +610 -0
- package/test/cascading-authority.test.cjs +390 -0
- package/test/grant-authority-security.test.cjs +305 -0
- package/test/matterhorn-host-runtime.test.cjs +1629 -0
- package/test/operation-descriptor-policy.test.cjs +140 -0
- package/test/role-key-auth.test.cjs +289 -0
- package/test/security-isolation.test.cjs +112 -0
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/host-runtime",
|
|
3
|
+
"version": "0.1.1-alpha.20260613T085325975Z",
|
|
4
|
+
"description": "Authoritative Matterhorn host plugin runtime.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@mh-gg/event": "^0.1.1-alpha.20260613T085325975Z",
|
|
12
|
+
"@mh-gg/authority": "^0.1.1-alpha.20260603T124500153Z",
|
|
13
|
+
"@mh-gg/base": "^0.1.1-alpha.20260613T085325975Z",
|
|
14
|
+
"@mh-gg/protocol": "^0.1.1-alpha.20260613T085325975Z"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.12"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.cjs",
|
|
21
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=75 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/errors.cjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class MatterhornRuntimeError extends Error {
|
|
2
|
+
constructor(code, message) {
|
|
3
|
+
super(message || code);
|
|
4
|
+
this.name = "MatterhornRuntimeError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function runtimeError(code, message) {
|
|
10
|
+
return new MatterhornRuntimeError(code, message);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function allow(details = {}) {
|
|
14
|
+
return { ok: true, ...details };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deny(reason = "Forbidden") {
|
|
18
|
+
return { ok: false, reason };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeRuntimeFailure(error, operationId) {
|
|
22
|
+
if (error instanceof MatterhornRuntimeError) {
|
|
23
|
+
return { ok: false, code: error.code, reason: error.message, ...(operationId ? { operationId } : {}) };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
code: "PLUGIN_FAILURE",
|
|
28
|
+
reason: error?.message || String(error || "Plugin failed"),
|
|
29
|
+
...(operationId ? { operationId } : {})
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
MatterhornRuntimeError,
|
|
35
|
+
allow,
|
|
36
|
+
deny,
|
|
37
|
+
normalizeRuntimeFailure,
|
|
38
|
+
runtimeError
|
|
39
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const {
|
|
2
|
+
assertProductionPluginGates,
|
|
3
|
+
assertTrustedManifest,
|
|
4
|
+
loadPackReference,
|
|
5
|
+
resolvePluginGraph,
|
|
6
|
+
validateAppPackManifest,
|
|
7
|
+
validateHostPackManifest
|
|
8
|
+
} = require("@mh-gg/base");
|
|
9
|
+
const { runtimeError } = require("../errors.cjs");
|
|
10
|
+
const { installHostPack } = require("../plugins/install.cjs");
|
|
11
|
+
|
|
12
|
+
async function installAppPack(appPackRef, options = {}) {
|
|
13
|
+
const appPack = validateAppPackManifest(await loadPackReference(appPackRef, options));
|
|
14
|
+
if (options.trustStore) assertTrustedManifest(appPack, options.trustStore, { requireSignature: options.requireSignature });
|
|
15
|
+
const hostPack = validateHostPackManifest(await loadPackReference(appPack.hostPack, options));
|
|
16
|
+
const installed = installHostPack({ appPack, hostPack, pluginRegistry: options.pluginRegistry });
|
|
17
|
+
const graph = resolvePluginGraph(installed.plugins);
|
|
18
|
+
if (hostPack.compatibility.pluginGraphHash !== graph.pluginGraphHash && options.skipPluginGraphHash !== true) {
|
|
19
|
+
throw runtimeError("PLUGIN_GRAPH_HASH_MISMATCH", `Host pack plugin graph hash mismatch: expected ${hostPack.compatibility.pluginGraphHash}, got ${graph.pluginGraphHash}`);
|
|
20
|
+
}
|
|
21
|
+
if (options.production) {
|
|
22
|
+
assertProductionPluginGates({
|
|
23
|
+
hostPack,
|
|
24
|
+
publisher: appPack.publisher,
|
|
25
|
+
trustStore: options.trustStore,
|
|
26
|
+
requireSignature: options.requireSignature,
|
|
27
|
+
availableCapabilities: options.availableCapabilities || hostPack.capabilities.required,
|
|
28
|
+
allowUnsafeSandbox: options.allowUnsafeSandbox,
|
|
29
|
+
allowMissingCapabilities: options.allowMissingCapabilities
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return { ...installed, plugins: graph.plugins, pluginGraph: graph };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { installAppPack };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { createMemoryOperationLog } = require("../memory.cjs");
|
|
2
|
+
const { HostPluginRuntime } = require("../runtime/HostPluginRuntime.cjs");
|
|
3
|
+
|
|
4
|
+
async function runMigrations(store, installed, options = {}) {
|
|
5
|
+
const state = await store.load();
|
|
6
|
+
if (!state) return { ok: true, migrated: [] };
|
|
7
|
+
const runtime = new HostPluginRuntime({
|
|
8
|
+
room: options.room || { id: state.roomId || "room", appPack: installed.roomAppPack },
|
|
9
|
+
store,
|
|
10
|
+
plugins: installed.plugins,
|
|
11
|
+
operationLog: createMemoryOperationLog(),
|
|
12
|
+
authenticateActor: async (_auth, actor) => actor,
|
|
13
|
+
capabilities: options.capabilities || installed.hostPack?.capabilities?.required || []
|
|
14
|
+
});
|
|
15
|
+
const migratedState = await runtime.migrateExistingState(state);
|
|
16
|
+
const changed = JSON.stringify(migratedState) !== JSON.stringify(state);
|
|
17
|
+
if (changed) await store.save(migratedState);
|
|
18
|
+
return { ok: true, migrated: changed ? Object.keys(migratedState.pluginVersions || {}) : [], state: migratedState };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { runMigrations };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { installHostPack } = require("../plugins/install.cjs");
|
|
2
|
+
const { HostPluginRuntime } = require("../runtime/HostPluginRuntime.cjs");
|
|
3
|
+
|
|
4
|
+
function pluginRegistryFromPlugins(plugins = []) {
|
|
5
|
+
return Object.fromEntries(plugins.flatMap((plugin) => [[plugin.id, plugin], [plugin.source, plugin]].filter((entry) => entry[0])));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function createHostPluginRuntimeFromPack(options = {}) {
|
|
9
|
+
const installed = installHostPack({
|
|
10
|
+
appPack: options.appPack,
|
|
11
|
+
hostPack: options.hostPack,
|
|
12
|
+
pluginRegistry: pluginRegistryFromPlugins(options.plugins)
|
|
13
|
+
});
|
|
14
|
+
return new HostPluginRuntime({
|
|
15
|
+
...options,
|
|
16
|
+
room: {
|
|
17
|
+
...(options.room || {}),
|
|
18
|
+
id: options.room?.id,
|
|
19
|
+
appPack: installed.roomAppPack
|
|
20
|
+
},
|
|
21
|
+
plugins: installed.plugins
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { createHostPluginRuntimeFromPack, pluginRegistryFromPlugins };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { manifestHash } = require("@mh-gg/base");
|
|
2
|
+
const { runtimeError } = require("../errors.cjs");
|
|
3
|
+
const { createMemoryRoomStore } = require("../memory.cjs");
|
|
4
|
+
const { HostPluginRuntime } = require("../runtime/HostPluginRuntime.cjs");
|
|
5
|
+
const { createRoleKeyAuthenticator, grantArray, operationKeyRoleToActorRole } = require("../security/roleKeys/index.cjs");
|
|
6
|
+
const { installAppPack } = require("./installAppPack.cjs");
|
|
7
|
+
const { runMigrations } = require("./migrations.cjs");
|
|
8
|
+
|
|
9
|
+
function initialMembersFromGrants(grants = []) {
|
|
10
|
+
const members = {};
|
|
11
|
+
for (const grant of grantArray(grants)) {
|
|
12
|
+
if (!grant?.memberId) continue;
|
|
13
|
+
members[grant.memberId] = {
|
|
14
|
+
...(members[grant.memberId] || {}),
|
|
15
|
+
id: grant.memberId,
|
|
16
|
+
role: operationKeyRoleToActorRole(grant.role),
|
|
17
|
+
credentialId: grant.credentialId,
|
|
18
|
+
deviceId: grant.deviceId
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return members;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function startRoomHost(options = {}) {
|
|
25
|
+
const installed = options.installed || await installAppPack(options.appPackRef, options);
|
|
26
|
+
const roomId = options.room?.id || options.roomName;
|
|
27
|
+
if (!roomId) throw runtimeError("INVALID_ROOM", "roomName or room.id is required");
|
|
28
|
+
const room = {
|
|
29
|
+
...(options.room || {}),
|
|
30
|
+
id: roomId,
|
|
31
|
+
appPack: installed.roomAppPack,
|
|
32
|
+
hostPack: {
|
|
33
|
+
id: installed.hostPack.id,
|
|
34
|
+
version: installed.hostPack.version,
|
|
35
|
+
hash: manifestHash(installed.hostPack),
|
|
36
|
+
pluginGraphHash: installed.hostPack.compatibility.pluginGraphHash
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const store = options.store || createMemoryRoomStore();
|
|
40
|
+
const operationRoleKeyGrants = options.operationRoleKeyGrants || options.operationKeyGrants || installed.operationRoleKeyGrants;
|
|
41
|
+
const operationGrantAuthorities = options.operationGrantAuthorities || options.grantAuthorities || installed.operationGrantAuthorities;
|
|
42
|
+
const authenticateActor = options.authenticateActor || (operationRoleKeyGrants ? createRoleKeyAuthenticator({
|
|
43
|
+
roomId: room.id,
|
|
44
|
+
appPackId: room.appPack.id,
|
|
45
|
+
appPackHash: room.appPack.hash,
|
|
46
|
+
grants: operationRoleKeyGrants,
|
|
47
|
+
grantAuthorities: operationGrantAuthorities,
|
|
48
|
+
requireSignedGrants: options.requireSignedOperationRoleKeyGrants || options.requireSignedGrants,
|
|
49
|
+
allowUnsignedOperationRoleKeyGrants: options.allowUnsignedOperationRoleKeyGrants,
|
|
50
|
+
devUnsafeUnsignedOperationRoleKeyGrants: options.devUnsafeUnsignedOperationRoleKeyGrants,
|
|
51
|
+
productionRuntime: options.productionRuntime,
|
|
52
|
+
relayExecuted: options.relayExecuted,
|
|
53
|
+
now: options.now
|
|
54
|
+
}) : undefined);
|
|
55
|
+
await runMigrations(store, installed, { ...options, room });
|
|
56
|
+
const runtime = new HostPluginRuntime({
|
|
57
|
+
...options,
|
|
58
|
+
authenticateActor,
|
|
59
|
+
room,
|
|
60
|
+
store,
|
|
61
|
+
plugins: installed.plugins,
|
|
62
|
+
capabilities: options.capabilities || installed.hostPack.capabilities.required,
|
|
63
|
+
playerPacks: options.playerPacks || [],
|
|
64
|
+
initialMembers: options.initialMembers || initialMembersFromGrants(operationRoleKeyGrants),
|
|
65
|
+
strictStanding: options.strictStanding
|
|
66
|
+
});
|
|
67
|
+
await runtime.start();
|
|
68
|
+
if (options.relay?.registerRoom) {
|
|
69
|
+
try {
|
|
70
|
+
await options.relay.registerRoom({
|
|
71
|
+
room,
|
|
72
|
+
runtime,
|
|
73
|
+
installed,
|
|
74
|
+
appPack: installed.roomAppPack,
|
|
75
|
+
hostPack: installed.hostPack,
|
|
76
|
+
plugins: installed.plugins,
|
|
77
|
+
operationRoleKeyGrants,
|
|
78
|
+
operationGrantAuthorities,
|
|
79
|
+
allowUnsignedOperationRoleKeyGrants: options.allowUnsignedOperationRoleKeyGrants,
|
|
80
|
+
state: await runtime.getState(),
|
|
81
|
+
operations: typeof runtime.operationLog?.list === "function" ? await runtime.operationLog.list() : []
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw runtimeError("RELAY_REGISTRATION_FAILED", error?.message || "Relay registration failed");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
room,
|
|
89
|
+
runtime,
|
|
90
|
+
installed,
|
|
91
|
+
inviteUrl: options.createInviteUrl ? options.createInviteUrl({ room, installed }) : `matterhorn://room/${encodeURIComponent(room.id)}?app=${encodeURIComponent(room.appPack.id)}&appHash=${encodeURIComponent(room.appPack.hash)}`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { startRoomHost };
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
...require("./constants.cjs"),
|
|
3
|
+
...require("./errors.cjs"),
|
|
4
|
+
...require("./memory.cjs"),
|
|
5
|
+
...require("./state.cjs"),
|
|
6
|
+
...require("./plugins/definition.cjs"),
|
|
7
|
+
...require("./plugins/operationDescriptors.cjs"),
|
|
8
|
+
...require("./plugins/install.cjs"),
|
|
9
|
+
...require("./runtime/HostPluginRuntime.cjs"),
|
|
10
|
+
...require("./host/installAppPack.cjs"),
|
|
11
|
+
...require("./host/migrations.cjs"),
|
|
12
|
+
...require("./host/runtimeFromPack.cjs"),
|
|
13
|
+
...require("./host/startRoomHost.cjs"),
|
|
14
|
+
...require("./security/roleKeys/index.cjs"),
|
|
15
|
+
...require("./security/roles.cjs"),
|
|
16
|
+
...require("./security/scopedRoles.cjs"),
|
|
17
|
+
...require("@mh-gg/authority"),
|
|
18
|
+
...require("./security/standingAuthority.cjs"),
|
|
19
|
+
...require("./security/roomDeviceKeys.cjs"),
|
|
20
|
+
...require("./security/authorization/coreGate.cjs"),
|
|
21
|
+
...require("./runtime/directMessages.cjs"),
|
|
22
|
+
...require("./runtime/readTags.cjs")
|
|
23
|
+
};
|
package/src/memory.cjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const { clone } = require("./shared.cjs");
|
|
2
|
+
|
|
3
|
+
function createMemoryRoomStore(initialState = null) {
|
|
4
|
+
let state = clone(initialState);
|
|
5
|
+
return {
|
|
6
|
+
async load() {
|
|
7
|
+
return clone(state);
|
|
8
|
+
},
|
|
9
|
+
async save(nextState) {
|
|
10
|
+
state = clone(nextState);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createMemoryOperationLog(initialEntries = []) {
|
|
16
|
+
const entries = initialEntries.map(clone);
|
|
17
|
+
const operationIds = new Set();
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (!entry?.id) continue;
|
|
20
|
+
if (operationIds.has(entry.id)) throw new Error(`Duplicate operation id ${entry.id}`);
|
|
21
|
+
operationIds.add(entry.id);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
entries,
|
|
25
|
+
async append(operation) {
|
|
26
|
+
if (operation?.id && operationIds.has(operation.id)) throw new Error(`Duplicate operation id ${operation.id}`);
|
|
27
|
+
if (operation?.id) operationIds.add(operation.id);
|
|
28
|
+
entries.push(clone(operation));
|
|
29
|
+
},
|
|
30
|
+
async removeById(operationId) {
|
|
31
|
+
const index = entries.findIndex((entry) => entry?.id === operationId);
|
|
32
|
+
if (index < 0) return false;
|
|
33
|
+
const [removed] = entries.splice(index, 1);
|
|
34
|
+
if (removed?.id) operationIds.delete(removed.id);
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
async list() {
|
|
38
|
+
return entries.map(clone);
|
|
39
|
+
},
|
|
40
|
+
async findById(operationId) {
|
|
41
|
+
const found = entries.find((entry) => entry.id === operationId);
|
|
42
|
+
return found ? clone(found) : null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createMemoryPluginStorage() {
|
|
48
|
+
const values = new Map();
|
|
49
|
+
return {
|
|
50
|
+
async get(key) {
|
|
51
|
+
return values.has(key) ? clone(values.get(key)) : null;
|
|
52
|
+
},
|
|
53
|
+
async put(key, value) {
|
|
54
|
+
values.set(key, clone(value));
|
|
55
|
+
},
|
|
56
|
+
async delete(key) {
|
|
57
|
+
values.delete(key);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createMemoryPluginEvents() {
|
|
63
|
+
const events = [];
|
|
64
|
+
return {
|
|
65
|
+
async append(event) {
|
|
66
|
+
events.push(clone(event));
|
|
67
|
+
},
|
|
68
|
+
async query(filter = {}) {
|
|
69
|
+
return events
|
|
70
|
+
.filter((event) => Object.entries(filter).every(([key, value]) => value === undefined || event[key] === value))
|
|
71
|
+
.map(clone);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
createMemoryOperationLog,
|
|
78
|
+
createMemoryPluginEvents,
|
|
79
|
+
createMemoryPluginStorage,
|
|
80
|
+
createMemoryRoomStore
|
|
81
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { runtimeError } = require("../errors.cjs");
|
|
2
|
+
const { validatePluginOperationDescriptors } = require("./operationDescriptors.cjs");
|
|
3
|
+
|
|
4
|
+
function validatePlugin(plugin) {
|
|
5
|
+
if (!plugin || typeof plugin !== "object") throw runtimeError("INVALID_PLUGIN", "Plugin must be an object");
|
|
6
|
+
if (!plugin.id || !plugin.version) throw runtimeError("INVALID_PLUGIN", "Plugin id and version are required");
|
|
7
|
+
if (typeof plugin.createInitialState !== "function") throw runtimeError("INVALID_PLUGIN", `${plugin.id} createInitialState is required`);
|
|
8
|
+
if (typeof plugin.authorize !== "function") throw runtimeError("INVALID_PLUGIN", `${plugin.id} authorize is required`);
|
|
9
|
+
if (typeof plugin.reduce !== "function") throw runtimeError("INVALID_PLUGIN", `${plugin.id} reduce is required`);
|
|
10
|
+
if (!plugin.schemas?.state || !plugin.schemas?.operations) throw runtimeError("INVALID_PLUGIN", `${plugin.id} schemas are required`);
|
|
11
|
+
validatePluginOperationDescriptors(plugin);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function defineHostPlugin(plugin) {
|
|
15
|
+
validatePlugin(plugin);
|
|
16
|
+
return plugin;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { defineHostPlugin, validatePlugin };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const {
|
|
2
|
+
hashCanonical,
|
|
3
|
+
manifestHash,
|
|
4
|
+
validateAppPackManifest,
|
|
5
|
+
validateHostPackManifest
|
|
6
|
+
} = require("@mh-gg/base");
|
|
7
|
+
const { runtimeError } = require("../errors.cjs");
|
|
8
|
+
const { validatePlugin } = require("./definition.cjs");
|
|
9
|
+
|
|
10
|
+
function pluginArtifactIntegrity(plugin) {
|
|
11
|
+
return hashCanonical({ id: plugin.id, version: plugin.version, schema: plugin.schemaDescriptor || null });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clone(value) {
|
|
15
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function compositionPluginConfigs(hostPack) {
|
|
19
|
+
const configs = new Map();
|
|
20
|
+
for (const ref of hostPack.composition?.plugins || []) {
|
|
21
|
+
if (ref?.id && ref.config !== undefined) configs.set(ref.id, clone(ref.config));
|
|
22
|
+
}
|
|
23
|
+
return configs;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveHostPlugins(hostPack, pluginRegistry) {
|
|
27
|
+
const pack = validateHostPackManifest(hostPack);
|
|
28
|
+
const registry = pluginRegistry || {};
|
|
29
|
+
const resolved = [];
|
|
30
|
+
const byId = new Map(pack.plugins.map((ref) => [ref.id, ref]));
|
|
31
|
+
const configs = compositionPluginConfigs(pack);
|
|
32
|
+
|
|
33
|
+
for (const ref of pack.plugins) {
|
|
34
|
+
for (const dependency of ref.dependsOn || []) {
|
|
35
|
+
const target = byId.get(dependency.id);
|
|
36
|
+
if (!target && !dependency.optional) throw runtimeError("PLUGIN_DEPENDENCY_MISSING", `Plugin ${ref.id} depends on ${dependency.id}`);
|
|
37
|
+
if (target && dependency.version && target.version !== dependency.version) throw runtimeError("PLUGIN_DEPENDENCY_VERSION", `Plugin ${ref.id} depends on ${dependency.id}@${dependency.version}`);
|
|
38
|
+
}
|
|
39
|
+
for (const conflictId of ref.conflictsWith || []) {
|
|
40
|
+
if (byId.has(conflictId)) throw runtimeError("PLUGIN_CONFLICT", `Plugin ${ref.id} conflicts with ${conflictId}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const visiting = new Set();
|
|
45
|
+
const visited = new Set();
|
|
46
|
+
function visit(ref) {
|
|
47
|
+
if (visited.has(ref.id)) return;
|
|
48
|
+
if (visiting.has(ref.id)) throw runtimeError("PLUGIN_DEPENDENCY_CYCLE", `Plugin dependency cycle at ${ref.id}`);
|
|
49
|
+
visiting.add(ref.id);
|
|
50
|
+
for (const dependency of ref.dependsOn || []) {
|
|
51
|
+
const target = byId.get(dependency.id);
|
|
52
|
+
if (target) visit(target);
|
|
53
|
+
}
|
|
54
|
+
visiting.delete(ref.id);
|
|
55
|
+
visited.add(ref.id);
|
|
56
|
+
const plugin = registry[ref.id] || registry[ref.source];
|
|
57
|
+
if (!plugin) throw runtimeError("PLUGIN_NOT_INSTALLED", `Host plugin ${ref.id} is not available`);
|
|
58
|
+
validatePlugin(plugin);
|
|
59
|
+
if (plugin.id !== ref.id) throw runtimeError("PLUGIN_ID_MISMATCH", `Plugin ${ref.id} resolved to ${plugin.id}`);
|
|
60
|
+
if (plugin.version !== ref.version) throw runtimeError("PLUGIN_VERSION_MISMATCH", `Plugin ${ref.id} version mismatch`);
|
|
61
|
+
if (ref.integrity && plugin.integrity && ref.integrity !== plugin.integrity) throw runtimeError("PLUGIN_INTEGRITY_MISMATCH", `Plugin ${ref.id} integrity mismatch`);
|
|
62
|
+
resolved.push(configs.has(ref.id) ? { ...plugin, config: configs.get(ref.id) } : plugin);
|
|
63
|
+
}
|
|
64
|
+
for (const ref of pack.plugins) visit(ref);
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function installHostPack(input) {
|
|
69
|
+
const appPack = validateAppPackManifest(input.appPack);
|
|
70
|
+
const hostPack = validateHostPackManifest(input.hostPack);
|
|
71
|
+
if (hostPack.appPackId !== appPack.id) throw runtimeError("APP_PACK_MISMATCH", "Host pack appPackId does not match app pack");
|
|
72
|
+
if (appPack.hostPack.integrity !== manifestHash(hostPack)) throw runtimeError("HOST_PACK_INTEGRITY_MISMATCH", "Host pack hash does not match app pack pin");
|
|
73
|
+
if (hostPack.compatibility.appProtocolHash !== appPack.compatibility.appProtocolHash) {
|
|
74
|
+
throw runtimeError("APP_PROTOCOL_MISMATCH", "Host pack protocol hash does not match app pack");
|
|
75
|
+
}
|
|
76
|
+
const plugins = resolveHostPlugins(hostPack, input.pluginRegistry);
|
|
77
|
+
return {
|
|
78
|
+
appPack,
|
|
79
|
+
hostPack,
|
|
80
|
+
plugins,
|
|
81
|
+
roomAppPack: {
|
|
82
|
+
id: appPack.id,
|
|
83
|
+
version: appPack.version,
|
|
84
|
+
hash: manifestHash(appPack),
|
|
85
|
+
protocolHash: appPack.compatibility.appProtocolHash
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { installHostPack, pluginArtifactIntegrity, resolveHostPlugins };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { runtimeError, MatterhornRuntimeError } = require("../errors.cjs");
|
|
2
|
+
const { clone } = require("../shared.cjs");
|
|
3
|
+
const { parseWithSchema } = require("../state.cjs");
|
|
4
|
+
|
|
5
|
+
function migrationKey(from, to) {
|
|
6
|
+
return `${from}->${to}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function migratePluginState(plugin, roomState, ctx) {
|
|
10
|
+
const currentVersion = roomState.pluginVersions?.[plugin.id];
|
|
11
|
+
if (!currentVersion || currentVersion === plugin.version) {
|
|
12
|
+
return parseWithSchema(plugin.schemas.state, roomState.plugins[plugin.id], `${plugin.id} state`);
|
|
13
|
+
}
|
|
14
|
+
const migrations = plugin.migrations || {};
|
|
15
|
+
let migration = migrations[migrationKey(currentVersion, plugin.version)] || migrations[plugin.version];
|
|
16
|
+
if (!migration && Array.isArray(migrations)) migration = migrations.find((candidate) => candidate.from === currentVersion && candidate.to === plugin.version)?.migrate;
|
|
17
|
+
if (typeof migration !== "function") throw runtimeError("MIGRATION_MISSING", `No migration for ${plugin.id} ${currentVersion} -> ${plugin.version}`);
|
|
18
|
+
try {
|
|
19
|
+
const migrated = await migration(ctx, clone(roomState.plugins[plugin.id]), { from: currentVersion, to: plugin.version });
|
|
20
|
+
return parseWithSchema(plugin.schemas.state, migrated, `${plugin.id} migrated state`);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error instanceof MatterhornRuntimeError) throw error;
|
|
23
|
+
throw runtimeError("MIGRATION_FAILED", `${plugin.id} migration failed: ${error?.message || error}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { migratePluginState, migrationKey };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const { runtimeError } = require('../errors.cjs');
|
|
2
|
+
const { normalizeRoomRole } = require('../security/roles.cjs');
|
|
3
|
+
const { clone } = require('../shared.cjs');
|
|
4
|
+
|
|
5
|
+
const OPERATION_SCHEMA_DESCRIPTOR_KIND = 'matterhorn.operation-schema-descriptor';
|
|
6
|
+
const FORBIDDEN_DESCRIPTOR_SHAPES = Object.freeze([
|
|
7
|
+
'operationPolicyDescriptor',
|
|
8
|
+
'operationDescriptors',
|
|
9
|
+
'operationSchemaDescriptors',
|
|
10
|
+
'operationSchemas'
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeOperationsObject(operations, label = 'operationSchemaDescriptor.operations') {
|
|
18
|
+
if (!isPlainObject(operations)) {
|
|
19
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${label} must be a canonical operation descriptor map`);
|
|
20
|
+
}
|
|
21
|
+
return Object.fromEntries(Object.entries(operations).map(([type, descriptor]) => [type, clone(descriptor)]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createOperationSchemaDescriptor(input, version, operations) {
|
|
25
|
+
if (isPlainObject(input) && arguments.length === 1) {
|
|
26
|
+
const plugin = input.plugin || input.pluginId;
|
|
27
|
+
if (typeof plugin !== 'string' || plugin.length === 0) throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', 'operation descriptor plugin is required');
|
|
28
|
+
if (typeof input.version !== 'string' || input.version.length === 0) throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', 'operation descriptor version is required');
|
|
29
|
+
return {
|
|
30
|
+
kind: input.kind || OPERATION_SCHEMA_DESCRIPTOR_KIND,
|
|
31
|
+
plugin,
|
|
32
|
+
version: input.version,
|
|
33
|
+
...(input.source ? { source: input.source } : {}),
|
|
34
|
+
operations: normalizeOperationsObject(input.operations)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (typeof input !== 'string' || input.length === 0) throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', 'operation descriptor plugin is required');
|
|
38
|
+
if (typeof version !== 'string' || version.length === 0) throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', 'operation descriptor version is required');
|
|
39
|
+
return {
|
|
40
|
+
kind: OPERATION_SCHEMA_DESCRIPTOR_KIND,
|
|
41
|
+
plugin: input,
|
|
42
|
+
version,
|
|
43
|
+
operations: normalizeOperationsObject(operations)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function canonicalOperationDescriptor(plugin) {
|
|
48
|
+
return plugin?.operationSchemaDescriptor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function operationDescriptor(plugin, type) {
|
|
52
|
+
const descriptor = canonicalOperationDescriptor(plugin);
|
|
53
|
+
if (!isPlainObject(descriptor?.operations)) return undefined;
|
|
54
|
+
return descriptor.operations[type];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function declaredRolesForOperation(plugin, type) {
|
|
58
|
+
const descriptor = operationDescriptor(plugin, type);
|
|
59
|
+
const roles = descriptor?.authorize?.roles;
|
|
60
|
+
return Array.isArray(roles) ? roles.slice() : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertNoLegacyDescriptorShapes(plugin) {
|
|
64
|
+
for (const property of FORBIDDEN_DESCRIPTOR_SHAPES) {
|
|
65
|
+
if (Object.prototype.hasOwnProperty.call(plugin, property)) {
|
|
66
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id} must use canonical operationSchemaDescriptor.operations; legacy ${property} is not supported`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (plugin.schemas?.operationsDescriptor) {
|
|
70
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id} must use canonical operationSchemaDescriptor.operations; schemas.operationsDescriptor is not supported`);
|
|
71
|
+
}
|
|
72
|
+
for (const [type, schema] of Object.entries(plugin.schemas?.operations || {})) {
|
|
73
|
+
if (schema?.descriptor) {
|
|
74
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id}.${type} must declare policy in operationSchemaDescriptor.operations, not schemas.operations.${type}.descriptor`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertRoleArray(plugin, type, roles) {
|
|
80
|
+
if (!Array.isArray(roles) || roles.length === 0) {
|
|
81
|
+
throw runtimeError('OPERATION_ROLE_POLICY_MISSING', `${plugin.id}.${type} must declare authorize.roles in operationSchemaDescriptor.operations`);
|
|
82
|
+
}
|
|
83
|
+
for (const role of roles) {
|
|
84
|
+
if (typeof role !== 'string' || role.length === 0 || normalizeRoomRole(role, undefined) === undefined) {
|
|
85
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id}.${type} has invalid authorize.roles entry ${JSON.stringify(role)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validatePluginOperationDescriptors(plugin) {
|
|
91
|
+
assertNoLegacyDescriptorShapes(plugin);
|
|
92
|
+
const descriptor = canonicalOperationDescriptor(plugin);
|
|
93
|
+
if (!isPlainObject(descriptor) || !isPlainObject(descriptor.operations)) {
|
|
94
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id} must declare canonical operationSchemaDescriptor.operations`);
|
|
95
|
+
}
|
|
96
|
+
if (descriptor.kind !== undefined && descriptor.kind !== OPERATION_SCHEMA_DESCRIPTOR_KIND) {
|
|
97
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id} operationSchemaDescriptor.kind is invalid`);
|
|
98
|
+
}
|
|
99
|
+
if (descriptor.plugin !== plugin.id) {
|
|
100
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id} operationSchemaDescriptor.plugin must match plugin id`);
|
|
101
|
+
}
|
|
102
|
+
if (descriptor.version !== plugin.version) {
|
|
103
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id} operationSchemaDescriptor.version must match plugin version`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const schemaOperationTypes = Object.keys(plugin.schemas?.operations || {}).sort();
|
|
107
|
+
const descriptorOperationTypes = Object.keys(descriptor.operations).sort();
|
|
108
|
+
for (const type of schemaOperationTypes) {
|
|
109
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor.operations, type)) {
|
|
110
|
+
throw runtimeError('OPERATION_ROLE_POLICY_MISSING', `${plugin.id}.${type} must declare authorize.roles in operationSchemaDescriptor.operations`);
|
|
111
|
+
}
|
|
112
|
+
assertRoleArray(plugin, type, descriptor.operations[type]?.authorize?.roles);
|
|
113
|
+
}
|
|
114
|
+
for (const type of descriptorOperationTypes) {
|
|
115
|
+
if (!Object.prototype.hasOwnProperty.call(plugin.schemas.operations, type)) {
|
|
116
|
+
throw runtimeError('INVALID_PLUGIN_OPERATION_DESCRIPTOR', `${plugin.id}.${type} has an operation descriptor but no schemas.operations entry`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function requireDeclaredRolePolicy(plugin, type) {
|
|
123
|
+
const roles = declaredRolesForOperation(plugin, type);
|
|
124
|
+
if (!Array.isArray(roles) || roles.length === 0) {
|
|
125
|
+
throw runtimeError('RUNTIME_CONFIGURATION_INVALID', `${plugin.id}.${type} has no validated canonical authorize.roles policy`);
|
|
126
|
+
}
|
|
127
|
+
return roles;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
OPERATION_SCHEMA_DESCRIPTOR_KIND,
|
|
132
|
+
canonicalOperationDescriptor,
|
|
133
|
+
createOperationSchemaDescriptor,
|
|
134
|
+
declaredRolesForOperation,
|
|
135
|
+
operationDescriptor,
|
|
136
|
+
requireDeclaredRolePolicy,
|
|
137
|
+
validatePluginOperationDescriptors
|
|
138
|
+
};
|