@memizy/protocol 0.0.0-stage → 1.0.0-rc.6
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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/SPEC.md +497 -0
- package/dist/errors.cjs +1 -0
- package/dist/errors.js +37 -0
- package/dist/index.cjs +1 -0
- package/dist/index.js +13 -0
- package/dist/internal.cjs +1 -0
- package/dist/internal.js +8 -0
- package/dist/items.cjs +1 -0
- package/dist/items.js +114 -0
- package/dist/limits.cjs +1 -0
- package/dist/limits.js +73 -0
- package/dist/manifestSchema.cjs +2 -0
- package/dist/manifestSchema.js +107 -0
- package/dist/pluginRuntime.cjs +1 -0
- package/dist/pluginRuntime.js +48 -0
- package/dist/polyfills.cjs +1 -0
- package/dist/polyfills.js +23 -0
- package/dist/relay.cjs +1 -0
- package/dist/relay.js +22 -0
- package/dist/schemas.cjs +1 -0
- package/dist/schemas.js +74 -0
- package/dist/security.cjs +1 -0
- package/dist/security.js +40 -0
- package/dist/settings.cjs +1 -0
- package/dist/settings.js +35 -0
- package/dist/settingsSchema.cjs +1 -0
- package/dist/settingsSchema.js +58 -0
- package/dist/src/errors.d.ts +21 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/internal.d.ts +12 -0
- package/dist/src/items.d.ts +27 -0
- package/dist/src/limits.d.ts +78 -0
- package/dist/src/manifestSchema.d.ts +159 -0
- package/dist/src/pluginRuntime.d.ts +47 -0
- package/dist/src/polyfills.d.ts +6 -0
- package/dist/src/relay.d.ts +186 -0
- package/dist/src/schemas.d.ts +111 -0
- package/dist/src/security.d.ts +21 -0
- package/dist/src/settings.d.ts +22 -0
- package/dist/src/settingsSchema.d.ts +54 -0
- package/dist/src/types.d.ts +182 -0
- package/dist/src/version.d.ts +30 -0
- package/dist/types.cjs +1 -0
- package/dist/types.js +24 -0
- package/dist/version.cjs +1 -0
- package/dist/version.js +31 -0
- package/package.json +63 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog of @memizy/protocol
|
|
2
|
+
|
|
3
|
+
The protocol has its own changelog at the end of [SPEC.md](https://github.com/memizy/memizy/blob/main/packages/protocol/SPEC.md#changelog) (protocol 1.0, 2026-10-14). This file lists the releases of the npm package.
|
|
4
|
+
|
|
5
|
+
## 1.0.0-rc.6 (2026-10-14)
|
|
6
|
+
|
|
7
|
+
The contract of protocol 1.0 is final: within MAJOR 1 it changes only additively (SPEC 9).
|
|
8
|
+
|
|
9
|
+
* SPEC status 1.0; the "RC4" labels are gone.
|
|
10
|
+
* The address `"settings"` (`SETTINGS_ADDRESS`, the plugin's settings screen in the lobby) is defined in SPEC 3.1 and on the `Address` type.
|
|
11
|
+
* `hello` is called once per instance: a second call rejects with `INVALID_ARGUMENT`. Only the first `end(result)` counts; later calls (also from a recreated authority) resolve and are ignored.
|
|
12
|
+
* Guaranteed rates and sizes in SPEC 8.1 and `LIMITS`: `recordAnswer` 10 per second, burst 20 (`answersPerSecond`, `answerBurst`; the authority: per player), `getAsset` 10 per second, burst 20, and 2 MB per asset (`assetsPerSecond`, `assetBurst`, `assetBytes`), `reportError` 5 per second, burst 10, more dropped without an error (`errorReportsPerSecond`, `errorReportBurst`), and `serviceResultBytes` (256 KB, the service result limit that was already in the SPEC).
|
|
13
|
+
* `RELAY_LIMITS.frameBytes` (in `@memizy/protocol/internal`) is 3 MB, so that a 2 MB asset fits a relay frame as base64.
|
|
14
|
+
* Since 1.0.0-rc.5 (details in the SPEC changelog "1.0"): the term *host* (3.1) and `sessionEnded` before a player's instance is removed (`kicked`, `closed`, `host-left`); `exit`, `resize`, `setChanged` and `saveProgress` removed and reserved (SPEC 10); `InitPayload.services` and all `SessionClock` fields always present; the relay type `RoomPlayer`; state patches carry their format (`f`); reserved names `$…`, `data-mz-…`, `mz-…`; the `@memizy/protocol/internal` entry point (not public API); Zod 4 for the 1.x line; the error code `UNKNOWN_METHOD`; the sandbox allows popups that escape it (links in a new tab); `appSpecific.memizy.set`; OQSE 1.0 sets and OQSEM 1.0 manifests.
|
|
15
|
+
|
|
16
|
+
## 1.0.0-rc.5
|
|
17
|
+
|
|
18
|
+
Protocol RC4 as a package: handshake, views and authority, messages, learning progress, plugin data, services, pauses, permissions with a content policy, display order and public items, limits and errors.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 memizy
|
|
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
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @memizy/protocol
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Types, Zod schemas and helpers of the **Memizy Plugin Protocol v1** – the contract between Memizy plugins (`@memizy/plugin-sdk`) and hosts (`@memizy/host-sdk`).
|
|
4
|
+
|
|
5
|
+
* Specification: [SPEC.md](./SPEC.md) (1.0); changes of the package: [CHANGELOG.md](./CHANGELOG.md)
|
|
6
|
+
* Plugin authors do not use this package directly – see the [AI plugin guide](https://github.com/memizy/memizy/blob/main/docs/ai-plugin-guide.md).
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import {
|
|
10
|
+
readPluginManifestFromHtml, // validate a plugin's manifest data island without running it
|
|
11
|
+
resolveSettings, // apply defaults / validate setting values
|
|
12
|
+
negotiateProtocolVersion, // handshake version negotiation
|
|
13
|
+
LIMITS, // size and rate limits (SPEC 8.1)
|
|
14
|
+
ProtocolError, toProtocolError,
|
|
15
|
+
type HostApi, type PluginApi, type InitPayload,
|
|
16
|
+
} from '@memizy/protocol';
|
|
17
|
+
|
|
18
|
+
const result = readPluginManifestFromHtml(html);
|
|
19
|
+
if (result.success) console.log(result.runtime.views); // e.g. ['solo', 'board', 'controller']
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
* **Public API** = what `@memizy/protocol` exports (semver within 1.x, SPEC 9). Helpers shared by the Memizy packages – call parsing (`parseHostCall`), size checks (`assertJsonWithin`), rate limiting, content policies of the iframe, relay limits – are in **`@memizy/protocol/internal`** and may change in any release.
|
|
23
|
+
* The exported `*Schema` objects are **Zod 4** schemas; the 1.x line stays on Zod 4.
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# Memizy Plugin Protocol v1
|
|
2
|
+
|
|
3
|
+
> Status: **1.0** (2026-10-14; protocol version `1.0`, packages `1.0.0`). The contract is frozen: within MAJOR `1` it changes only additively (section 9). Every change is recorded in the changelog at the end.
|
|
4
|
+
|
|
5
|
+
This document defines the contract between a **Memizy plugin** (an HTML page running in a sandboxed iframe) and a **Memizy host** (the Memizy app, the Plugin Lab, or any other application embedding plugins through `@memizy/host-sdk`).
|
|
6
|
+
|
|
7
|
+
It covers:
|
|
8
|
+
|
|
9
|
+
1. the **plugin manifest** (what the plugin declares statically),
|
|
10
|
+
2. the **session model** and lifecycle,
|
|
11
|
+
3. the **handshake** and version negotiation,
|
|
12
|
+
4. the **RPC methods** in both directions,
|
|
13
|
+
5. **message routing** for multiplayer,
|
|
14
|
+
6. **limits, errors, security and compatibility rules**.
|
|
15
|
+
|
|
16
|
+
It does **not** cover the high-level game API (`defineGame`) – that lives in `@memizy/plugin-sdk`, which every plugin bundles (see [the AI plugin guide](https://github.com/memizy/memizy/blob/main/docs/ai-plugin-guide.md)). It also does not cover the host ↔ multiplayer-server wire protocol, which is internal to `@memizy/host-sdk`.
|
|
17
|
+
|
|
18
|
+
Data formats (sets, items, progress) are defined by [OQSE](https://github.com/memizy/memizy/blob/main/packages/oqse/oqse.md).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. Design Principles
|
|
23
|
+
|
|
24
|
+
* **Small surface.** Everything a plugin needs is built in the SDK on top of a few generic primitives (messages, snapshot, learning progress, plugin data, assets, lifecycle). Only these primitives are frozen.
|
|
25
|
+
* **The plugin never talks to the network.** All communication goes through `postMessage` to the host (Penpal). Games that need more than JSON messages through the host must be standalone games.
|
|
26
|
+
* **The host owns the session.** Lobby, players, settings, starting and ending a session, persistence and learning algorithms (Leitner/FSRS) belong to the host.
|
|
27
|
+
* **Additive evolution.** Minor versions only add optional fields, methods and features; both sides ignore what they do not know.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. Plugin Manifest
|
|
32
|
+
|
|
33
|
+
The manifest is an [OQSEM](https://github.com/memizy/memizy/blob/main/packages/oqse/oqse-manifest.md) document embedded in the plugin HTML as a data island. The host reads it **without executing the plugin** (registry, upload in the Lab).
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<script type="application/oqse-manifest+json">
|
|
37
|
+
{
|
|
38
|
+
"version": "1.0",
|
|
39
|
+
"id": "https://example.com/plugins/quiz-race",
|
|
40
|
+
"appName": "Quiz Race",
|
|
41
|
+
"pluginVersion": "1.0.0",
|
|
42
|
+
"capabilities": {
|
|
43
|
+
"actions": ["render"],
|
|
44
|
+
"types": ["mcq-single", "true-false"],
|
|
45
|
+
"features": ["markdown", "latex"]
|
|
46
|
+
},
|
|
47
|
+
"appSpecific": {
|
|
48
|
+
"memizy": {
|
|
49
|
+
"protocol": "1.0",
|
|
50
|
+
"modes": {
|
|
51
|
+
"solo": {},
|
|
52
|
+
"multiplayer": {
|
|
53
|
+
"players": { "min": 2, "max": 40 },
|
|
54
|
+
"hostAs": ["presenter", "player"],
|
|
55
|
+
"lateJoin": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"settings": [
|
|
59
|
+
{ "id": "questionTime", "type": "number", "label": "Time per question (s)", "default": 20, "min": 5, "max": 120 }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The OQSEM part describes **content** (item types, assets, features) and is used for the [OQSE handshake](https://github.com/memizy/memizy/blob/main/packages/oqse/oqse-manifest.md#the-handshake-matching-process) (`checkCompatibility`). The Memizy part (`appSpecific.memizy`) describes the **runtime**:
|
|
68
|
+
|
|
69
|
+
| Key | Type | Required | Description |
|
|
70
|
+
| :--- | :--- | :--- | :--- |
|
|
71
|
+
| `protocol` | string | Yes | Minimum protocol version the plugin needs, `MAJOR.MINOR` (e.g. `"1.0"`). |
|
|
72
|
+
| `set` | `"required"` \| `"optional"` \| `"none"` | No | Whether the plugin uses a study set. Default `"required"`: the host offers sets and checks compatibility. `"optional"`: the host also offers playing without a set. `"none"`: a game without questions – the host offers no set, checks nothing and reports no incompatibility. Without a set, the plugin gets an empty set (`items: []`, `meta` made by the host, e.g. with the plugin's name). `"required"` and `"optional"` need `capabilities.types`; `"none"` needs none. |
|
|
73
|
+
| `modes` | object | Yes | At least one of `solo`, `multiplayer`. |
|
|
74
|
+
| `modes.solo` | object | No | Present = the plugin supports single-player. Empty object for now. |
|
|
75
|
+
| `modes.multiplayer.players` | `{min, max, recommended?}` | Yes (in multiplayer) | Player count limits (players, not counting a presenter). `1 ≤ min ≤ max ≤ 200`; `recommended` is a hint for the lobby. |
|
|
76
|
+
| `modes.multiplayer.hostAs` | string[] | Yes (in multiplayer) | How the host may take part: `"presenter"` (host shows a shared board and does not play) and/or `"player"` (host plays like everyone else, no board). |
|
|
77
|
+
| `modes.multiplayer.lateJoin` | boolean | No | Players may join a running game. Default `true`. |
|
|
78
|
+
| `settings` | SettingDefinition[] | No | Settings of the game (see below). Default `[]`. They define types, defaults and limits; the host validates every value against them. Used in both modes (section 3.3). |
|
|
79
|
+
| `settingsScreen` | `{ size }` | No | Multiplayer lobby only. Present = the plugin renders its own settings screen (view `settings`) on the host's device; absent = the host generates a form from `settings`. `size: "compact"` – a panel next to the player list (the user may expand it to full screen); `size: "large"` – opened as a full-screen dialog from a "Game settings" button. |
|
|
80
|
+
| `display.orientation` | `"any"` \| `"portrait"` \| `"landscape"` | No | Preferred orientation of player screens. Default `"any"`. |
|
|
81
|
+
| `permissions.network` | string[] | No | Origins the plugin talks to with `fetch` / WebSocket, as `"https://host[:port]"` or `"wss://host[:port]"` (no path; max 20). Shown to users and enforced (section 8.4). |
|
|
82
|
+
| `permissions.devices` | (`"camera"` \| `"microphone"` \| `"geolocation"` \| `"serial"` \| `"bluetooth"`)[] | No | Devices the plugin uses; the host grants only these (section 8.4). |
|
|
83
|
+
| `services` | string[] | No | Memizy services the plugin calls (section 5.3), e.g. `"chess.puzzles"`: lowercase segments joined by dots. A host that does not offer one can still run the plugin; the call then fails with `SERVICE_UNAVAILABLE`. |
|
|
84
|
+
|
|
85
|
+
**SettingDefinition:** `{ id, type, label, default, description?, modes?, ...typeSpecific }`
|
|
86
|
+
|
|
87
|
+
`modes`: `["solo"]`, `["multiplayer"]` or both (default when absent). Hosts show a setting only in the modes it applies to; the plugin still receives a value (the default) for every declared setting.
|
|
88
|
+
|
|
89
|
+
| `type` | Extra keys | Value |
|
|
90
|
+
| :--- | :--- | :--- |
|
|
91
|
+
| `number` | `min?`, `max?`, `step?` | number |
|
|
92
|
+
| `boolean` | – | boolean |
|
|
93
|
+
| `select` | `options: [{ value, label }]` | one of the values |
|
|
94
|
+
| `text` | `maxLength?` | string |
|
|
95
|
+
|
|
96
|
+
`label`, `description` and option labels are either a string or a map of BCP 47 language → string (e.g. `{ "cs": "Čas", "en": "Time" }`).
|
|
97
|
+
|
|
98
|
+
Reserved for later minor versions: `teams`, editing permissions.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 3. Session Model
|
|
103
|
+
|
|
104
|
+
### 3.1 Terms
|
|
105
|
+
|
|
106
|
+
| Term | Meaning |
|
|
107
|
+
| :--- | :--- |
|
|
108
|
+
| **Host** | The application that embeds the plugin on a device and connects it to the session (the Memizy app, the Plugin Lab, on a player's device the player's app). A plugin only ever talks to its host; how a host connects the devices of a session is the host's business. |
|
|
109
|
+
| **Session** | One run of a game, from start to end (e.g., one quiz in a lesson). |
|
|
110
|
+
| **Mode** | `solo` (one person plays alone) or `multiplayer` (several devices together). |
|
|
111
|
+
| **Host role** (`hostAs`) | In multiplayer, the role of the person who created the session: `presenter` (shows a shared board, e.g. a teacher at a projector, and does not play) or `player` (plays like everyone else, no board). A plugin may support both; the host chooses in the lobby. |
|
|
112
|
+
| **Instance** | One running plugin iframe on one device. 30 phones + 1 projector = 31 instances. |
|
|
113
|
+
| **View** | What one instance shows: `solo`, `board` (the presenter's shared screen), `controller` (one player's own screen – a phone, tablet or computer) or `settings` (the plugin's settings screen on the host's device in the multiplayer lobby). Views are derived from the manifest (section 3.2), not declared separately. |
|
|
114
|
+
| **Player** | A person who plays and can score. The presenter is not a player. |
|
|
115
|
+
| **Address** | The "envelope address" of an instance, used to route messages (section 6): the player ID for a player's instance (stable across reconnects), `"board"` for the presenter's board, `"settings"` (`SETTINGS_ADDRESS`) for the plugin's settings screen in the lobby. The address `"server"` is **reserved** for a future server-side authority. Plugin authors never see addresses; the SDK exposes only player IDs. |
|
|
116
|
+
| **Authority** | The one place that owns the game state and applies actions: the board (`presenter`), the host's controller (host as `player`) or the only instance (`solo`). Other instances send actions to it and receive the new state. In a future version the authority may be the server (address `"server"`); this is why game logic must be deterministic. |
|
|
117
|
+
| **State** | The whole state of the game: one JSON value owned by the authority; its content is defined by the plugin. |
|
|
118
|
+
| **Action** | An intent of a player or a timer (e.g., "answered B"). Only actions change the state. |
|
|
119
|
+
| **Snapshot** | A saved copy of the authority's state (including pending timers), used to resume the game when the authority instance is recreated (reload, crash). Late joiners and reconnecting players do not use it; they synchronize with the running authority. |
|
|
120
|
+
| **Learning progress** | Per-item learning records of a player in [OQSEP](https://github.com/memizy/memizy/blob/main/packages/oqse/oqse-progress.md) (buckets, review dates). Understood and scheduled by the host. |
|
|
121
|
+
| **Plugin data** | Game progress of a player that only the plugin understands (unlocked levels, coins, best scores). Stored by the host, opaque to it (section 5.2). |
|
|
122
|
+
|
|
123
|
+
| Situation | Instances (views) | Authority |
|
|
124
|
+
| :--- | :--- | :--- |
|
|
125
|
+
| Solo | 1 × `solo` | itself |
|
|
126
|
+
| Multiplayer, host as presenter | 1 × `board` + N × `controller` | `board` |
|
|
127
|
+
| Multiplayer, host as player | N × `controller` (host is one of them) | host's controller |
|
|
128
|
+
|
|
129
|
+
### 3.2 Views Are Derived from the Manifest
|
|
130
|
+
|
|
131
|
+
A plugin does not declare views. They follow from what it declares, so the two can never contradict each other:
|
|
132
|
+
|
|
133
|
+
| The manifest declares | The plugin must render view |
|
|
134
|
+
| :--- | :--- |
|
|
135
|
+
| `modes.solo` | `solo` |
|
|
136
|
+
| `modes.multiplayer` with `hostAs` containing `presenter` | `board` and `controller` |
|
|
137
|
+
| `modes.multiplayer` with `hostAs` containing `player` | `controller` |
|
|
138
|
+
| `modes.multiplayer` and `settingsScreen` | `settings` |
|
|
139
|
+
|
|
140
|
+
`hostAs`, `players`, `lateJoin` and `settingsScreen` only apply when `modes.multiplayer` is declared. The `settings` schema applies to both modes.
|
|
141
|
+
|
|
142
|
+
### 3.3 Lifecycle
|
|
143
|
+
|
|
144
|
+
**Multiplayer:**
|
|
145
|
+
|
|
146
|
+
1. **Lobby** (host UI). The host chooses the plugin and the set; players join. Player names are display names chosen by the host app – Memizy Play gives every player a random nickname and does not ask for real names (privacy of students); a host may let players change it. Names may change during the game (`playersChanged`). Plugins MUST NOT ask players for their real names or other personal data; a game that needs a name uses `Player.name`. The settings are edited by the host: either in a form generated from `settings`, or – if the manifest declares `settingsScreen` – in the plugin itself running in view `settings` on the host's device (the plugin calls `updateSettings` and receives `playersChanged`). Start is enabled only while the settings are valid.
|
|
147
|
+
2. **Loading.** After Start, the host creates the game instances (board and/or controllers). Each instance connects, receives its `InitPayload` (without game state) and calls `ready()`.
|
|
148
|
+
3. **Countdown.** When all connected instances are ready (or after a host-defined timeout, e.g. 15 s), the host shows a countdown and then calls `start()` on the authority. The authority creates the initial state and the game runs. The barrier exists so that timed games do not start before slower devices have loaded.
|
|
149
|
+
4. **Running.** Late joiners (if `lateJoin`) and reconnecting players get a new instance, which synchronizes with the authority. A recreated authority receives the last `snapshot` and continues without `start()`. While the authority is unreachable, see section 6.1.
|
|
150
|
+
5. **End.** The authority calls `end(result)`; the host shows results and finally closes the instances with `sessionEnded`.
|
|
151
|
+
|
|
152
|
+
**Solo:** one instance in view `solo`. There is **no plugin settings screen and no countdown**. Before creating the instance, the host MAY show a form generated from the settings that apply to solo (`modes`); otherwise it uses the defaults or values it presets (e.g. a course step "practise this set with 10 questions"). It calls `start()` right after `ready()`. Choices that belong to the game itself (a level, a character) are made inside the game, typically as its first phase.
|
|
153
|
+
|
|
154
|
+
**Continuing a saved game** (a host feature; nothing changes for plugins). A host may save a game that has not ended – the authority's last snapshot, the settings, the display-order seed (4.4) and the players – and continue it later, in multiplayer also in a new room with other devices. The continued session keeps the same session ID and the same player IDs, so the game state stays valid: the authority is recreated from the snapshot (`InitPayload.snapshot`, no `start()`), the session starts paused (`SessionClock`, so the time between the lessons does not count), and each device takes the place of one earlier player (the host decides how; Memizy Play lets the player pick the earlier nickname and the teacher change it). A place without a device is a disconnected player (6). A game that ended with `end(result)` cannot be continued (its snapshot is discarded, 5).
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 4. Handshake
|
|
159
|
+
|
|
160
|
+
The connection uses [Penpal](https://github.com/Aaronius/penpal) over `postMessage`. The iframe is sandboxed **without** `allow-same-origin` (opaque origin), therefore the plugin connects with `allowedOrigins: ['*']`; Penpal still verifies that messages come from the expected window.
|
|
161
|
+
|
|
162
|
+
**Transport.** Within MAJOR version 1 the connection is Penpal 8 over `postMessage`, with the method names of sections 5 and 7. Implementations may update Penpal (including security fixes) only to versions that speak the same wire protocol: an older host must still connect to a newer plugin and the other way round. Penpal 8 needs `Promise.withResolvers` and `Object.hasOwn`; on older browsers the host and the SDK add them before connecting.
|
|
163
|
+
|
|
164
|
+
1. The host creates the iframe and starts listening.
|
|
165
|
+
2. The plugin (SDK) connects and calls `hello(handshake)` within **10 s**.
|
|
166
|
+
3. The host validates the handshake and returns an `InitPayload` or rejects with a `ProtocolError`.
|
|
167
|
+
4. The plugin renders and calls `ready()`. Until then the host shows a loading state.
|
|
168
|
+
|
|
169
|
+
### 4.1 `Handshake` (plugin → host)
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
interface Handshake {
|
|
173
|
+
protocol: string; // version the SDK speaks, "1.0"
|
|
174
|
+
sdk: { name: string; version: string }; // e.g. { name: "@memizy/plugin-sdk", version: "1.0.3" }
|
|
175
|
+
plugin: { id: string; version: string }; // from the manifest
|
|
176
|
+
features: string[]; // optional capabilities of the SDK, see 8.3
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 4.2 `InitPayload` (host → plugin)
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
interface InitPayload {
|
|
184
|
+
protocol: string; // negotiated version: same MAJOR, MIN of the MINORs
|
|
185
|
+
host: { name: string; version: string };
|
|
186
|
+
oqseVersion: string; // "1.0"
|
|
187
|
+
features: string[]; // host features, see 8.3
|
|
188
|
+
|
|
189
|
+
session: {
|
|
190
|
+
id: string;
|
|
191
|
+
mode: 'solo' | 'multiplayer';
|
|
192
|
+
hostAs: 'presenter' | 'player' | null; // null in solo
|
|
193
|
+
view: 'solo' | 'board' | 'controller' | 'settings';
|
|
194
|
+
self: Address; // this instance
|
|
195
|
+
authority: Address; // who owns the game state
|
|
196
|
+
lateJoin: boolean; // this instance joined a running game
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
players: Player[]; // current roster (presenter is not a player)
|
|
200
|
+
set: {
|
|
201
|
+
meta: OQSEMeta;
|
|
202
|
+
items: OQSEAnyItem[]; // only types the plugin declared, in display order (4.4); public items for non-authority instances in multiplayer
|
|
203
|
+
};
|
|
204
|
+
settings: Record<string, unknown>; // current values for every declared setting (defaults applied)
|
|
205
|
+
config: { locale: string; theme: 'light' | 'dark' };
|
|
206
|
+
clock: SessionClock; // session clock and pauses (below)
|
|
207
|
+
services: string[]; // services declared by the plugin AND offered by this host
|
|
208
|
+
progress: Record<string, ProgressRecord>; // learning progress of `self` (empty for board/settings)
|
|
209
|
+
data: { plugin: unknown; set: unknown }; // plugin data of `self` (null when nothing saved; null for board/settings)
|
|
210
|
+
snapshot: unknown | null; // last saved snapshot, only for the authority (resume)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
type Address = string; // player ID, "board", "settings"; "server" is reserved
|
|
214
|
+
|
|
215
|
+
interface SessionClock {
|
|
216
|
+
offsetMs: number; // Date.now() + offsetMs = session time
|
|
217
|
+
pausedMs: number; // total of finished pauses
|
|
218
|
+
pausedAt: number | null; // session time when the current pause began (null = running)
|
|
219
|
+
}
|
|
220
|
+
// Game time = (pausedAt ?? session time) - pausedMs. The SDK uses game time for ctx.now,
|
|
221
|
+
// timers and countdowns, so a pause stops them everywhere and nothing jumps afterwards.
|
|
222
|
+
|
|
223
|
+
interface Player {
|
|
224
|
+
id: string;
|
|
225
|
+
name: string;
|
|
226
|
+
isHost: boolean;
|
|
227
|
+
connected: boolean;
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Assets:** every `MediaObject.value` in `set` is either an absolute `https:` URL, or a host-relative key that the SDK resolves with `getAsset` (section 5). The host never passes `blob:` URLs, because an opaque-origin iframe cannot load the host's blob URLs.
|
|
232
|
+
|
|
233
|
+
### 4.3 Version negotiation
|
|
234
|
+
|
|
235
|
+
* A host supports a set of protocol versions, one per MAJOR version (e.g. `1.4` and `2.1`). The plugin's MAJOR version must be one of them. Otherwise the host rejects with `ProtocolError { code: 'UNSUPPORTED_PROTOCOL' }` and shows the user a clear message ("this plugin needs a newer Memizy" or "this plugin is too old").
|
|
236
|
+
* The negotiated version has the plugin's MAJOR and the lower of both MINOR versions. Neither side uses a method, field or feature introduced in a higher minor than the negotiated one.
|
|
237
|
+
* A plugin that uses a field, value, method or setting type introduced in protocol 1.*x* declares `protocol: "1.x"` in its manifest (the minimum version it needs). A host that supports only an older minor version then rejects it before loading, instead of misreading what it does not know.
|
|
238
|
+
* `manifest.appSpecific.memizy.protocol` is checked **before** loading the iframe (the host does not offer plugins it cannot run). The manifest itself (`version`, the OQSEM format) follows the [OQSE version rules](https://github.com/memizy/memizy/blob/main/packages/oqse/oqse.md#versioning-and-compatibility): the host reads the OQSEM MAJOR versions it supports.
|
|
239
|
+
* A host SHOULD keep supporting older MAJOR versions as long as practical (e.g. an adapter in `@memizy/host-sdk`), so older plugins keep working.
|
|
240
|
+
|
|
241
|
+
### 4.4 Display order and hidden answers
|
|
242
|
+
|
|
243
|
+
The host prepares the set once per session (`prepareDisplaySet` in `@memizy/protocol`; a future server authority does the same):
|
|
244
|
+
|
|
245
|
+
1. **Display order.** With a seed (normally the session id), the host shuffles `options` (mcq, unless `shuffle: false`), the options of `fill-in-select` blanks (unless `shuffle: false`), `items` of `sort-items` (never left in the correct order), `prompts` and `matches` of `match-pairs` and `match-complex`, `events` of `timeline` (unless `shuffle: false`; never left in chronological order), the entries of `categorize` and the `labels` of `diagram-label` (unless `requireTyping`). OQSE answers refer to choice IDs, so shuffling never changes an answer. Every instance sees the same order.
|
|
246
|
+
2. **Public items.** In multiplayer, every instance except the authority gets the items without the fields that give the answer away and with `answerHidden: true`: `explanation`, `incorrectFeedback`, the `explanation` of every choice, `correctId`, `correctIds`, `correctAnswer(s)`, `correctOrder`, `pairs`, `connections`, `correctCells`, `correctCategoryId`, zone `correctLabelId` (and the labels when `requireTyping`), the accepted texts of `fill-in-blanks`, the `correctId` of select blanks, event `date`s, `hotspots` of pin items, `back` of a flashcard, `hiddenContent` of a note, `sampleAnswer`, `rubric` and the `quote` of every source (`sources[].quote`, which often states the answer). Custom `x-` types lose `solution` (OQSE puts everything that gives their answer away there), `explanation` and `incorrectFeedback`, and also get `answerHidden: true`. `hints` stay public: a game may show them on any device while the player answers. A host that sends the game to other devices sends only public items.
|
|
247
|
+
3. **Revealing.** The authority sends full items to the other instances in its own messages (the plugin SDK's `ctx.reveal`); the host does not take part.
|
|
248
|
+
|
|
249
|
+
Solo instances and the authority get the full items in display order.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## 5. Host API (plugin → host)
|
|
254
|
+
|
|
255
|
+
All methods return Promises and reject with a `ProtocolError` (section 8.2). Methods marked *authority* reject with `NOT_AUTHORITY` when called by another instance; methods limited to some views reject with `NOT_ALLOWED_IN_VIEW` elsewhere.
|
|
256
|
+
|
|
257
|
+
| Method | Description |
|
|
258
|
+
| :--- | :--- |
|
|
259
|
+
| `hello(handshake): InitPayload` | Handshake (section 4). Called exactly once per instance; a second call rejects with `INVALID_ARGUMENT`. |
|
|
260
|
+
| `ready(): void` | The plugin is rendered and interactive. |
|
|
261
|
+
| `send(message: OutgoingMessage): void` | Sends a game message to other instances (section 6). |
|
|
262
|
+
| `saveSnapshot(snapshot: unknown): void` | *authority.* Stores the latest resumable game state (overwrites the previous one). Returned as `InitPayload.snapshot` when the authority instance is recreated (reload, crash, reconnect). After `end(result)` the host discards the snapshot: a finished game is not resumed. |
|
|
263
|
+
| `recordAnswer(answer: AnswerRecord): void` | Records a learning result. The host applies its learning algorithm and stores the result in the learning progress of the given player (routing it to that player's device in multiplayer). |
|
|
264
|
+
| `saveData(scope: 'plugin' \| 'set', value: unknown): void` | *views `solo`, `controller`.* Replaces the plugin data of `self` in the given scope (section 5.2). |
|
|
265
|
+
| `updateSettings(update: { values: Record<string, unknown>; valid: boolean; message?: string }): void` | *view `settings`.* Replaces the settings values. The host validates them against the manifest schema; `valid: false` disables Start and shows `message`. |
|
|
266
|
+
| `getAsset(key: string, itemId?: string): Blob` | Raw data of an asset (resolution: item assets, then set assets). |
|
|
267
|
+
| `end(result: SessionResult): void` | *authority.* The game is over. The host shows/stores the result; instances stay open until the host closes them. Only the first call counts: later calls (also from a recreated authority) resolve and are ignored. |
|
|
268
|
+
| `reportError(error: { code: string; message: string; context?: object }): void` | Non-fatal error for logs and the Lab test report. Reports beyond the rate in section 8.1 are dropped without an error. |
|
|
269
|
+
| `service(name: string, payload: unknown): unknown` | Calls a Memizy service (section 5.3). Rejects with `SERVICE_UNAVAILABLE` unless the plugin declares it and `InitPayload.services` lists it. |
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
interface AnswerRecord {
|
|
273
|
+
playerId?: string; // default: self. The authority may record for any player.
|
|
274
|
+
itemId: string;
|
|
275
|
+
isCorrect: boolean;
|
|
276
|
+
confidence?: 1 | 2 | 3 | 4;
|
|
277
|
+
timeSpentMs?: number;
|
|
278
|
+
hintsUsed?: number;
|
|
279
|
+
isSkipped?: boolean;
|
|
280
|
+
answer?: unknown; // what the player answered (checkAnswer format, choice IDs; max 4 KB)
|
|
281
|
+
item?: { id: string; type: string; [key: string]: unknown }; // required for an item that is not in the set
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface SessionResult {
|
|
285
|
+
scores?: Record<string, number>; // final score per player ID (in solo: the single player)
|
|
286
|
+
summary?: string; // short plain-text summary shown by the host
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### 5.1 Learning progress
|
|
291
|
+
|
|
292
|
+
Plugins write it with **`recordAnswer`**: the plugin reports what happened ("player X answered item Y correctly"); the host decides the new bucket and review date with its own algorithm. A self-rating (a flashcard) is an answer too (OQSE Answer Formats). Writing progress records directly is reserved (`saveProgress`, section 10).
|
|
293
|
+
|
|
294
|
+
What `recordAnswer` carries besides the result:
|
|
295
|
+
|
|
296
|
+
* `answer` – what the player answered, in the format of the item type defined in [OQSE Answer Formats](https://github.com/memizy/memizy/blob/main/packages/oqse/oqse.md#answer-formats) (the format `checkAnswer` takes: choice IDs, text, numbers; a flashcard's self-rating 1–4). Teachers see which wrong answers were chosen; because answers use IDs, the statistics survive edits of the set.
|
|
297
|
+
* **Generated items** (from a service or a generator in the plugin) are not in the set. The plugin attaches the item (`item`, at least `id` and `type`; answer fields may be left out; max 32 KB). The host counts such answers in statistics (per `skills` and `tags`) but not in the repetition schedule, because the item never comes back.
|
|
298
|
+
* Progress for teachers is aggregated per OQSE `skills` (and their parents, `chess.tactics` for `chess.tactics.fork`), which come from the content, never from the plugin. A game's own model of the player (rating, level, unlocked chapters) belongs to plugin data (section 5.2).
|
|
299
|
+
|
|
300
|
+
### 5.2 Plugin data
|
|
301
|
+
|
|
302
|
+
Game progress that only the plugin understands (unlocked levels, coins, best scores, a chosen avatar) MUST NOT be stored in the set (`customData`, `appSpecific`) – the set is shared content, not the state of a player. Plugins store it as **plugin data** instead:
|
|
303
|
+
|
|
304
|
+
* It belongs to **one user and one plugin** (identified by the manifest `id`). Two scopes:
|
|
305
|
+
* `plugin` – across all sets (e.g., unlocked characters, total coins),
|
|
306
|
+
* `set` – for this plugin and the current set (e.g., the level reached in this set).
|
|
307
|
+
* Each value is one JSON document, replaced as a whole by `saveData(scope, value)`. The current values are in `InitPayload.data`; `null` means nothing has been saved yet.
|
|
308
|
+
* Only the player's own instance (`solo`, `controller`) reads and writes it; the board and the settings view have none. In multiplayer every controller stores the data of its own player on its own device.
|
|
309
|
+
* The host stores it locally and MAY synchronize or back it up with the user's account. It does not interpret it. Users can in principle edit their own data; plugins must not treat it as tamper-proof.
|
|
310
|
+
|
|
311
|
+
### 5.3 Services
|
|
312
|
+
|
|
313
|
+
Services are what a set cannot contain: computations and data on Memizy's side, e.g. an AI chat, text-to-speech, a translation, a leaderboard across classes, or "a chess puzzle for my level" from a database. **Content stays in sets**; services are for everything that must be computed or fetched at play time.
|
|
314
|
+
|
|
315
|
+
* The plugin lists the services it uses in the manifest (`services`). The host offers some services; `InitPayload.services` lists those both sides have. The SDK exposes `ui.service(name, payload)`.
|
|
316
|
+
* Requests and results are JSON. Requests are at most 64 KB, results at most 256 KB; a host accepts at least 30 calls per minute and instance (`RATE_LIMITED` beyond).
|
|
317
|
+
* The host calls the service on behalf of the player (it knows the account, the class and the limits). The plugin never holds keys and the data does not leave Memizy, so no `permissions.network` is needed for services.
|
|
318
|
+
* Service names and their request/result schemas are not part of this protocol: they are listed in the Memizy service registry. A new service needs no protocol change.
|
|
319
|
+
* A service that returns study items returns OQSE items (e.g. `chess-puzzle`), so plugins render and check them like items of a set, and record answers to them as generated items (section 5.1).
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## 6. Messages and Routing
|
|
324
|
+
|
|
325
|
+
Game data travels as **messages** through the host. In solo there is nobody else to talk to; the SDK runs the game locally.
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
interface OutgoingMessage {
|
|
329
|
+
to: 'authority' | 'all' | Address[]; // 'all' = every other instance
|
|
330
|
+
data: unknown; // JSON-serializable
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface IncomingMessage {
|
|
334
|
+
from: Address;
|
|
335
|
+
data: unknown;
|
|
336
|
+
sentAt: number; // session clock (ms)
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Guarantees:
|
|
341
|
+
|
|
342
|
+
* Messages from one sender to one recipient arrive **in order** and **at most once**.
|
|
343
|
+
* Messages to a disconnected instance are **dropped**. After reconnecting, the instance is a new iframe with a new `InitPayload` and must re-synchronize (the SDK asks the authority for the current state).
|
|
344
|
+
* The host does not interpret `data`. It validates the size and that the value is JSON.
|
|
345
|
+
|
|
346
|
+
### 6.1 Authority unavailable
|
|
347
|
+
|
|
348
|
+
If the authority instance loses its connection or is being recreated (e.g., the host who plays along loses Wi-Fi, the teacher reloads the board):
|
|
349
|
+
|
|
350
|
+
* The host calls `authorityChanged({ connected: false })` on all other instances and shows them a "Waiting for the host…" overlay. Messages sent `to: 'authority'` meanwhile are rejected with `AUTHORITY_UNAVAILABLE` (the SDK does not queue them).
|
|
351
|
+
* When the authority is back (resumed from its snapshot), the host calls `authorityChanged({ connected: true })`; the other instances re-synchronize with it.
|
|
352
|
+
* If the authority does not come back within a host-defined time, the host ends the session (`sessionEnded('host-left')`). Handing the game over to another player is a future feature (section 10).
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## 7. Plugin API (host → plugin)
|
|
357
|
+
|
|
358
|
+
| Method | Description |
|
|
359
|
+
| :--- | :--- |
|
|
360
|
+
| `start(): void` | *authority only.* Begin the game (after the countdown in multiplayer, right after `ready()` in solo). Not called when resuming from a snapshot. |
|
|
361
|
+
| `deliver(message: IncomingMessage): void` | A game message from another instance. |
|
|
362
|
+
| `playersChanged(players: Player[]): void` | The roster changed (join, leave, rename, connection status). Always the full list. |
|
|
363
|
+
| `authorityChanged(status: { connected: boolean }): void` | The authority became unreachable / reachable again (section 6.1). Not called on the authority itself. |
|
|
364
|
+
| `configChanged(config: { locale, theme }): void` | Locale or theme changed. |
|
|
365
|
+
| `clockChanged(clock: SessionClock): void` | Clock synchronization updated, or the host paused / resumed the game. While `pausedAt` is set the SDK stops game time and timers, ignores actions and covers the game with a "paused" curtain. |
|
|
366
|
+
| `sessionEnded(reason: 'finished' \| 'host-left' \| 'kicked' \| 'closed' \| 'error'): void` | The host is closing this instance. |
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## 8. Limits, Errors, Features, Security
|
|
371
|
+
|
|
372
|
+
### 8.1 Limits
|
|
373
|
+
|
|
374
|
+
The values are **guaranteed minimums**: a host MUST accept at least this much and MAY allow more. Plugins and the SDK MUST stay within them. To respect the message rate, the SDK sends state updates in batches (at most ~20 per second), so that many simultaneous actions (e.g., 40 players answering in the same second) do not exceed it.
|
|
375
|
+
|
|
376
|
+
| Limit | Guaranteed minimum | When exceeded |
|
|
377
|
+
| :--- | :--- | :--- |
|
|
378
|
+
| `send` message size (serialized JSON) | 64 KB | `MESSAGE_TOO_LARGE` |
|
|
379
|
+
| `send` rate per instance | 30 messages/s (burst 60); the authority: 30 messages/s **per other instance** (it sends each device its own view) | `RATE_LIMITED` |
|
|
380
|
+
| `saveSnapshot` size | 1 MB | `SNAPSHOT_TOO_LARGE` |
|
|
381
|
+
| `saveSnapshot` rate | 2 per second (the host keeps the latest) | coalesced, no error |
|
|
382
|
+
| `saveData` size per scope | 256 KB | `DATA_TOO_LARGE` |
|
|
383
|
+
| `saveData` rate | 1 per second per scope (the host keeps the latest) | coalesced, no error |
|
|
384
|
+
| `hello` deadline | 10 s after iframe load | the host shows an error |
|
|
385
|
+
| `recordAnswer` answer / generated item | 4 KB / 32 KB | `DATA_TOO_LARGE` |
|
|
386
|
+
| `recordAnswer` rate per instance | 10 per second (burst 20); the authority: 10 per second **per player** (it records for everyone) | `RATE_LIMITED` |
|
|
387
|
+
| `getAsset` size | 2 MB per asset | `DATA_TOO_LARGE` |
|
|
388
|
+
| `getAsset` rate per instance | 10 per second (burst 20) | `RATE_LIMITED` |
|
|
389
|
+
| `reportError` rate per instance | 5 per second (burst 10) | dropped, no error |
|
|
390
|
+
| `service` request / result | 64 KB / 256 KB | `MESSAGE_TOO_LARGE` |
|
|
391
|
+
| `service` rate per instance | 30 calls/min | `RATE_LIMITED` |
|
|
392
|
+
|
|
393
|
+
### 8.2 Errors
|
|
394
|
+
|
|
395
|
+
Rejected calls carry a `ProtocolError`:
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
interface ProtocolError {
|
|
399
|
+
code: ProtocolErrorCode;
|
|
400
|
+
message: string; // human-readable, for logs and the Lab report
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
| Code | Meaning |
|
|
405
|
+
| :--- | :--- |
|
|
406
|
+
| `UNSUPPORTED_PROTOCOL` | Incompatible MAJOR version (handshake). |
|
|
407
|
+
| `INVALID_ARGUMENT` | The call does not match the schema in `@memizy/protocol` (wrong type, unknown setting, non-JSON value…). |
|
|
408
|
+
| `NOT_AUTHORITY` | An *authority* method was called by another instance. |
|
|
409
|
+
| `NOT_ALLOWED_IN_VIEW` | The method is not available in this view (e.g. `saveData` on the board). |
|
|
410
|
+
| `AUTHORITY_UNAVAILABLE` | A message to the authority while it is unreachable (section 6.1). |
|
|
411
|
+
| `MESSAGE_TOO_LARGE`, `SNAPSHOT_TOO_LARGE`, `DATA_TOO_LARGE` | Size limits (section 8.1). |
|
|
412
|
+
| `RATE_LIMITED` | Message rate limit (section 8.1). |
|
|
413
|
+
| `ASSET_NOT_FOUND` | `getAsset` with an unknown key. |
|
|
414
|
+
| `SESSION_ENDED` | A call after `sessionEnded`. |
|
|
415
|
+
| `INTERNAL_ERROR` | A host-side failure; the plugin may retry. |
|
|
416
|
+
| `SERVICE_UNAVAILABLE` | The service is not declared by the plugin or not offered by this host. |
|
|
417
|
+
| `UNKNOWN_METHOD` | The other side does not have the method (an older or newer version). Methods added in 1.x are guarded by a feature (9), so a plugin sees this only by mistake. |
|
|
418
|
+
|
|
419
|
+
New codes may be added in minor versions; plugins MUST treat unknown codes like `INTERNAL_ERROR`.
|
|
420
|
+
|
|
421
|
+
### 8.3 Features
|
|
422
|
+
|
|
423
|
+
`Handshake.features` and `InitPayload.features` list optional capabilities. A side may only use a feature both sides list. v1.0 defines none; future examples: `teams`, `edit-set`.
|
|
424
|
+
|
|
425
|
+
### 8.4 Security
|
|
426
|
+
|
|
427
|
+
* The iframe is sandboxed with `allow-scripts allow-forms allow-modals allow-pointer-lock allow-popups allow-popups-to-escape-sandbox` – **never** `allow-same-origin` and never `allow-top-navigation`. Popups are allowed so that links open in a new tab (the plugin SDK opens every external link there; inside the iframe a link would replace the game). As a consequence, browser storage (`localStorage`, IndexedDB) is not available to plugins; they use plugin data (section 5.2).
|
|
428
|
+
* The host validates every incoming call against the schemas in `@memizy/protocol` and the limits above.
|
|
429
|
+
* The host never passes credentials, user e-mail or other private data. `Player.name` is the display name chosen for the session.
|
|
430
|
+
* Answers are known only to the authority (section 4.4). Everything else a plugin puts in the game state or in messages is visible to every instance: do not put secrets there.
|
|
431
|
+
* **Network.** The host puts a Content Security Policy into the plugin document (`pluginContentPolicy` in `@memizy/protocol`): scripts only inline, from the library CDNs (`cdn.jsdelivr.net`, `unpkg.com`, `cdnjs.cloudflare.com`, `esm.sh`) and from the host; `fetch`, WebSocket and similar only to these and to the origins in `permissions.network`. Images and media may come from any `https:` URL (sets link them), so the policy blocks data channels but not every possible leak; hosts show the declared origins to users ("this game talks to lichess.org").
|
|
432
|
+
* **Devices.** The iframe `allow` attribute grants only `permissions.devices`; the browser still asks the user.
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## 9. Compatibility Rules (what is frozen)
|
|
437
|
+
|
|
438
|
+
From `1.0` on, the following MUST NOT change in a backward-incompatible way within MAJOR `1`:
|
|
439
|
+
|
|
440
|
+
* method names, parameters and return types in sections 5 and 7,
|
|
441
|
+
* the shapes of `Handshake`, `InitPayload`, `Player`, `AnswerRecord`, `SessionResult`, `ProtocolError`, messages,
|
|
442
|
+
* the manifest schema in section 2,
|
|
443
|
+
* the semantics of views, authority, routing, learning progress and plugin data,
|
|
444
|
+
* the limits in section 8.1 may only grow.
|
|
445
|
+
|
|
446
|
+
Allowed in `1.x`: new optional fields, new methods guarded by a feature, new setting types, new enum values only where the receiver is required to ignore unknown values (`sessionEnded` reasons, error codes, `Player` fields).
|
|
447
|
+
|
|
448
|
+
* The plugin SDK sends its own game messages inside `send` / `deliver` (actions, state and state patches, reveals). Their format is part of the SDK's contract: within SDK 1.x it only changes additively, because the devices of one game may run different 1.x versions (a cached copy). Receivers ignore message types and fields they do not know. State patches carry the version of their format (`f`, today `1`: the patches of mutative 2); a receiver that does not know the format asks for the full state instead of applying them, so the patch format can change within 1.x.
|
|
449
|
+
* **Reserved names** (so that a game never collides with a later SDK): action names and timer keys starting with `$` (the SDK rejects them in a game); HTML attributes starting with `data-mz-` (new SDK attributes use this prefix; the existing `data-act`, `data-local`, `data-payload`, `data-setting`, `data-keep`, `data-key` stay); CSS classes starting with `mz-`. Fields the SDK keeps in the game state exist only on request: today `phase`, `phaseSeq` and `phaseEndsAt`, only in games with `phases`. Any later field of the SDK will also be opt-in (a new option of the game definition), never added to the state of an existing game.
|
|
450
|
+
* The public API of `@memizy/protocol` is what its entry point exports; `@memizy/protocol/internal` (helpers shared by the Memizy packages) is not covered by these rules. Exported Zod schemas are Zod 4 schemas: the 1.x packages stay on Zod 4.
|
|
451
|
+
* Libraries inside the SDK and the host SDK (sanitizer, Markdown parser…) may be updated in any 1.x release, including security fixes; only the wire formats above are fixed.
|
|
452
|
+
* Plugins load the SDK as `https://cdn.jsdelivr.net/npm/@memizy/plugin-sdk@1` (the self-contained bundle of the package): every 1.x release reaches them without a change of the plugin.
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## 10. Decisions and Planned Extensions
|
|
457
|
+
|
|
458
|
+
What was decided and why, and what is **reserved** (name and intent fixed, not implemented yet, so that later work does not collide).
|
|
459
|
+
|
|
460
|
+
**Decided**
|
|
461
|
+
|
|
462
|
+
1. **Graded tests and assignments are not plugins.** Whoever runs the rules can change them, and in a homework a plugin runs on the student's device. Graded work lives in the platform, which checks answers on the server (the public items of section 4.4 make that possible). Plugins are for learning through play and for practice, where cheating only hurts the cheater.
|
|
463
|
+
2. **Content is sets, computation is services.** Sets hold what can be prepared in advance; services (section 5.3) compute or fetch at play time. Memizy services go through `service()`; third parties through `permissions.network`.
|
|
464
|
+
3. **Shared data of a class is a service**, not a new data scope: every write is a request the server can validate (a plugin on a student's device is not trusted).
|
|
465
|
+
4. **"Always new" exercises come from generators** that return ordinary OQSE items (in the plugin with `ctx.random()`, or as a service with a database or AI behind it). Everything else – rendering, `checkAnswer`, recording – works the same; answers are recorded as generated items.
|
|
466
|
+
5. **Progress for teachers** = `recordAnswer` + OQSE `skills` from the content (hierarchical IDs from a shared vocabulary); a game's own model of the player stays in plugin data.
|
|
467
|
+
6. **Choices have IDs** (OQSE): answers and statistics survive edits of a set; the host shuffles freely.
|
|
468
|
+
7. **`playerView`** (per-player views of the state) is an optional SDK feature, not a protocol requirement: games without secrets need nothing.
|
|
469
|
+
|
|
470
|
+
**Reserved**
|
|
471
|
+
|
|
472
|
+
| Name | Intent |
|
|
473
|
+
| :--- | :--- |
|
|
474
|
+
| Address `"server"` | Server authority: official games run their rules on a server (graded homework, larger games). The display order and public items of section 4.4 already work the same there. |
|
|
475
|
+
| Item field `generated: { by, seed }` | Where a generated item comes from (service or generator) and how to recreate it. |
|
|
476
|
+
| Host method `uploadAsset` | Files created by players (drawings, photos) stored by the host and referenced like set assets. |
|
|
477
|
+
| Feature `edit-set` | Tools that create or change a set from inside a plugin. |
|
|
478
|
+
| `Player.active` | Whether the player's app is in the foreground (for supervised tests in the platform). |
|
|
479
|
+
| Plugin data summary | A short, human-readable summary of a game's own progress (e.g. "Elo 1450") that hosts may show to teachers. |
|
|
480
|
+
| Feature `teams` | Teams managed by the host (today every game handles teams itself). |
|
|
481
|
+
| Hot-seat | Several players sharing one device; v1 assumes one player per instance. |
|
|
482
|
+
| Host migration | Moving the authority to another device when its device is gone for good; v1 resumes the same authority from the snapshot. |
|
|
483
|
+
| Host method `exit` | Leaving the game from inside it: a "back to the menu" button of a full-screen game; the host closes the game or returns to its menu. |
|
|
484
|
+
| Host method `resize` | Auto height for games embedded in articles and course pages: the game reports the height of its content, the host sizes the frame (switched on in the manifest; the SDK measures it). |
|
|
485
|
+
| Plugin method `setChanged` | Live set editing: the host updates the set of a running game, e.g. when the author edits it in the Lab. |
|
|
486
|
+
| Host method `saveProgress` | Writing learning progress directly, e.g. a placement test ("these items are known"); the host may refuse or limit it. |
|
|
487
|
+
| Feature `replay` | Recording a game and replaying it: the host keeps the session's inputs (the `InitPayload` with the seed and the display order, every action with its sender and game time, pauses) and replays the game by running the same plugin version on them. This works because the rules are deterministic (`ctx.random`, `ctx.now`, no `Math.random` / `Date.now` in actions); keep that rule. |
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Changelog
|
|
492
|
+
|
|
493
|
+
* **1.0 (2026-10-14):** the term *host* (3.1): the application that embeds the plugin on a device, also on a player's device; the host ends a player's instance with `sessionEnded` (also `kicked`, `host-left`) before removing it. Removed before 1.0 and reserved (10): `exit`, `resize`, `setChanged`, `saveProgress` (a self-rating is an answer). `InitPayload.services` and the pause fields of `SessionClock` are always present; `sessionEnded` reason `host-left` (was `host_left`); `SETTINGS_ADDRESS` is defined here; the relay type of a room player is `RoomPlayer`. State patches carry their format (`f`); reserved names `$…`, `data-mz-…`, `mz-…` and opt-in state fields; `@memizy/protocol/internal`; Zod 4 (9). Renamed before 1.0: `ui.renderNote` → `ui.note` (SDK), the type `Theme` → `HostConfig`. Player names and personal data: plugins MUST NOT ask for real names or other personal data (3.3); `setChanged` is optional for hosts (7); `replay` reserved (10). The sandbox allows popups that escape it, links open in a new tab (8.4). Error code `UNKNOWN_METHOD` (8.2). Continuing a saved game is described (3.3; host feature, no protocol change). After `end(result)` the host discards the snapshot (5). The transport (Penpal 8), the SDK message format (actions carry the `phaseSeq` the player saw and the tap time `actedAt`; devices of one game may run different SDK 1.x versions) and the protocol declaration of plugins using 1.x additions are fixed rules (4, 4.3, 9); plugins load the SDK from `…/plugin-sdk@1`. `appSpecific.memizy.set` (`required` / `optional` / `none`): games without questions; OQSEM `capabilities` and `actions` may be omitted. Public items (4.4) also lose `sources[].quote`; custom `x-` items lose `solution`, `explanation` and `incorrectFeedback` and get `answerHidden: true` (they were passed unchanged); `hints` stay public. `recordAnswer.answer` follows OQSE Answer Formats (a flashcard answer is a self-rating 1–4; `ui.question` offers four ratings). OQSE 1.0 sets and OQSEM 1.0 manifests (`"version": "1.0"`; other MAJOR versions are not read as 1.x). A host may support several protocol MAJOR versions at once (4.3); supporting older ones is a SHOULD instead of a MUST. Final rules: a second `hello` of an instance rejects with `INVALID_ARGUMENT`, only the first `end(result)` counts (5); guaranteed rates of `recordAnswer` (per player for the authority), `getAsset` and `reportError` (dropped beyond it) and an asset size of 2 MB (8.1); the address `"settings"` (3.1).
|
|
494
|
+
* **RC1 (2026-10-04):** first release candidate.
|
|
495
|
+
* **RC2 (2026-10-06):** `SettingDefinition.modes`; the host may show solo settings before the start (section 3.3). Additive: RC1 plugins and hosts keep working.
|
|
496
|
+
* **RC4 (2026-10-07):** OQSE 0.3 sets (choices with IDs; the RC3 `correctOrder` / `correctMatches` display fields are gone because answers refer to IDs); `recordAnswer.answer` and generated items (5.1); services (5.3, `service`, `InitPayload.services`, `SERVICE_UNAVAILABLE`); pauses (`SessionClock`); `permissions` with a Content Security Policy and device grants (8.4); decisions and reservations (section 10). Additive for the protocol except the OQSE version: plugins must read choices (`option.text`, `option.id`).
|
|
497
|
+
* **RC3 (2026-10-06):** display order and public items (section 4.4). Not fully additive: a plugin that read answers on a player's device in multiplayer must now reveal them first (`ctx.reveal` in the SDK). Answers by index refer to the delivered lists; `checkAnswer` handles `correctOrder` / `correctMatches`, so plugins that shuffled locally and sent the indices of the delivered lists keep working.
|
package/dist/errors.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=[`UNSUPPORTED_PROTOCOL`,`INVALID_ARGUMENT`,`NOT_AUTHORITY`,`NOT_ALLOWED_IN_VIEW`,`AUTHORITY_UNAVAILABLE`,`MESSAGE_TOO_LARGE`,`SNAPSHOT_TOO_LARGE`,`DATA_TOO_LARGE`,`RATE_LIMITED`,`ASSET_NOT_FOUND`,`SESSION_ENDED`,`INTERNAL_ERROR`,`SERVICE_UNAVAILABLE`,`UNKNOWN_METHOD`],t=/^\[([A-Z_]+)\]\s?/,n=class extends Error{code;constructor(e,t){super(`[${e}] ${t}`),this.name=`ProtocolError`,this.code=e}get detail(){return this.message.replace(t,``)}};function r(t){return typeof t==`string`&&e.includes(t)}function i(e){if(e instanceof n)return e;let i=e instanceof Error?e.message:String(e);if(e?.code===`METHOD_NOT_FOUND`)return new n(`UNKNOWN_METHOD`,i);let a=t.exec(i);return new n(a&&r(a[1])?a[1]:`INTERNAL_ERROR`,a?i.slice(a[0].length):i)}exports.PROTOCOL_ERROR_CODES=e,exports.ProtocolError=n,exports.isProtocolErrorCode=r,exports.toProtocolError=i;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
var e = [
|
|
3
|
+
"UNSUPPORTED_PROTOCOL",
|
|
4
|
+
"INVALID_ARGUMENT",
|
|
5
|
+
"NOT_AUTHORITY",
|
|
6
|
+
"NOT_ALLOWED_IN_VIEW",
|
|
7
|
+
"AUTHORITY_UNAVAILABLE",
|
|
8
|
+
"MESSAGE_TOO_LARGE",
|
|
9
|
+
"SNAPSHOT_TOO_LARGE",
|
|
10
|
+
"DATA_TOO_LARGE",
|
|
11
|
+
"RATE_LIMITED",
|
|
12
|
+
"ASSET_NOT_FOUND",
|
|
13
|
+
"SESSION_ENDED",
|
|
14
|
+
"INTERNAL_ERROR",
|
|
15
|
+
"SERVICE_UNAVAILABLE",
|
|
16
|
+
"UNKNOWN_METHOD"
|
|
17
|
+
], t = /^\[([A-Z_]+)\]\s?/, n = class extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(e, t) {
|
|
20
|
+
super(`[${e}] ${t}`), this.name = "ProtocolError", this.code = e;
|
|
21
|
+
}
|
|
22
|
+
get detail() {
|
|
23
|
+
return this.message.replace(t, "");
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
function r(t) {
|
|
27
|
+
return typeof t == "string" && e.includes(t);
|
|
28
|
+
}
|
|
29
|
+
function i(e) {
|
|
30
|
+
if (e instanceof n) return e;
|
|
31
|
+
let i = e instanceof Error ? e.message : String(e);
|
|
32
|
+
if (e?.code === "METHOD_NOT_FOUND") return new n("UNKNOWN_METHOD", i);
|
|
33
|
+
let a = t.exec(i);
|
|
34
|
+
return new n(a && r(a[1]) ? a[1] : "INTERNAL_ERROR", a ? i.slice(a[0].length) : i);
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { e as PROTOCOL_ERROR_CODES, n as ProtocolError, r as isProtocolErrorCode, i as toProtocolError };
|