@mh-gg/player-runtime 0.1.1-alpha.20260626T104441232Z

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matterhorn contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @mh-gg/player-runtime
2
+
3
+ Matterhorn player-side plugin runtime helpers for dispatch, optimistic state, and launch envelopes.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@mh-gg/player-runtime",
3
+ "version": "0.1.1-alpha.20260626T104441232Z",
4
+ "description": "Matterhorn player-side plugin runtime helpers for dispatch, optimistic state, and launch envelopes.",
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.20260626T104441232Z",
12
+ "@mh-gg/launcher": "^0.1.1-alpha.20260626T104441232Z",
13
+ "@mh-gg/protocol": "^0.1.1-alpha.20260626T104441232Z"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.12"
17
+ },
18
+ "license": "MIT",
19
+ "files": [
20
+ "src",
21
+ "README.md",
22
+ "package.json"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test test/*.test.cjs",
26
+ "coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=70 --test-coverage-branches=40 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
27
+ }
28
+ }
package/src/errors.cjs ADDED
@@ -0,0 +1,12 @@
1
+ class MatterhornPlayerRuntimeError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "MatterhornPlayerRuntimeError";
5
+ }
6
+ }
7
+
8
+ function fail(message) {
9
+ throw new MatterhornPlayerRuntimeError(message);
10
+ }
11
+
12
+ module.exports = { MatterhornPlayerRuntimeError, fail };
package/src/index.cjs ADDED
@@ -0,0 +1,13 @@
1
+ const {
2
+ createOptimisticOperationManager,
3
+ createPlayerRuntime
4
+ } = require("@mh-gg/launcher");
5
+
6
+ module.exports = {
7
+ ...require("./errors.cjs"),
8
+ createOptimisticOperationManager,
9
+ createPlayerRuntime,
10
+ ...require("./launch/index.cjs"),
11
+ ...require("./operation-auth/index.cjs"),
12
+ ...require("./plugin-runtime/index.cjs")
13
+ };
@@ -0,0 +1,33 @@
1
+ const {
2
+ decryptLaunchEnvelope,
3
+ encryptLaunchEnvelope,
4
+ parseExternalLaunchUrl,
5
+ verifyLaunchEnvelope
6
+ } = require("@mh-gg/launcher");
7
+ const { fail } = require("../errors.cjs");
8
+
9
+ function openExternalLaunch(url, options = {}) {
10
+ const parsed = parseExternalLaunchUrl(url);
11
+ if (parsed.envelope?.kind === "matterhorn.launch-sealed" && options.playerPack) {
12
+ if (parsed.envelope.playerPack?.id !== options.playerPack.id || parsed.envelope.playerPack?.version !== options.playerPack.version) {
13
+ fail("Launch envelope is for a different player.");
14
+ }
15
+ }
16
+ const envelope = parsed.envelope?.kind === "matterhorn.launch-sealed"
17
+ ? decryptLaunchEnvelope(parsed.envelope.sealed, options.secret)
18
+ : parsed.envelope;
19
+ const verified = verifyLaunchEnvelope(envelope, {
20
+ now: options.now ? options.now() : Date.now(),
21
+ playerPack: options.playerPack,
22
+ appPack: options.appPack,
23
+ nonceStore: options.nonceStore
24
+ });
25
+ if (!verified.ok && options.allowUnverifiedLaunch !== true) fail(`Launch envelope is invalid: ${verified.reason}`);
26
+ return { ...parsed, envelope };
27
+ }
28
+
29
+ function sealExternalLaunchEnvelope(envelope, secret) {
30
+ return encryptLaunchEnvelope(envelope, secret);
31
+ }
32
+
33
+ module.exports = { openExternalLaunch, sealExternalLaunchEnvelope };
@@ -0,0 +1,55 @@
1
+ const crypto = require("node:crypto");
2
+ const { canonicalJson } = require("@mh-gg/event/canonicalJson");
3
+ const { clone } = require("../shared/clone.cjs");
4
+ const { ensureOperationIdentity } = require("@mh-gg/protocol");
5
+
6
+ function unsignedRoomOperation(operation) {
7
+ const copy = clone(operation);
8
+ if (copy?.auth && Object.prototype.hasOwnProperty.call(copy.auth, "signature")) delete copy.auth.signature;
9
+ return copy;
10
+ }
11
+
12
+ function signRoomOperation(operation, privateKey, publicKey) {
13
+ const next = clone(operation);
14
+ next.auth = { ...(next.auth || {}) };
15
+ if (publicKey !== undefined) next.auth.publicKey = publicKey;
16
+ delete next.auth.signature;
17
+ const withIdentity = ensureOperationIdentity(next, { nodeId: next.actor?.deviceId || next.actor?.memberId || "player-auth", now: next.createdAt || Date.now() });
18
+ withIdentity.auth.signature = crypto.sign(null, Buffer.from(canonicalJson(unsignedRoomOperation(withIdentity))), privateKey).toString("base64url");
19
+ return withIdentity;
20
+ }
21
+
22
+ /**
23
+ * Verify an operation signature against an explicit public key the caller already trusts.
24
+ * This is an authenticity check only when trustedPublicKey came from a grant, member record, or pin.
25
+ */
26
+ function verifyOperationAuth(operation, trustedPublicKey) {
27
+ if (!operation || typeof operation !== "object") return { ok: false, error: "Operation is required." };
28
+ if (!operation.auth || typeof operation.auth !== "object") return { ok: false, error: "Operation auth is required." };
29
+ if (typeof operation.auth.signature !== "string" || operation.auth.signature.length === 0) return { ok: false, error: "Operation signature is required." };
30
+ if (typeof trustedPublicKey !== "string" || trustedPublicKey.length === 0) return { ok: false, error: "Trusted operation public key is required." };
31
+ try {
32
+ const ok = crypto.verify(null, Buffer.from(canonicalJson(unsignedRoomOperation(operation))), trustedPublicKey, Buffer.from(operation.auth.signature, "base64url"));
33
+ return ok ? { ok: true } : { ok: false, error: "Operation signature is invalid." };
34
+ } catch (error) {
35
+ return { ok: false, error: error?.message || "Operation signature verification failed." };
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Check only that an operation was signed by its embedded public key.
41
+ * This is not an authenticity check and must not be used as an authorization decision.
42
+ */
43
+ function checkOperationSignatureSelfConsistent(operation) {
44
+ const verified = verifyOperationAuth(operation, operation?.auth?.publicKey);
45
+ return verified.ok
46
+ ? { ok: true, signedWithEmbeddedKey: true }
47
+ : { ...verified, signedWithEmbeddedKey: true };
48
+ }
49
+
50
+ module.exports = {
51
+ checkOperationSignatureSelfConsistent,
52
+ signRoomOperation,
53
+ unsignedRoomOperation,
54
+ verifyOperationAuth
55
+ };
@@ -0,0 +1,60 @@
1
+ const {
2
+ createOptimisticOperationManager,
3
+ createPlayerRuntime,
4
+ verifyLaunchEnvelope
5
+ } = require("@mh-gg/launcher");
6
+ const { fail } = require("../errors.cjs");
7
+ const { clone } = require("../shared/clone.cjs");
8
+
9
+ function createPlayerPluginRuntime(options = {}) {
10
+ if (!options.playerPlugin || typeof options.playerPlugin !== "object") fail("playerPlugin is required");
11
+ if (!options.launchEnvelope || typeof options.launchEnvelope !== "object") fail("launchEnvelope is required");
12
+ const verified = verifyLaunchEnvelope(options.launchEnvelope, { now: options.now ? options.now() : Date.now() });
13
+ if (!verified.ok && options.allowUnverifiedLaunch !== true) fail(`Launch envelope is invalid: ${verified.reason}`);
14
+
15
+ const hostClient = options.hostClient;
16
+ if (!hostClient || typeof hostClient.sendOperation !== "function") fail("hostClient.sendOperation is required");
17
+ const operationRuntime = createPlayerRuntime({
18
+ room: options.launchEnvelope.room,
19
+ appPack: options.launchEnvelope.appPack,
20
+ actor: options.actor || options.launchEnvelope.actor || { memberId: "anonymous", deviceId: "device", role: "guest" },
21
+ credentialGrant: options.launchEnvelope.credentialGrant,
22
+ now: options.now,
23
+ signOperation: options.signOperation,
24
+ sendOperation: (operation) => hostClient.sendOperation(operation)
25
+ });
26
+ const optimistic = createOptimisticOperationManager(options.initialState || {}, options.playerPlugin.optimisticReducers || {});
27
+
28
+ return {
29
+ playerPlugin: options.playerPlugin,
30
+ launchEnvelope: clone(options.launchEnvelope),
31
+ pending: operationRuntime.pending,
32
+ state: optimistic,
33
+ dispatch: async (draft) => {
34
+ const operation = await operationRuntime.buildOperationAsync(draft);
35
+ optimistic.apply(operation);
36
+ operationRuntime.pending.set(operation.id, { operation, status: "pending" });
37
+ try {
38
+ const result = await hostClient.sendOperation(operation);
39
+ operationRuntime.pending.set(operation.id, { operation, status: "accepted", result });
40
+ if (result?.state !== undefined) optimistic.ack(operation.id, result.state);
41
+ else optimistic.ack(operation.id);
42
+ return result;
43
+ } catch (error) {
44
+ operationRuntime.pending.set(operation.id, { operation, status: "rejected", error: error?.message || String(error) });
45
+ optimistic.reject(operation.id);
46
+ throw error;
47
+ }
48
+ },
49
+ action(name, ...args) {
50
+ const fn = options.playerPlugin.actions?.[name];
51
+ if (typeof fn !== "function") fail(`Player action ${name} is not available`);
52
+ return fn({ dispatch: this.dispatch }, ...args);
53
+ },
54
+ snapshot() {
55
+ return optimistic.getState();
56
+ }
57
+ };
58
+ }
59
+
60
+ module.exports = { createPlayerPluginRuntime };
@@ -0,0 +1,5 @@
1
+ function clone(value) {
2
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
3
+ }
4
+
5
+ module.exports = { clone };