anear-js-api 2.2.1 → 2.3.1

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,6 +132,61 @@ 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
+ */
145
+ const FORM_PAYLOAD_MARKER = '__anearForm'
146
+ const CLIENT_PAYLOAD_MARKER = '__anearClient'
147
+ const TRUSTED_CLIENT_PAYLOAD_MARKERS = [FORM_PAYLOAD_MARKER, CLIENT_PAYLOAD_MARKER]
148
+
149
+ const parseActionEnvelopeObject = (payloadObject) => {
150
+ if (!payloadObject || typeof payloadObject !== 'object' || Array.isArray(payloadObject)) return null
151
+ const entries = Object.entries(payloadObject)
152
+ if (entries.length < 1) return null
153
+ const [appEventName, payload] = entries[0]
154
+ if (!appEventName || typeof appEventName !== 'string') return null
155
+ return { appEventName, payload: payload ?? {} }
156
+ }
157
+
158
+ const parseActionEnvelope = (rawPayload) => {
159
+ if (rawPayload && typeof rawPayload === 'object') {
160
+ return parseActionEnvelopeObject(rawPayload)
161
+ }
162
+ if (typeof rawPayload !== 'string') return null
163
+ try {
164
+ return parseActionEnvelopeObject(JSON.parse(rawPayload))
165
+ } catch (_e) {
166
+ return null
167
+ }
168
+ }
169
+
170
+ const resolveIncomingActionEnvelope = (context, participantId, rawPayload) => {
171
+ const participantLookup = context.participantActionLookup?.[participantId]
172
+ if (participantLookup && typeof rawPayload === 'string' && participantLookup[rawPayload]) {
173
+ const parsed = parseActionEnvelopeObject(participantLookup[rawPayload])
174
+ if (!parsed) return null
175
+ return { ...parsed, source: 'token' }
176
+ }
177
+
178
+ const parsed = parseActionEnvelope(rawPayload)
179
+ if (!parsed) return null
180
+ const payload = parsed.payload
181
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
182
+ const hasTrustedMarker = TRUSTED_CLIENT_PAYLOAD_MARKERS.some(marker => payload[marker] === true)
183
+ if (!hasTrustedMarker) return null
184
+
185
+ const cleanedPayload = { ...payload }
186
+ TRUSTED_CLIENT_PAYLOAD_MARKERS.forEach(marker => delete cleanedPayload[marker])
187
+ return { appEventName: parsed.appEventName, payload: cleanedPayload, source: 'client' }
188
+ }
189
+
135
190
  const RealtimeMessaging = require('../utils/RealtimeMessaging')
136
191
  const EventStats = require('../utils/EventStats')
137
192
  const AppMachineTransition = require('../utils/AppMachineTransition')
@@ -195,7 +250,8 @@ const AnearEventMachineContext = (
195
250
  participantPrivateChannels: {},
196
251
  exitedParticipantPrivateChannels: [],
197
252
  participantsActionTimeout: null,
198
- eachParticipantCancelActionType: null, // ACTION type that triggers cancellation for eachParticipant timeouts
253
+ participantActionLookup: {}, // Per-render ephemeral action token lookup by participant ID.
254
+ eachParticipantCancelActionType: null, // ACTION type that can cancel active allParticipants timeout orchestration
199
255
  pendingCancelAction: null, // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
200
256
  consecutiveTimeoutCount: 0, // Global counter tracking consecutive timeouts across all participants for dead-man switch
201
257
  consecutiveAllParticipantsTimeoutCount: 0, // Counter tracking consecutive allParticipants timeouts where ALL participants timed out
@@ -524,8 +580,12 @@ const ActiveEventStatesConfig = {
524
580
  BOOT_PARTICIPANT: {
525
581
  actions: 'sendBootEventToParticipant'
526
582
  },
583
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {
584
+ actions: 'cancelAllParticipantsTimeout'
585
+ },
586
+ // Backward-compatible alias.
527
587
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
528
- actions: 'cancelAllParticipantTimeouts'
588
+ actions: 'cancelAllParticipantsTimeout'
529
589
  },
530
590
  SPECTATOR_ENTER: {
531
591
  actions: 'sendSpectatorEnterToAppEventMachine'
@@ -651,6 +711,8 @@ const ActiveEventStatesConfig = {
651
711
  BOOT_PARTICIPANT: {
652
712
  actions: 'sendBootEventToParticipant'
653
713
  },
714
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {},
715
+ // Backward-compatible alias.
654
716
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {}
655
717
  },
656
718
  states: {
@@ -683,6 +745,8 @@ const ActiveEventStatesConfig = {
683
745
  ACTION: {
684
746
  actions: ['resetParticipantTimeoutCount', 'processParticipantAction']
685
747
  },
748
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {},
749
+ // Backward-compatible alias.
686
750
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {}
687
751
  }
688
752
  },
@@ -779,20 +843,30 @@ const ActiveEventStatesConfig = {
779
843
  guard: ({ context, event }) => {
780
844
  const { cancelActionType } = context.participantsActionTimeout || {}
781
845
  if (!cancelActionType) return false
782
- const eventMessagePayload = JSON.parse(event.data.payload)
783
- const [appEventName] = Object.entries(eventMessagePayload)[0]
846
+ const actionEnvelope = resolveIncomingActionEnvelope(context, event.data.participantId, event.data.payload)
847
+ if (!actionEnvelope) return false
848
+ const { appEventName } = actionEnvelope
784
849
  return cancelActionType === appEventName
785
850
  },
786
851
  actions: ['storeCancelActionForAllParticipants'],
787
852
  target: 'handleParticipantsTimeoutCanceled'
788
853
  },
789
854
  {
855
+ guard: 'isRecognizedActionPayload',
790
856
  actions: ['resetParticipantTimeoutCount', 'processAndForwardAction', 'processParticipantResponse'],
791
857
  // v5 note: internal transitions are the default (v4 had `internal: true`)
858
+ },
859
+ {
860
+ actions: ['logDroppedUnrecognizedAction']
792
861
  }
793
862
  ],
863
+ CANCEL_ALL_PARTICIPANTS_TIMEOUT: {
864
+ actions: ['cancelAllParticipantsTimeout'],
865
+ target: 'handleParticipantsTimeoutCanceled'
866
+ },
867
+ // Backward-compatible alias.
794
868
  CANCEL_ALL_PARTICIPANT_TIMEOUTS: {
795
- actions: ['cancelAllParticipantTimeouts'],
869
+ actions: ['cancelAllParticipantsTimeout'],
796
870
  target: 'handleParticipantsTimeoutCanceled'
797
871
  },
798
872
  RENDER_DISPLAY: {
@@ -1396,7 +1470,18 @@ const AnearEventMachineFunctions = ({
1396
1470
  }),
1397
1471
  setLastRenderResult: assign(({ event }) => {
1398
1472
  const output = event?.output ?? event?.data
1399
- return { lastRenderResult: output && typeof output === 'object' ? output : null }
1473
+ if (!output || typeof output !== 'object') {
1474
+ return {
1475
+ lastRenderResult: null,
1476
+ participantActionLookup: {}
1477
+ }
1478
+ }
1479
+ return {
1480
+ lastRenderResult: output,
1481
+ participantActionLookup: output.participantActionLookup && typeof output.participantActionLookup === 'object'
1482
+ ? output.participantActionLookup
1483
+ : {}
1484
+ }
1400
1485
  }),
1401
1486
  notifyAppMachineRendered: ({ context }) => {
1402
1487
  if (context.appEventMachine) {
@@ -1772,7 +1857,7 @@ const AnearEventMachineFunctions = ({
1772
1857
  participantPrivateChannels: context.participantPrivateChannels
1773
1858
  }
1774
1859
  }),
1775
- cancelAllParticipantTimeouts: assign(() => ({
1860
+ cancelAllParticipantsTimeout: assign(() => ({
1776
1861
  participantsActionTimeout: null,
1777
1862
  pendingCancelAction: null
1778
1863
  })),
@@ -1789,7 +1874,7 @@ const AnearEventMachineFunctions = ({
1789
1874
  }
1790
1875
  }),
1791
1876
  setupPendingCancelConfirmationsForManualCancel: assign(({ context }) => {
1792
- // Get all active participants (for manual cancelAllParticipantTimeouts call)
1877
+ // Get all active participants (for manual cancelAllParticipantsTimeout call)
1793
1878
  const activeParticipantIds = getActiveParticipantIds(context)
1794
1879
  const pendingConfirmations = new Set(activeParticipantIds)
1795
1880
 
@@ -1835,7 +1920,7 @@ const AnearEventMachineFunctions = ({
1835
1920
  null
1836
1921
 
1837
1922
  if (cancelActionType) {
1838
- logger.debug(`[AEM] Setting up cancel action type for eachParticipant timeout: ${cancelActionType}`)
1923
+ logger.debug(`[AEM] Setting up cancel action type for allParticipants timeout orchestration: ${cancelActionType}`)
1839
1924
  }
1840
1925
 
1841
1926
  return {
@@ -1869,14 +1954,22 @@ const AnearEventMachineFunctions = ({
1869
1954
  }
1870
1955
  return updates
1871
1956
  }),
1957
+ logDroppedUnrecognizedAction: ({ context, event }) => {
1958
+ const participantId = event?.data?.participantId
1959
+ logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
1960
+ },
1872
1961
  processParticipantAction: ({ context, event }) => {
1873
1962
  // event.data.participantId,
1874
1963
  // event.data.payload: {"appEventMachineACTION": {action event keys and values}}
1875
1964
  // e.g. {"MOVE":{"x":1, "y":2}}
1876
1965
  // send to the ParticipantMachine to handle state of participant (idle, active, timed-out, etc)
1877
1966
  const participantId = event.data.participantId
1878
- const eventMessagePayload = JSON.parse(event.data.payload) // { eventName: {eventObject} }
1879
- const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
1967
+ const actionEnvelope = resolveIncomingActionEnvelope(context, participantId, event.data.payload)
1968
+ if (!actionEnvelope) {
1969
+ logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
1970
+ return
1971
+ }
1972
+ const { appEventName, payload } = actionEnvelope
1880
1973
  const participantName = getParticipantName(context, participantId)
1881
1974
 
1882
1975
  logger.info(`[AEM] Event ${context.anearEvent.id} ACTION participantId=${participantId} name=${participantName} action=${appEventName}`)
@@ -1894,8 +1987,12 @@ const AnearEventMachineFunctions = ({
1894
1987
  const { nonResponders } = context.participantsActionTimeout;
1895
1988
 
1896
1989
  // Forward to AppM with the finalAction flag
1897
- const eventMessagePayload = JSON.parse(event.data.payload);
1898
- const [appEventName, payload] = Object.entries(eventMessagePayload)[0];
1990
+ const actionEnvelope = resolveIncomingActionEnvelope(context, participantId, event.data.payload)
1991
+ if (!actionEnvelope) {
1992
+ logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
1993
+ return
1994
+ }
1995
+ const { appEventName, payload } = actionEnvelope
1899
1996
  const participantName = getParticipantName(context, participantId);
1900
1997
 
1901
1998
  logger.info(`[AEM] Event ${context.anearEvent.id} ACTION participantId=${participantId} name=${participantName} action=${appEventName}`)
@@ -1918,8 +2015,12 @@ const AnearEventMachineFunctions = ({
1918
2015
  },
1919
2016
  storeCancelActionForAllParticipants: assign(({ context, event }) => {
1920
2017
  const participantId = event.data.participantId
1921
- const eventMessagePayload = JSON.parse(event.data.payload)
1922
- const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
2018
+ const actionEnvelope = resolveIncomingActionEnvelope(context, participantId, event.data.payload)
2019
+ if (!actionEnvelope) {
2020
+ logger.warn(`[AEM] Event ${context.anearEvent.id} dropping cancel ACTION with unrecognized payload from participantId=${participantId}`)
2021
+ return {}
2022
+ }
2023
+ const { appEventName, payload } = actionEnvelope
1923
2024
  const participantName = getParticipantName(context, participantId)
1924
2025
 
1925
2026
  logger.info(`[AEM] Event ${context.anearEvent.id} intercepting cancel ACTION ${appEventName} from participantId=${participantId} name=${participantName} (allParticipants timeout)`)
@@ -2592,6 +2693,10 @@ const AnearEventMachineFunctions = ({
2592
2693
  const isTimeoutAlreadyRunning = context.participantsActionTimeout !== null;
2593
2694
  return isStartingNewTimeout || isTimeoutAlreadyRunning;
2594
2695
  },
2696
+ isRecognizedActionPayload: ({ context, event }) => {
2697
+ const participantId = event?.data?.participantId
2698
+ return !!resolveIncomingActionEnvelope(context, participantId, event?.data?.payload)
2699
+ },
2595
2700
  allParticipantsResponded: ({ context }) => {
2596
2701
  return context.participantsActionTimeout && context.participantsActionTimeout.nonResponders.size === 0
2597
2702
  },
@@ -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: {
@@ -42,6 +42,7 @@ const logger = require('./Logger')
42
42
  const RealtimeMessaging = require('./RealtimeMessaging')
43
43
  const EventStats = require('./EventStats')
44
44
  const C = require('./Constants')
45
+ const { randomBytes } = require('crypto')
45
46
 
46
47
  class DisplayEventProcessor {
47
48
  constructor(anearEventMachineContext) {
@@ -56,6 +57,9 @@ class DisplayEventProcessor {
56
57
  this.participants = anearEventMachineContext.participants
57
58
  this.participantsIndex = this._buildParticipantsIndex(anearEventMachineContext.participants)
58
59
  this.eventStats = anearEventMachineContext.eventStats ?? null
60
+ // Ephemeral map generated per render batch:
61
+ // { [participantId]: { [token]: { APP_EVENT: payloadObject } } }
62
+ this.participantActionLookup = {}
59
63
  }
60
64
 
61
65
  processAndPublish(displayEvents, cancelActionType = null) {
@@ -92,10 +96,54 @@ class DisplayEventProcessor {
92
96
  })
93
97
 
94
98
  return Promise.all(publishPromises).then(() => {
95
- return { participantsTimeout, cancelActionType, anyDisplaySent }
99
+ return {
100
+ participantsTimeout,
101
+ cancelActionType,
102
+ anyDisplaySent,
103
+ participantActionLookup: this.participantActionLookup
104
+ }
96
105
  })
97
106
  }
98
107
 
108
+ _normalizeActionPayload(payload) {
109
+ if (typeof payload === 'string') {
110
+ return { [payload]: {} }
111
+ }
112
+ if (typeof payload === 'object' && payload !== null) {
113
+ return payload
114
+ }
115
+ throw new Error("Invalid payload for action(): must be a string or an object.")
116
+ }
117
+
118
+ _createActionToken(existingTokens) {
119
+ // 6 random bytes encoded as base64url produce 8 chars.
120
+ for (let i = 0; i < 6; i += 1) {
121
+ const token = randomBytes(6).toString('base64url')
122
+ if (!existingTokens[token]) return token
123
+ }
124
+ // Extremely unlikely fallback on repeated collisions.
125
+ return randomBytes(9).toString('base64url')
126
+ }
127
+
128
+ _encodeParticipantActionPayload(participantId, payload) {
129
+ const normalizedPayload = this._normalizeActionPayload(payload)
130
+ if (!this.participantActionLookup[participantId]) {
131
+ this.participantActionLookup[participantId] = {}
132
+ }
133
+ const participantLookup = this.participantActionLookup[participantId]
134
+ const token = this._createActionToken(participantLookup)
135
+ participantLookup[token] = normalizedPayload
136
+ return token
137
+ }
138
+
139
+ _buildPugHelpersForParticipant(participantId) {
140
+ if (!participantId) return this.pugHelpers
141
+ return {
142
+ ...this.pugHelpers,
143
+ action: (payload) => this._encodeParticipantActionPayload(participantId, payload)
144
+ }
145
+ }
146
+
99
147
  _buildParticipantsIndex(participants) {
100
148
  const participantStructs = Object.fromEntries(
101
149
  Object.entries(participants).map(([id, info]) => [ id, { info, context: null } ])
@@ -414,7 +462,8 @@ class DisplayEventProcessor {
414
462
  }
415
463
  const privateRenderContext = {
416
464
  ...templateRenderContext,
417
- participant: participantStruct
465
+ participant: participantStruct,
466
+ ...this._buildPugHelpersForParticipant(participantId)
418
467
  }
419
468
 
420
469
  let visualTimeout = null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anear-js-api",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Javascript Developer API for Anear Apps",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -10,7 +10,10 @@ const makeProcessor = (overrides = {}) => {
10
10
  anearEvent: {},
11
11
  participantsActionTimeout: null,
12
12
  pugTemplates: {},
13
- pugHelpers: {},
13
+ pugHelpers: {
14
+ action: (payload) => JSON.stringify(payload),
15
+ cdnImg: (filename) => `https://cdn.example/${filename}`
16
+ },
14
17
  participantPrivateChannels: { p1: 'private-channel-p1' },
15
18
  participantsDisplayChannel: 'participants-channel',
16
19
  spectatorsDisplayChannel: null,
@@ -98,3 +101,60 @@ describe('DisplayEventProcessor anchor updates', () => {
98
101
  })
99
102
  })
100
103
 
104
+ describe('DisplayEventProcessor secure action tokens', () => {
105
+ beforeEach(() => {
106
+ jest.clearAllMocks()
107
+ })
108
+
109
+ test('encodes participant anear-action payloads into ephemeral tokens', async () => {
110
+ const { processor } = makeProcessor({
111
+ pugTemplates: {
112
+ 'board.pug': (ctx) => `<anear-action payload="${ctx.action({ PLAY_CARD: { cardId: 'c1' } })}">PLAY</anear-action>`
113
+ }
114
+ })
115
+
116
+ const result = await processor.processAndPublish([
117
+ {
118
+ viewPath: 'board',
119
+ appRenderContext: { app: {}, meta: { viewer: 'eachParticipant', timeoutFn: null } },
120
+ viewer: 'eachParticipant',
121
+ participantId: 'p1',
122
+ props: {}
123
+ }
124
+ ])
125
+
126
+ expect(RealtimeMessaging.publish).toHaveBeenCalledTimes(1)
127
+ const [, , payload] = RealtimeMessaging.publish.mock.calls[0]
128
+ const html = payload.content
129
+ const tokenMatch = html.match(/payload="([^"]+)"/)
130
+ expect(tokenMatch).toBeTruthy()
131
+ const token = tokenMatch[1]
132
+ expect(token).toMatch(/^[A-Za-z0-9_-]{8,12}$/)
133
+
134
+ expect(result.participantActionLookup.p1[token]).toEqual({
135
+ PLAY_CARD: { cardId: 'c1' }
136
+ })
137
+ })
138
+
139
+ test('keeps allParticipants action helper backward-compatible with JSON payloads', async () => {
140
+ const { processor } = makeProcessor({
141
+ pugTemplates: {
142
+ 'public_view.pug': (ctx) => `<anear-action payload="${ctx.action({ PLAY: {} })}">PLAY</anear-action>`
143
+ }
144
+ })
145
+
146
+ const result = await processor.processAndPublish([
147
+ {
148
+ viewPath: 'public_view',
149
+ appRenderContext: { app: {}, meta: { viewer: 'allParticipants', timeoutFn: null } },
150
+ viewer: 'allParticipants',
151
+ props: {}
152
+ }
153
+ ])
154
+
155
+ const [, , payload] = RealtimeMessaging.publish.mock.calls[0]
156
+ expect(payload.content).toMatch(/payload=".*PLAY.*"/)
157
+ expect(result.participantActionLookup).toEqual({})
158
+ })
159
+ })
160
+