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.,
|
|
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
|
|
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
|
-
```
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
332
|
-
anearEvent.
|
|
352
|
+
// allParticipants timeout management
|
|
353
|
+
anearEvent.cancelAllParticipantsTimeout() // Cancel active allParticipants timeout orchestration
|
|
333
354
|
```
|
|
334
355
|
|
|
335
356
|
## XState Integration
|
package/lib/models/AnearEvent.js
CHANGED
|
@@ -110,10 +110,14 @@ class AnearEvent extends JsonApiResource {
|
|
|
110
110
|
this.send({ type: "BOOT_PARTICIPANT", data: { participantId, reason } })
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
cancelAllParticipantsTimeout() {
|
|
114
114
|
// Cancels active allParticipants timeout tracking in AEM.
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
783
|
-
|
|
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: ['
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1879
|
-
|
|
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
|
|
1898
|
-
|
|
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
|
|
1922
|
-
|
|
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
|
},
|
package/lib/utils/Constants.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
@@ -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
|
+
|