@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const { clone } = require("../shared.cjs");
|
|
2
|
+
|
|
3
|
+
function cleanText(value, max = 160) {
|
|
4
|
+
if (typeof value !== "string") return undefined;
|
|
5
|
+
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
6
|
+
return text ? text.slice(0, max) : undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function actorDisplayName(actor) {
|
|
10
|
+
return cleanText(actor?.displayName, 120)
|
|
11
|
+
|| cleanText(actor?.name, 120)
|
|
12
|
+
|| cleanText(actor?.nickname, 120);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function actorMemberRecord(existing, actor, now) {
|
|
16
|
+
const memberId = cleanText(actor?.memberId, 160);
|
|
17
|
+
if (!memberId) return undefined;
|
|
18
|
+
const displayName = actorDisplayName(actor);
|
|
19
|
+
const next = {
|
|
20
|
+
...(existing || {}),
|
|
21
|
+
id: existing?.id || memberId,
|
|
22
|
+
memberId,
|
|
23
|
+
profileOnly: existing ? existing.profileOnly : true,
|
|
24
|
+
role: existing?.role || cleanText(actor?.role, 80) || "member",
|
|
25
|
+
updatedAt: now,
|
|
26
|
+
lastSeenAt: now
|
|
27
|
+
};
|
|
28
|
+
const deviceId = cleanText(actor?.deviceId, 160);
|
|
29
|
+
const avatar = cleanText(actor?.avatar, 64);
|
|
30
|
+
const avatarUrl = cleanText(actor?.avatarUrl, 500);
|
|
31
|
+
const profileImageUrl = cleanText(actor?.profileImageUrl, 500);
|
|
32
|
+
const keyId = cleanText(actor?.keyId, 160);
|
|
33
|
+
const publicKey = cleanText(actor?.publicKey, 500);
|
|
34
|
+
if (!existing && !displayName && !avatar && !avatarUrl && !profileImageUrl) return undefined;
|
|
35
|
+
if (deviceId) next.deviceId = deviceId;
|
|
36
|
+
if (displayName) {
|
|
37
|
+
next.name = displayName;
|
|
38
|
+
next.displayName = displayName;
|
|
39
|
+
}
|
|
40
|
+
if (avatar) next.avatar = avatar;
|
|
41
|
+
if (avatarUrl) next.avatarUrl = avatarUrl;
|
|
42
|
+
if (profileImageUrl) next.profileImageUrl = profileImageUrl;
|
|
43
|
+
if (keyId) next.keyId = keyId;
|
|
44
|
+
if (publicKey) next.publicKey = publicKey;
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stateWithActorMember(state, actor, now) {
|
|
49
|
+
const member = actorMemberRecord(state?.members?.[actor?.memberId], actor, now);
|
|
50
|
+
if (!member) return state;
|
|
51
|
+
return {
|
|
52
|
+
...state,
|
|
53
|
+
members: {
|
|
54
|
+
...(state.members || {}),
|
|
55
|
+
[member.memberId]: member
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function stateWithOperationLogMembers(runtime, state) {
|
|
61
|
+
const entries = typeof runtime.operationLog?.list === "function"
|
|
62
|
+
? await runtime.operationLog.list()
|
|
63
|
+
: runtime.operationLog?.entries || [];
|
|
64
|
+
let next = state;
|
|
65
|
+
for (const operation of entries || []) {
|
|
66
|
+
next = stateWithActorMember(next, operation?.actor, operation?.createdAt || operation?.committedAt || runtime.now());
|
|
67
|
+
}
|
|
68
|
+
return next === state ? state : clone(next);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
stateWithActorMember,
|
|
73
|
+
stateWithOperationLogMembers
|
|
74
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const lifecycle = require("./lifecycle.cjs");
|
|
2
|
+
const { createContext } = require("./context.cjs");
|
|
3
|
+
const operations = require("./operations.cjs");
|
|
4
|
+
const queries = require("./queries.cjs");
|
|
5
|
+
|
|
6
|
+
function attachRuntimeMethods(RuntimeClass) {
|
|
7
|
+
Object.assign(RuntimeClass.prototype, lifecycle, { createContext }, operations, queries);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = { attachRuntimeMethods };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const { validateRoomOperation } = require("@mh-gg/protocol");
|
|
2
|
+
const { normalizeRuntimeFailure, runtimeError } = require("../errors.cjs");
|
|
3
|
+
const { parseWithSchema } = require("../state.cjs");
|
|
4
|
+
const { clone, pruneMap, rememberBounded } = require("../shared.cjs");
|
|
5
|
+
const { stateWithActorMember } = require("./memberProfiles.cjs");
|
|
6
|
+
const { assignOperationLedgerId } = require("./snowflake.cjs");
|
|
7
|
+
const {
|
|
8
|
+
CORE_AUTHORITY_GRANT_TYPE,
|
|
9
|
+
CORE_AUTHORITY_REVOKE_TYPE,
|
|
10
|
+
CORE_PLUGIN_ID,
|
|
11
|
+
CORE_REVOKE_CREDENTIAL_TYPE,
|
|
12
|
+
authenticateAndGateOperation,
|
|
13
|
+
commitAccessRoleAssignment,
|
|
14
|
+
commitAccessRoleDefine,
|
|
15
|
+
commitAuthorityCoreOperation,
|
|
16
|
+
commitCoreOperation,
|
|
17
|
+
commitScopeRoleSet,
|
|
18
|
+
commitCredentialRevocation,
|
|
19
|
+
commitDirectCoreOperation,
|
|
20
|
+
commitReadTagCoreOperation,
|
|
21
|
+
isCoreOperation
|
|
22
|
+
} = require("./coreOperations.cjs");
|
|
23
|
+
|
|
24
|
+
function assertOperationRoom(op) {
|
|
25
|
+
if (op.roomId !== this.room.id) throw runtimeError("ROOM_MISMATCH", "Operation room does not match runtime room");
|
|
26
|
+
if (op.appPackId !== this.room.appPack.id) throw runtimeError("APP_PACK_MISMATCH", "Operation app pack id does not match runtime app");
|
|
27
|
+
if (op.appPackHash !== this.room.appPack.hash && this.allowHistoricalAppPackHashes !== true) throw runtimeError("APP_PACK_HASH_MISMATCH", "Operation app pack hash does not match runtime app");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function findLoggedOperation(operationId) {
|
|
31
|
+
if (typeof this.operationLog.findById === "function") return await this.operationLog.findById(operationId);
|
|
32
|
+
const entries = typeof this.operationLog.list === "function" ? await this.operationLog.list() : (this.operationLog.entries || []);
|
|
33
|
+
return entries.find((entry) => entry.id === operationId) || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function handleOperation(operation) {
|
|
37
|
+
try {
|
|
38
|
+
return await this.handleOperationOrThrow(operation);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
this.logger.warn?.("Operation rejected", error);
|
|
41
|
+
return normalizeRuntimeFailure(error, operation?.id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function commitPluginOperation(runtime, operation, state, plugin, signedOperationForLog) {
|
|
46
|
+
const actor = await authenticateAndGateOperation(runtime, { operation, state, plugin });
|
|
47
|
+
const parsedPayload = parseWithSchema(plugin.schemas.operations[operation.type], operation.payload, `${plugin.id}.${operation.type}`);
|
|
48
|
+
const parsedOperation = { ...operation, payload: parsedPayload, actor };
|
|
49
|
+
const pluginState = state.plugins[plugin.id];
|
|
50
|
+
const authz = await plugin.authorize(runtime.createContext({ plugin, roomState: state, pluginState, actor, operation: parsedOperation }), parsedOperation);
|
|
51
|
+
if (!authz?.ok) throw runtimeError("FORBIDDEN", authz?.reason || "Forbidden");
|
|
52
|
+
|
|
53
|
+
let nextPluginState;
|
|
54
|
+
try {
|
|
55
|
+
nextPluginState = await plugin.reduce(runtime.createContext({ plugin, roomState: state, pluginState, actor, operation: parsedOperation }), clone(pluginState), parsedOperation);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw runtimeError("PLUGIN_REDUCE_FAILED", `${plugin.id}: ${error?.message || error}`);
|
|
58
|
+
}
|
|
59
|
+
const parsedNextPluginState = parseWithSchema(plugin.schemas.state, nextPluginState, `${plugin.id} state`);
|
|
60
|
+
const updatedAt = parsedOperation.createdAt || runtime.now();
|
|
61
|
+
const memberRoomState = stateWithActorMember(state, actor, updatedAt);
|
|
62
|
+
const nextRoomState = runtime.validateRoomState({
|
|
63
|
+
...memberRoomState,
|
|
64
|
+
plugins: { ...memberRoomState.plugins, [plugin.id]: parsedNextPluginState },
|
|
65
|
+
pluginVersions: { ...memberRoomState.pluginVersions, [plugin.id]: plugin.version },
|
|
66
|
+
version: state.version + 1,
|
|
67
|
+
updatedAt,
|
|
68
|
+
seenOperations: rememberBounded(state.seenOperations, parsedOperation.id, runtime.maxSeenOperations)
|
|
69
|
+
});
|
|
70
|
+
await runtime.commitStateAndOperation({
|
|
71
|
+
logEntry: { ...signedOperationForLog, committedRoomVersion: nextRoomState.version, committedAt: nextRoomState.updatedAt },
|
|
72
|
+
nextState: nextRoomState,
|
|
73
|
+
expectedPreviousVersion: state.version
|
|
74
|
+
});
|
|
75
|
+
await runAfterCommit(runtime, plugin, nextRoomState, parsedNextPluginState, actor, parsedOperation);
|
|
76
|
+
return ackOperation(runtime, parsedOperation.id, nextRoomState.version, parsedOperation);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function commitStateAndOperation({ logEntry, nextState, expectedPreviousVersion }) {
|
|
80
|
+
logEntry = assignOperationLedgerId(this, logEntry);
|
|
81
|
+
if (!logEntry?.id) throw runtimeError("INVALID_OPERATION", "Operation log entry id is required");
|
|
82
|
+
if (Number.isInteger(expectedPreviousVersion) && nextState?.version !== expectedPreviousVersion + 1) {
|
|
83
|
+
throw runtimeError("VERSION_CONFLICT", "Committed room state version must advance by one");
|
|
84
|
+
}
|
|
85
|
+
const existing = await this.findLoggedOperation(logEntry.id);
|
|
86
|
+
if (existing) throw runtimeError("DUPLICATE_OPERATION", `Operation ${logEntry.id} is already committed`);
|
|
87
|
+
if (typeof this.store.commitStateAndOperation === "function") {
|
|
88
|
+
await this.store.commitStateAndOperation({ operation: logEntry, nextState, expectedPreviousVersion, operationLog: this.operationLog });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (this.productionRuntime || this.relayExecuted) {
|
|
92
|
+
throw runtimeError("ATOMIC_COMMIT_REQUIRED", "Production and relay-executed runtimes require store.commitStateAndOperation");
|
|
93
|
+
}
|
|
94
|
+
await this.operationLog.append(logEntry);
|
|
95
|
+
try {
|
|
96
|
+
await this.store.save(nextState);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (typeof this.operationLog.removeById === "function") await this.operationLog.removeById(logEntry.id);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function runAfterCommit(runtime, plugin, nextRoomState, parsedNextPluginState, actor, parsedOperation) {
|
|
104
|
+
if (typeof plugin.afterCommit !== "function") return;
|
|
105
|
+
try {
|
|
106
|
+
await plugin.afterCommit(runtime.createContext({ plugin, roomState: nextRoomState, pluginState: parsedNextPluginState, actor, operation: parsedOperation }), { op: parsedOperation, state: parsedNextPluginState });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
runtime.logger.error?.(`Plugin ${plugin.id} afterCommit failed`, error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ackOperation(runtime, operationId, roomVersion, operation = {}) {
|
|
113
|
+
const ack = { ok: true, acceptedOperationId: operationId, roomVersion, ...(operation.ledgerId ? { acceptedLedgerId: operation.ledgerId, acceptedSnowflakeId: operation.ledgerId } : {}) };
|
|
114
|
+
runtime.acks.set(operationId, ack);
|
|
115
|
+
pruneMap(runtime.acks, Math.min(runtime.maxAcks, runtime.maxAckCache));
|
|
116
|
+
return clone(ack);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function handleOperationOrThrow(operation) {
|
|
120
|
+
await this.start();
|
|
121
|
+
validateRoomOperation(operation);
|
|
122
|
+
this.assertOperationRoom(operation);
|
|
123
|
+
|
|
124
|
+
const state = this.validateRoomState(await this.store.load());
|
|
125
|
+
if (this.acks.has(operation.id)) return clone(this.acks.get(operation.id));
|
|
126
|
+
const logged = await this.findLoggedOperation(operation.id);
|
|
127
|
+
if (logged || state.seenOperations.includes(operation.id)) {
|
|
128
|
+
return { ok: true, acceptedOperationId: operation.id, roomVersion: logged?.committedRoomVersion || state.version, duplicate: true, ...(logged?.ledgerId ? { acceptedLedgerId: logged.ledgerId, acceptedSnowflakeId: logged.ledgerId } : {}) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const operationForCommit = assignOperationLedgerId(this, {
|
|
132
|
+
...operation,
|
|
133
|
+
ledgerId: undefined,
|
|
134
|
+
snowflakeId: undefined
|
|
135
|
+
});
|
|
136
|
+
const signedOperationForLog = clone(operationForCommit);
|
|
137
|
+
if (isCoreOperation(operationForCommit)) {
|
|
138
|
+
const actor = await authenticateAndGateOperation(this, { operation: operationForCommit, state });
|
|
139
|
+
return this.commitCoreOperation(operationForCommit, actor, state, signedOperationForLog);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const plugin = this.pluginMap.get(operationForCommit.pluginId);
|
|
143
|
+
if (!plugin) throw runtimeError("PLUGIN_NOT_INSTALLED", `Plugin ${operationForCommit.pluginId} is not installed`);
|
|
144
|
+
if (!plugin.schemas.operations[operationForCommit.type]) throw runtimeError("UNKNOWN_OPERATION_TYPE", `Unknown operation type ${operationForCommit.type}`);
|
|
145
|
+
return commitPluginOperation(this, operationForCommit, state, plugin, signedOperationForLog);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
CORE_AUTHORITY_GRANT_TYPE,
|
|
150
|
+
CORE_AUTHORITY_REVOKE_TYPE,
|
|
151
|
+
CORE_PLUGIN_ID,
|
|
152
|
+
CORE_REVOKE_CREDENTIAL_TYPE,
|
|
153
|
+
assertOperationRoom,
|
|
154
|
+
commitAuthorityCoreOperation,
|
|
155
|
+
commitAccessRoleAssignment,
|
|
156
|
+
commitAccessRoleDefine,
|
|
157
|
+
commitCoreOperation,
|
|
158
|
+
commitStateAndOperation,
|
|
159
|
+
commitCredentialRevocation,
|
|
160
|
+
commitDirectCoreOperation,
|
|
161
|
+
commitReadTagCoreOperation,
|
|
162
|
+
commitScopeRoleSet,
|
|
163
|
+
findLoggedOperation,
|
|
164
|
+
handleOperation,
|
|
165
|
+
handleOperationOrThrow
|
|
166
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const { manifestHash } = require("@mh-gg/base");
|
|
2
|
+
const { runtimeError } = require("../errors.cjs");
|
|
3
|
+
const { pluginArtifactIntegrity } = require("../plugins/install.cjs");
|
|
4
|
+
const { parseWithSchema } = require("../state.cjs");
|
|
5
|
+
const { clone } = require("../shared.cjs");
|
|
6
|
+
const { effectiveActorForRoom, publicScopedRolesView } = require("../security/scopedRoles.cjs");
|
|
7
|
+
const { publicDirectView } = require("./directMessages.cjs");
|
|
8
|
+
|
|
9
|
+
async function handleQuery(pluginId, queryName, input, actor) {
|
|
10
|
+
await this.start();
|
|
11
|
+
const plugin = this.pluginMap.get(pluginId);
|
|
12
|
+
if (!plugin) throw runtimeError("PLUGIN_NOT_INSTALLED", `Plugin ${pluginId} is not installed`);
|
|
13
|
+
const query = plugin.queries?.[queryName];
|
|
14
|
+
if (typeof query !== "function") throw runtimeError("PLUGIN_QUERY_NOT_FOUND", `Plugin query ${pluginId}.${queryName} is not available`);
|
|
15
|
+
const state = this.validateRoomState(await this.store.load());
|
|
16
|
+
const queryActor = await effectiveActorForRoom(this, { actor, state });
|
|
17
|
+
const pluginState = clone(state.plugins[pluginId]);
|
|
18
|
+
const result = await query(this.createContext({ plugin, roomState: clone(state), pluginState, actor: queryActor }), pluginState, clone(input), queryActor);
|
|
19
|
+
const querySchema = plugin.schemas.queries?.[queryName];
|
|
20
|
+
return clone(querySchema ? parseWithSchema(querySchema, result, `${plugin.id}.${queryName} query`) : result);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function query(pluginId, queryName, input, actor) {
|
|
24
|
+
return await this.handleQuery(pluginId, queryName, input, actor);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function operationBatchSince(baseVersion = 0, options = {}) {
|
|
28
|
+
await this.start();
|
|
29
|
+
const state = this.validateRoomState(await this.store.load());
|
|
30
|
+
const entries = typeof this.operationLog.list === "function" ? await this.operationLog.list() : (this.operationLog.entries || []).map(clone);
|
|
31
|
+
const operations = entries.filter((entry) => Number(entry.committedRoomVersion || 0) > baseVersion);
|
|
32
|
+
return {
|
|
33
|
+
kind: "matterhorn.host-operation-batch",
|
|
34
|
+
version: "0.1",
|
|
35
|
+
roomId: this.room.id,
|
|
36
|
+
appPackId: this.room.appPack.id,
|
|
37
|
+
appPackHash: this.room.appPack.hash,
|
|
38
|
+
baseVersion,
|
|
39
|
+
headVersion: state.version,
|
|
40
|
+
operations: operations.slice(0, options.limit || operations.length).map(clone)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function createSnapshot() {
|
|
45
|
+
await this.start();
|
|
46
|
+
const state = this.validateRoomState(await this.store.load());
|
|
47
|
+
return {
|
|
48
|
+
kind: "matterhorn.host-snapshot",
|
|
49
|
+
version: state.version,
|
|
50
|
+
roomId: this.room.id,
|
|
51
|
+
appPackId: this.room.appPack.id,
|
|
52
|
+
appPackHash: this.room.appPack.hash,
|
|
53
|
+
appPack: clone(this.room.appPack),
|
|
54
|
+
createdAt: this.now(),
|
|
55
|
+
state: clone(state)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function operations(options = {}) {
|
|
60
|
+
if (typeof this.operationLog.list === "function") return await this.operationLog.list(options);
|
|
61
|
+
return (this.operationLog.entries || []).map(clone);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function publicMembersView(members = {}, actor = {}) {
|
|
65
|
+
const view = {};
|
|
66
|
+
for (const [key, member] of Object.entries(members || {})) {
|
|
67
|
+
const id = member?.memberId || member?.id || key;
|
|
68
|
+
if (!id) continue;
|
|
69
|
+
const item = {
|
|
70
|
+
id,
|
|
71
|
+
memberId: id,
|
|
72
|
+
name: member.name,
|
|
73
|
+
displayName: member.displayName,
|
|
74
|
+
role: member.role,
|
|
75
|
+
status: member.status,
|
|
76
|
+
revokedAt: member.revokedAt,
|
|
77
|
+
bannedAt: member.bannedAt
|
|
78
|
+
};
|
|
79
|
+
if (member.avatar !== undefined) item.avatar = member.avatar;
|
|
80
|
+
if (member.avatarUrl !== undefined) item.avatarUrl = member.avatarUrl;
|
|
81
|
+
if (member.profileImageUrl !== undefined) item.profileImageUrl = member.profileImageUrl;
|
|
82
|
+
if (actor.memberId === id && member.readTags && typeof member.readTags === "object" && !Array.isArray(member.readTags)) item.readTags = clone(member.readTags);
|
|
83
|
+
view[id] = item;
|
|
84
|
+
}
|
|
85
|
+
return view;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function publicView(actor) {
|
|
89
|
+
await this.start();
|
|
90
|
+
const state = this.validateRoomState(await this.store.load());
|
|
91
|
+
const viewActor = await effectiveActorForRoom(this, { actor, state });
|
|
92
|
+
const plugins = {};
|
|
93
|
+
for (const [pluginId, plugin] of this.pluginMap.entries()) {
|
|
94
|
+
const pluginState = clone(state.plugins[pluginId]);
|
|
95
|
+
const view = typeof plugin.getPublicView === "function"
|
|
96
|
+
? await plugin.getPublicView(this.createContext({ plugin, roomState: clone(state), pluginState, actor: viewActor }), pluginState, viewActor)
|
|
97
|
+
: pluginState;
|
|
98
|
+
plugins[pluginId] = plugin.schemas.publicView ? parseWithSchema(plugin.schemas.publicView, view, `${pluginId} publicView`) : view;
|
|
99
|
+
}
|
|
100
|
+
return { roomId: state.roomId, appPack: clone(state.appPack), version: state.version, updatedAt: state.updatedAt, members: publicMembersView(state.members, viewActor), access: publicScopedRolesView(state, viewActor), direct: publicDirectView(state, viewActor), plugins };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function roomInfo(extra = {}) {
|
|
104
|
+
await this.start();
|
|
105
|
+
const state = this.validateRoomState(await this.store.load());
|
|
106
|
+
return {
|
|
107
|
+
kind: "matterhorn.host-info",
|
|
108
|
+
type: "room/info",
|
|
109
|
+
version: "0.1",
|
|
110
|
+
roomId: this.room.id,
|
|
111
|
+
roomVersion: state.version,
|
|
112
|
+
appPack: clone(this.room.appPack),
|
|
113
|
+
hostPack: this.hostPack ? {
|
|
114
|
+
id: this.hostPack.id,
|
|
115
|
+
version: this.hostPack.version,
|
|
116
|
+
hash: manifestHash(this.hostPack),
|
|
117
|
+
pluginGraphHash: this.hostPack.compatibility.pluginGraphHash
|
|
118
|
+
} : undefined,
|
|
119
|
+
plugins: [...this.pluginMap.values()].map((plugin) => ({
|
|
120
|
+
id: plugin.id,
|
|
121
|
+
version: plugin.version,
|
|
122
|
+
capabilities: clone(plugin.capabilities || {}),
|
|
123
|
+
integrity: plugin.integrity || pluginArtifactIntegrity(plugin)
|
|
124
|
+
})),
|
|
125
|
+
capabilities: [...this.capabilities].sort(),
|
|
126
|
+
playerRecommendations: this.playerPacks.map((pack) => ({
|
|
127
|
+
id: pack.id,
|
|
128
|
+
name: pack.name,
|
|
129
|
+
version: pack.version,
|
|
130
|
+
mode: pack.mode,
|
|
131
|
+
recommendedFor: clone(pack.recommendedFor || {})
|
|
132
|
+
})),
|
|
133
|
+
operationPublicKey: this.operationPublicKey,
|
|
134
|
+
...clone(extra)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
createSnapshot,
|
|
140
|
+
handleQuery,
|
|
141
|
+
operationBatchSince,
|
|
142
|
+
operations,
|
|
143
|
+
publicView,
|
|
144
|
+
query,
|
|
145
|
+
roomInfo
|
|
146
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const { isSnowflakeId, compareSnowflakeIds } = require("@mh-gg/protocol");
|
|
2
|
+
const { CORE_PLUGIN_ID } = require("@mh-gg/authority");
|
|
3
|
+
const { runtimeError } = require("../errors.cjs");
|
|
4
|
+
const { authorizeCoreOperation } = require("../security/authorization/coreGate.cjs");
|
|
5
|
+
const { clone } = require("../shared.cjs");
|
|
6
|
+
const { commitVersion, tombstone } = require("./authorityReplay/state.cjs");
|
|
7
|
+
const { stateWithActorMember } = require("./memberProfiles.cjs");
|
|
8
|
+
|
|
9
|
+
const CORE_READ_TAG_SET_TYPE = "read.tag.set";
|
|
10
|
+
const READ_TAG_VERSION = 1;
|
|
11
|
+
const MAX_MEMBER_READ_TAGS = 1000;
|
|
12
|
+
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requiredString(value, name, max = 240) {
|
|
18
|
+
if (typeof value !== "string") throw runtimeError("SCHEMA_INVALID", `${name} must be a string`);
|
|
19
|
+
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
20
|
+
if (!text) throw runtimeError("SCHEMA_INVALID", `${name} must be a non-empty string`);
|
|
21
|
+
if (text.length > max) throw runtimeError("SCHEMA_INVALID", `${name} is too long`);
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function optionalString(value, name, max = 240) {
|
|
26
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
27
|
+
return requiredString(String(value), name, max);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function optionalTimestamp(value, name) {
|
|
31
|
+
if (value === undefined || value === null) return undefined;
|
|
32
|
+
const number = Number(value);
|
|
33
|
+
if (!Number.isFinite(number) || number < 0) throw runtimeError("SCHEMA_INVALID", `${name} must be a non-negative finite number`);
|
|
34
|
+
return Math.trunc(number);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertOnlyPayloadKeys(payload, allowed, operationType) {
|
|
38
|
+
if (!isPlainObject(payload)) throw runtimeError("SCHEMA_INVALID", `${operationType} payload must be an object`);
|
|
39
|
+
const allow = new Set(allowed);
|
|
40
|
+
for (const key of Object.keys(payload)) {
|
|
41
|
+
if (!allow.has(key)) throw runtimeError("SCHEMA_INVALID", `${operationType} payload has unknown field ${key}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readTagScopeKey(scopeType, scopeId) {
|
|
46
|
+
return `${scopeType}:${scopeId}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseReadTagPayload(operation) {
|
|
50
|
+
assertOnlyPayloadKeys(operation.payload || {}, ["scopeType", "scopeId", "ledgerId", "operationId", "messageId", "readAt", "label"], operation.type);
|
|
51
|
+
const scopeType = requiredString(operation.payload?.scopeType, `${operation.type} scopeType`, 120);
|
|
52
|
+
const scopeId = requiredString(operation.payload?.scopeId, `${operation.type} scopeId`, 500);
|
|
53
|
+
const ledgerId = optionalString(operation.payload?.ledgerId, `${operation.type} ledgerId`, 32);
|
|
54
|
+
if (ledgerId && !isSnowflakeId(ledgerId)) throw runtimeError("SCHEMA_INVALID", `${operation.type} ledgerId must be a Snowflake ID`);
|
|
55
|
+
const operationId = optionalString(operation.payload?.operationId, `${operation.type} operationId`, 240);
|
|
56
|
+
const messageId = optionalString(operation.payload?.messageId, `${operation.type} messageId`, 240);
|
|
57
|
+
const readAt = optionalTimestamp(operation.payload?.readAt, `${operation.type} readAt`);
|
|
58
|
+
const label = optionalString(operation.payload?.label, `${operation.type} label`, 120);
|
|
59
|
+
return {
|
|
60
|
+
scopeType,
|
|
61
|
+
scopeId,
|
|
62
|
+
...(ledgerId ? { ledgerId } : {}),
|
|
63
|
+
...(operationId ? { operationId } : {}),
|
|
64
|
+
...(messageId ? { messageId } : {}),
|
|
65
|
+
...(readAt !== undefined ? { readAt } : {}),
|
|
66
|
+
...(label ? { label } : {})
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readTagCursorValue(tag) {
|
|
71
|
+
if (tag?.ledgerId && isSnowflakeId(tag.ledgerId)) return { kind: "ledger", value: tag.ledgerId };
|
|
72
|
+
if (Number.isFinite(Number(tag?.readAt))) return { kind: "time", value: Number(tag.readAt) };
|
|
73
|
+
return { kind: "time", value: 0 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shouldReplaceReadTag(existing, next) {
|
|
77
|
+
if (!existing) return true;
|
|
78
|
+
const left = readTagCursorValue(existing);
|
|
79
|
+
const right = readTagCursorValue(next);
|
|
80
|
+
if (left.kind === "ledger" && right.kind === "ledger") return compareSnowflakeIds(right.value, left.value) >= 0;
|
|
81
|
+
if (left.kind === "ledger" && right.kind !== "ledger") return false;
|
|
82
|
+
if (left.kind !== "ledger" && right.kind === "ledger") return true;
|
|
83
|
+
return right.value >= left.value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pruneReadTags(readTags) {
|
|
87
|
+
const entries = Object.entries(readTags || {});
|
|
88
|
+
if (entries.length <= MAX_MEMBER_READ_TAGS) return readTags;
|
|
89
|
+
entries.sort((left, right) => Number(right[1]?.updatedAt || right[1]?.readAt || 0) - Number(left[1]?.updatedAt || left[1]?.readAt || 0));
|
|
90
|
+
return Object.fromEntries(entries.slice(0, MAX_MEMBER_READ_TAGS));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applyReadTagToRoomState(state, payload, actor, operation, now) {
|
|
94
|
+
const memberId = requiredString(actor?.memberId, `${operation.type} actor.memberId`, 240);
|
|
95
|
+
const key = readTagScopeKey(payload.scopeType, payload.scopeId);
|
|
96
|
+
const readAt = payload.readAt ?? now;
|
|
97
|
+
const nextTag = {
|
|
98
|
+
kind: "matterhorn.read-tag",
|
|
99
|
+
version: READ_TAG_VERSION,
|
|
100
|
+
key,
|
|
101
|
+
scopeType: payload.scopeType,
|
|
102
|
+
scopeId: payload.scopeId,
|
|
103
|
+
readAt,
|
|
104
|
+
updatedAt: now,
|
|
105
|
+
updatedBy: memberId,
|
|
106
|
+
updateOperationId: operation.id,
|
|
107
|
+
...(operation.ledgerId && isSnowflakeId(operation.ledgerId) ? { updateLedgerId: operation.ledgerId } : {}),
|
|
108
|
+
...(payload.ledgerId ? { ledgerId: payload.ledgerId } : operation.ledgerId && isSnowflakeId(operation.ledgerId) ? { ledgerId: operation.ledgerId } : {}),
|
|
109
|
+
...(payload.operationId ? { operationId: payload.operationId } : {}),
|
|
110
|
+
...(payload.messageId ? { messageId: payload.messageId } : {}),
|
|
111
|
+
...(payload.label ? { label: payload.label } : {})
|
|
112
|
+
};
|
|
113
|
+
const members = isPlainObject(state.members) ? state.members : {};
|
|
114
|
+
const existingMember = members[memberId] || {};
|
|
115
|
+
const existingReadTags = isPlainObject(existingMember.readTags) ? existingMember.readTags : {};
|
|
116
|
+
const existingTag = existingReadTags[key];
|
|
117
|
+
if (!shouldReplaceReadTag(existingTag, nextTag)) return state;
|
|
118
|
+
const member = {
|
|
119
|
+
...existingMember,
|
|
120
|
+
id: existingMember.id || memberId,
|
|
121
|
+
memberId,
|
|
122
|
+
role: existingMember.role || actor.role || "member",
|
|
123
|
+
readTags: pruneReadTags({ ...existingReadTags, [key]: nextTag }),
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
lastSeenAt: now
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
...state,
|
|
129
|
+
members: {
|
|
130
|
+
...members,
|
|
131
|
+
[memberId]: member
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function applyReadTagCoreEnvelope(runtime, state, operation) {
|
|
137
|
+
try {
|
|
138
|
+
const rawActor = await runtime.authenticateActor(operation.auth, operation.actor, operation);
|
|
139
|
+
const actor = await authorizeCoreOperation(runtime, { operation, actor: rawActor, state, plugin: { id: CORE_PLUGIN_ID }, roles: ["member"], operationLabel: `${CORE_PLUGIN_ID}.${operation.type}` });
|
|
140
|
+
const payload = parseReadTagPayload(operation);
|
|
141
|
+
const updatedAt = operation.createdAt || runtime.now();
|
|
142
|
+
const memberState = stateWithActorMember(state, actor, updatedAt);
|
|
143
|
+
Object.assign(state, applyReadTagToRoomState(memberState, payload, actor, { ...operation, payload, actor }, updatedAt));
|
|
144
|
+
commitVersion(state, operation, runtime);
|
|
145
|
+
return true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
tombstone(state, operation, "invalid-read-tag-operation", { code: error?.code, message: error?.message || String(error) });
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readTagsForMember(state, memberId) {
|
|
153
|
+
const member = state?.members?.[memberId];
|
|
154
|
+
return isPlainObject(member?.readTags) ? clone(member.readTags) : {};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readTagForScope(state, memberId, scopeType, scopeId) {
|
|
158
|
+
return readTagsForMember(state, memberId)[readTagScopeKey(scopeType, scopeId)];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
CORE_READ_TAG_SET_TYPE,
|
|
163
|
+
MAX_MEMBER_READ_TAGS,
|
|
164
|
+
READ_TAG_VERSION,
|
|
165
|
+
applyReadTagCoreEnvelope,
|
|
166
|
+
applyReadTagToRoomState,
|
|
167
|
+
parseReadTagPayload,
|
|
168
|
+
readTagForScope,
|
|
169
|
+
readTagScopeKey,
|
|
170
|
+
readTagsForMember
|
|
171
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { runtimeError } = require("../errors.cjs");
|
|
2
|
+
const { clone, pruneMap, rememberBounded } = require("../shared.cjs");
|
|
3
|
+
const { stateWithActorMember } = require("./memberProfiles.cjs");
|
|
4
|
+
const {
|
|
5
|
+
CORE_ACCESS_ROLE_ASSIGN_TYPE,
|
|
6
|
+
CORE_ACCESS_ROLE_DEFINE_TYPE,
|
|
7
|
+
CORE_SCOPE_ROLE_SET_TYPE,
|
|
8
|
+
applyAccessRoleAssignmentToRoomState,
|
|
9
|
+
applyAccessRoleDefineToRoomState,
|
|
10
|
+
applyScopeRoleSetToRoomState,
|
|
11
|
+
assertScopedRoleManagerCanApply,
|
|
12
|
+
normalizeScopedRolesState,
|
|
13
|
+
parseAccessRoleAssignmentPayload,
|
|
14
|
+
parseAccessRoleDefinePayload,
|
|
15
|
+
parseScopeRoleSetPayload
|
|
16
|
+
} = require("../security/scopedRoles.cjs");
|
|
17
|
+
|
|
18
|
+
function parseScopedCorePayload(operation) {
|
|
19
|
+
if (operation.type === CORE_SCOPE_ROLE_SET_TYPE) return parseScopeRoleSetPayload(operation);
|
|
20
|
+
if (operation.type === CORE_ACCESS_ROLE_DEFINE_TYPE) return parseAccessRoleDefinePayload(operation);
|
|
21
|
+
return parseAccessRoleAssignmentPayload(operation);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ackOperation(runtime, operationId, roomVersion) {
|
|
25
|
+
const ack = { ok: true, acceptedOperationId: operationId, roomVersion };
|
|
26
|
+
runtime.acks.set(operationId, ack);
|
|
27
|
+
pruneMap(runtime.acks, Math.min(runtime.maxAcks, runtime.maxAckCache));
|
|
28
|
+
return clone(ack);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function commitScopedRoleState(runtime, operation, state, actor, signedOperationForLog, payload, nextScopedState) {
|
|
32
|
+
const createdAt = operation.createdAt || runtime.now();
|
|
33
|
+
const memberState = stateWithActorMember(nextScopedState, actor, createdAt);
|
|
34
|
+
const nextRoomState = runtime.validateRoomState({
|
|
35
|
+
...memberState,
|
|
36
|
+
version: state.version + 1,
|
|
37
|
+
updatedAt: createdAt,
|
|
38
|
+
seenOperations: rememberBounded(state.seenOperations, operation.id, runtime.maxSeenOperations)
|
|
39
|
+
});
|
|
40
|
+
await runtime.commitStateAndOperation({
|
|
41
|
+
logEntry: { ...signedOperationForLog, payload, committedRoomVersion: nextRoomState.version, committedAt: nextRoomState.updatedAt },
|
|
42
|
+
nextState: nextRoomState,
|
|
43
|
+
expectedPreviousVersion: state.version
|
|
44
|
+
});
|
|
45
|
+
return ackOperation(runtime, operation.id, nextRoomState.version);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function commitScopeRoleSet(operation, actor, state, signedOperationForLog) {
|
|
49
|
+
const payload = parseScopedCorePayload(operation);
|
|
50
|
+
assertScopedRoleManagerCanApply(actor, { grants: [{ role: payload.role || payload.defaultRole || "none" }] });
|
|
51
|
+
return commitScopedRoleState(
|
|
52
|
+
this,
|
|
53
|
+
operation,
|
|
54
|
+
state,
|
|
55
|
+
actor,
|
|
56
|
+
signedOperationForLog,
|
|
57
|
+
payload,
|
|
58
|
+
applyScopeRoleSetToRoomState(state, payload, actor, operation.createdAt || this.now(), operation)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function commitAccessRoleDefine(operation, actor, state, signedOperationForLog) {
|
|
63
|
+
const payload = parseScopedCorePayload(operation);
|
|
64
|
+
assertScopedRoleManagerCanApply(actor, payload);
|
|
65
|
+
return commitScopedRoleState(
|
|
66
|
+
this,
|
|
67
|
+
operation,
|
|
68
|
+
state,
|
|
69
|
+
actor,
|
|
70
|
+
signedOperationForLog,
|
|
71
|
+
payload,
|
|
72
|
+
applyAccessRoleDefineToRoomState(state, payload, actor, operation.createdAt || this.now(), operation)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function commitAccessRoleAssignment(operation, actor, state, signedOperationForLog) {
|
|
77
|
+
const payload = parseScopedCorePayload(operation);
|
|
78
|
+
const compoundRole = normalizeScopedRolesState(state.scopedRoles).roles[payload.roleId];
|
|
79
|
+
if (!compoundRole) throw runtimeError("SCHEMA_INVALID", `Compound role ${payload.roleId} is not defined`);
|
|
80
|
+
assertScopedRoleManagerCanApply(actor, { grants: compoundRole.grants });
|
|
81
|
+
return commitScopedRoleState(
|
|
82
|
+
this,
|
|
83
|
+
operation,
|
|
84
|
+
state,
|
|
85
|
+
actor,
|
|
86
|
+
signedOperationForLog,
|
|
87
|
+
payload,
|
|
88
|
+
applyAccessRoleAssignmentToRoomState(state, payload, operation.type === CORE_ACCESS_ROLE_ASSIGN_TYPE, actor, operation.createdAt || this.now(), operation)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
commitAccessRoleAssignment,
|
|
94
|
+
commitAccessRoleDefine,
|
|
95
|
+
commitScopeRoleSet,
|
|
96
|
+
parseScopedCorePayload
|
|
97
|
+
};
|