anear-js-api 2.3.0 → 2.3.2

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/README.md CHANGED
@@ -77,7 +77,7 @@ The AppM developer is responsible for everything related to the specific event's
77
77
 
78
78
  - **Game/Event Logic:** Controls the flow, rules, and state of the interactive event.
79
79
  - **User Experience (UX/UI):** Defines what the user sees at every stage via `meta` display properties in its state nodes. All display content is the AppM's responsibility.
80
- - **Decision Maker:** The AppM is the decision-maker. When the JSAPI notifies it of an event (e.g., "a participant timed out"), the AppM decides what to do. Should the game end? Should the participant be removed? Should the state change? That is application-level logic.
80
+ - **Decision Maker:** The AppM is the decision-maker. When the JSAPI notifies it of an event (e.g., an `ACTION`/`ACTIONS_TIMEOUT`), the AppM decides what to do. Should the game end? Should the participant be removed? Should the state change? That is application-level logic.
81
81
 
82
82
  This strict separation allows AppM developers to focus entirely on their application logic without needing to manage the complexities of real-time infrastructure.
83
83
 
@@ -169,6 +169,36 @@ Your AppM state names are completely independent of these AEM states. You can st
169
169
  2. **Action Events**: Participant clicks → `actionsChannel` → App logic
170
170
  3. **Display Events**: App state transitions → `AppMachineTransition` → Channel rendering
171
171
 
172
+ ## Action Payload Tokenization
173
+
174
+ JSAPI tokenizes `anear-action` payloads for participant-private displays to prevent client-side payload inspection/tampering.
175
+
176
+ ### Render-time behavior
177
+
178
+ - App templates still use `action(...)` in Pug.
179
+ - For private participant renders, `DisplayEventProcessor` replaces action JSON with short opaque tokens.
180
+ - JSAPI stores an ephemeral lookup map per participant and render cycle:
181
+ - `participantActionLookup[participantId][token] = { APP_EVENT: payload }`
182
+ - The lookup map is replaced on each render cycle.
183
+
184
+ ### Action-ingest behavior (AEM)
185
+
186
+ When an `ACTION` is received on the actions channel:
187
+
188
+ 1. AEM tries token resolution from `participantActionLookup`.
189
+ 2. If token lookup fails, AEM accepts JSON payloads marked as trusted ABR client payloads (`__anearClient: true`, forms also include `__anearForm: true`).
190
+ 3. Any unrecognized/unmarked payload is dropped and not forwarded to AppM.
191
+
192
+ ### Forms
193
+
194
+ `anear-form` submissions remain JSON-based (they carry user-entered values). ABR marks form payloads with `__anearForm: true` and `__anearClient: true`; AEM strips these markers before forwarding payload to AppM.
195
+
196
+ ### Security model
197
+
198
+ - Tokenized button actions remove app-specific payload details from client-visible markup.
199
+ - Forged/replayed unknown tokens are rejected by default.
200
+ - AppM guards are still required for domain correctness (turn validity, ownership checks, ranges, etc.).
201
+
172
202
  ## Participant Presence Events
173
203
 
174
204
  The JSAPI handles participant lifecycle through presence events:
@@ -196,7 +226,7 @@ meta: {
196
226
  allParticipants: 'TemplateName', // String template name
197
227
  spectators: 'TemplateName', // String template name
198
228
 
199
- // Object format with timeout
229
+ // Object format (timeout metadata is legacy; prefer <anear-timeout> in PUG for participant-local UX)
200
230
  eachParticipant: {
201
231
  view: 'TemplateName',
202
232
  timeout: (appContext, participantId) => 30000,
@@ -231,21 +261,12 @@ PUG templates receive rich context including:
231
261
 
232
262
  ### Participant-local Timeouts
233
263
 
234
- Participant-local timeout UX is handled by browser runtime (`<anear-timeout>`). JSAPI does not emit `PARTICIPANT_TIMEOUT`.
264
+ Participant-local timeout UX is handled by browser runtime (`<anear-timeout>`). JSAPI does not emit `PARTICIPANT_TIMEOUT`; timeout-driven actions should arrive as normal `ACTION` payloads (for example `ACTION_TIMEOUT` if your app uses that event name).
235
265
 
236
- ```javascript
237
- meta: {
238
- eachParticipant: {
239
- view: 'PlayableGameBoard',
240
- timeout: (context, participant) => {
241
- // Only current player gets timeout
242
- return context.currentPlayerId === participant.info.id ? 30000 : null
243
- }
244
- }
245
- },
246
- on: {
247
- MOVE: 'nextTurn'
248
- }
266
+ ```pug
267
+ //- Active player's controls wrapped with browser-owned timeout UX
268
+ anear-timeout(duration='30000')
269
+ anear-action(payload=action("MOVE")) Move
249
270
  ```
250
271
 
251
272
  ### Group Timeouts
@@ -328,8 +349,8 @@ await anearEvent.startEvent() // Transition to live
328
349
  await anearEvent.closeEvent() // Transition to complete
329
350
  await anearEvent.cancelEvent() // Transition to cancelled
330
351
 
331
- // Participant timeout management
332
- anearEvent.cancelAllParticipantTimeouts() // Cancel all active timeouts
352
+ // allParticipants timeout management
353
+ anearEvent.cancelAllParticipantsTimeout() // Cancel active allParticipants timeout orchestration
333
354
  ```
334
355
 
335
356
  ## XState Integration
@@ -110,10 +110,14 @@ class AnearEvent extends JsonApiResource {
110
110
  this.send({ type: "BOOT_PARTICIPANT", data: { participantId, reason } })
111
111
  }
112
112
 
113
- cancelAllParticipantTimeouts() {
113
+ cancelAllParticipantsTimeout() {
114
114
  // Cancels active allParticipants timeout tracking in AEM.
115
- // Participant-local timeout ownership now lives in the browser runtime.
116
- this.send({ type: "CANCEL_ALL_PARTICIPANT_TIMEOUTS" })
115
+ this.send({ type: "CANCEL_ALL_PARTICIPANTS_TIMEOUT" })
116
+ }
117
+
118
+ // Backward-compatible alias (legacy misspelling/wording).
119
+ cancelAllParticipantTimeouts() {
120
+ this.cancelAllParticipantsTimeout()
117
121
  }
118
122
 
119
123
  render(viewPath, displayType, appContext, event, timeout = null, props = {}) {
@@ -132,7 +132,19 @@ const getPresenceEventName = (participant, presenceAction) => {
132
132
  return `${role}_${presenceAction}`;
133
133
  };
134
134
 
135
+ /**
136
+ * ACTION payload tokenization contract
137
+ * -----------------------------------
138
+ * - For participant-private displays, DisplayEventProcessor tokenizes anear-action payloads:
139
+ * participantActionLookup[participantId][token] = { APP_EVENT_NAME: payloadObject }
140
+ * - Incoming ACTION messages are first resolved by token lookup.
141
+ * - Raw JSON payloads are accepted when ABR marks them as trusted client payloads
142
+ * (for example forms and web-component actions).
143
+ * - Unknown/unmarked payloads are dropped (not forwarded to AppM).
144
+ */
135
145
  const FORM_PAYLOAD_MARKER = '__anearForm'
146
+ const CLIENT_PAYLOAD_MARKER = '__anearClient'
147
+ const TRUSTED_CLIENT_PAYLOAD_MARKERS = [FORM_PAYLOAD_MARKER, CLIENT_PAYLOAD_MARKER]
136
148
 
137
149
  const parseActionEnvelopeObject = (payloadObject) => {
138
150
  if (!payloadObject || typeof payloadObject !== 'object' || Array.isArray(payloadObject)) return null
@@ -167,11 +179,12 @@ const resolveIncomingActionEnvelope = (context, participantId, rawPayload) => {
167
179
  if (!parsed) return null
168
180
  const payload = parsed.payload
169
181
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
170
- if (payload[FORM_PAYLOAD_MARKER] !== true) return null
182
+ const hasTrustedMarker = TRUSTED_CLIENT_PAYLOAD_MARKERS.some(marker => payload[marker] === true)
183
+ if (!hasTrustedMarker) return null
171
184
 
172
185
  const cleanedPayload = { ...payload }
173
- delete cleanedPayload[FORM_PAYLOAD_MARKER]
174
- return { appEventName: parsed.appEventName, payload: cleanedPayload, source: 'form' }
186
+ TRUSTED_CLIENT_PAYLOAD_MARKERS.forEach(marker => delete cleanedPayload[marker])
187
+ return { appEventName: parsed.appEventName, payload: cleanedPayload, source: 'client' }
175
188
  }
176
189
 
177
190
  const RealtimeMessaging = require('../utils/RealtimeMessaging')
@@ -238,7 +251,7 @@ const AnearEventMachineContext = (
238
251
  exitedParticipantPrivateChannels: [],
239
252
  participantsActionTimeout: null,
240
253
  participantActionLookup: {}, // Per-render ephemeral action token lookup by participant ID.
241
- eachParticipantCancelActionType: null, // ACTION type that triggers cancellation for eachParticipant timeouts
254
+ eachParticipantCancelActionType: null, // ACTION type that can cancel active allParticipants timeout orchestration
242
255
  pendingCancelAction: null, // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
243
256
  consecutiveTimeoutCount: 0, // Global counter tracking consecutive timeouts across all participants for dead-man switch
244
257
  consecutiveAllParticipantsTimeoutCount: 0, // Counter tracking consecutive allParticipants timeouts where ALL participants timed out
@@ -567,8 +580,12 @@ const ActiveEventStatesConfig = {
567
580
  BOOT_PARTICIPANT: {
568
581
  actions: 'sendBootEventToParticipant'
569
582
  },
583
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {
584
+ actions: 'cancelAllParticipantsTimeout'
585
+ },
586
+ // Backward-compatible alias.
570
587
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
571
- actions: 'cancelAllParticipantTimeouts'
588
+ actions: 'cancelAllParticipantsTimeout'
572
589
  },
573
590
  SPECTATOR_ENTER: {
574
591
  actions: 'sendSpectatorEnterToAppEventMachine'
@@ -694,6 +711,8 @@ const ActiveEventStatesConfig = {
694
711
  BOOT_PARTICIPANT: {
695
712
  actions: 'sendBootEventToParticipant'
696
713
  },
714
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {},
715
+ // Backward-compatible alias.
697
716
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {}
698
717
  },
699
718
  states: {
@@ -726,6 +745,8 @@ const ActiveEventStatesConfig = {
726
745
  ACTION: {
727
746
  actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
728
747
  },
748
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {},
749
+ // Backward-compatible alias.
729
750
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {}
730
751
  }
731
752
  },
@@ -831,12 +852,21 @@ const ActiveEventStatesConfig = {
831
852
  target: 'handleParticipantsTimeoutCanceled'
832
853
  },
833
854
  {
855
+ guard: 'isRecognizedActionPayload',
834
856
  actions: ['resetParticipantTimeoutCount', 'processAndForwardAction', 'processParticipantResponse'],
835
857
  // v5 note: internal transitions are the default (v4 had `internal: true`)
858
+ },
859
+ {
860
+ actions: ['logDroppedUnrecognizedAction']
836
861
  }
837
862
  ],
863
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {
864
+ actions: ['cancelAllParticipantsTimeout'],
865
+ target: 'handleParticipantsTimeoutCanceled'
866
+ },
867
+ // Backward-compatible alias.
838
868
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
839
- actions: ['cancelAllParticipantTimeouts'],
869
+ actions: ['cancelAllParticipantsTimeout'],
840
870
  target: 'handleParticipantsTimeoutCanceled'
841
871
  },
842
872
  RENDER_DISPLAY: {
@@ -1438,19 +1468,31 @@ const AnearEventMachineFunctions = ({
1438
1468
  return { ...s, liveDurationMs: (s.liveDurationMs || 0) + add, liveStartedAt: null }
1439
1469
  }
1440
1470
  }),
1441
- setLastRenderResult: assign(({ event }) => {
1471
+ setLastRenderResult: assign(({ context, event }) => {
1442
1472
  const output = event?.output ?? event?.data
1443
1473
  if (!output || typeof output !== 'object') {
1444
1474
  return {
1445
1475
  lastRenderResult: null,
1446
- participantActionLookup: {}
1476
+ participantActionLookup: context.participantActionLookup || {}
1447
1477
  }
1448
1478
  }
1449
- return {
1450
- lastRenderResult: output,
1451
- participantActionLookup: output.participantActionLookup && typeof output.participantActionLookup === 'object'
1479
+ const incomingLookup =
1480
+ output.participantActionLookup && typeof output.participantActionLookup === 'object'
1452
1481
  ? output.participantActionLookup
1453
1482
  : {}
1483
+ // Merge token maps by participant so a selective/private re-render does not
1484
+ // invalidate untouched participants' existing action tokens.
1485
+ // For participants present in this render batch, replace their token map
1486
+ // atomically with the fresh map generated during render.
1487
+ const participantActionLookup = {
1488
+ ...(context.participantActionLookup || {}),
1489
+ }
1490
+ Object.keys(incomingLookup).forEach((participantId) => {
1491
+ participantActionLookup[participantId] = incomingLookup[participantId]
1492
+ })
1493
+ return {
1494
+ lastRenderResult: output,
1495
+ participantActionLookup
1454
1496
  }
1455
1497
  }),
1456
1498
  notifyAppMachineRendered: ({ context }) => {
@@ -1827,7 +1869,7 @@ const AnearEventMachineFunctions = ({
1827
1869
  participantPrivateChannels: context.participantPrivateChannels
1828
1870
  }
1829
1871
  }),
1830
- cancelAllParticipantTimeouts: assign(() => ({
1872
+ cancelAllParticipantsTimeout: assign(() => ({
1831
1873
  participantsActionTimeout: null,
1832
1874
  pendingCancelAction: null
1833
1875
  })),
@@ -1844,7 +1886,7 @@ const AnearEventMachineFunctions = ({
1844
1886
  }
1845
1887
  }),
1846
1888
  setupPendingCancelConfirmationsForManualCancel: assign(({ context }) => {
1847
- // Get all active participants (for manual cancelAllParticipantTimeouts call)
1889
+ // Get all active participants (for manual cancelAllParticipantsTimeout call)
1848
1890
  const activeParticipantIds = getActiveParticipantIds(context)
1849
1891
  const pendingConfirmations = new Set(activeParticipantIds)
1850
1892
 
@@ -1890,7 +1932,7 @@ const AnearEventMachineFunctions = ({
1890
1932
  null
1891
1933
 
1892
1934
  if (cancelActionType) {
1893
- logger.debug(`[AEM] Setting up cancel action type for eachParticipant timeout: ${cancelActionType}`)
1935
+ logger.debug(`[AEM] Setting up cancel action type for allParticipants timeout orchestration: ${cancelActionType}`)
1894
1936
  }
1895
1937
 
1896
1938
  return {
@@ -1924,6 +1966,10 @@ const AnearEventMachineFunctions = ({
1924
1966
  }
1925
1967
  return updates
1926
1968
  }),
1969
+ logDroppedUnrecognizedAction: ({ context, event }) => {
1970
+ const participantId = event?.data?.participantId
1971
+ logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
1972
+ },
1927
1973
  processParticipantAction: ({ context, event }) => {
1928
1974
  // event.data.participantId,
1929
1975
  // event.data.payload: {"appEventMachineACTION": {action event keys and values}}
@@ -2659,6 +2705,10 @@ const AnearEventMachineFunctions = ({
2659
2705
  const isTimeoutAlreadyRunning = context.participantsActionTimeout !== null;
2660
2706
  return isStartingNewTimeout || isTimeoutAlreadyRunning;
2661
2707
  },
2708
+ isRecognizedActionPayload: ({ context, event }) => {
2709
+ const participantId = event?.data?.participantId
2710
+ return !!resolveIncomingActionEnvelope(context, participantId, event?.data?.payload)
2711
+ },
2662
2712
  allParticipantsResponded: ({ context }) => {
2663
2713
  return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
2664
2714
  },
@@ -17,7 +17,8 @@ module.exports = {
17
17
  BOOT_EXIT: 2 * 1000 // 5 seconds
18
18
  },
19
19
  DEAD_MAN_SWITCH: {
20
- // Maximum consecutive timeouts per participant before auto-canceling event
20
+ // Maximum consecutive allParticipants timeouts (where everyone timed out)
21
+ // before auto-canceling the event.
21
22
  MAX_CONSECUTIVE_PARTICIPANT_TIMEOUTS: 4
22
23
  },
23
24
  EventStates: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {