anear-js-api 2.2.0 → 2.3.0
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.
|
@@ -132,6 +132,48 @@ const getPresenceEventName = (participant, presenceAction) => {
|
|
|
132
132
|
return `${role}_${presenceAction}`;
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
+
const FORM_PAYLOAD_MARKER = '__anearForm'
|
|
136
|
+
|
|
137
|
+
const parseActionEnvelopeObject = (payloadObject) => {
|
|
138
|
+
if (!payloadObject || typeof payloadObject !== 'object' || Array.isArray(payloadObject)) return null
|
|
139
|
+
const entries = Object.entries(payloadObject)
|
|
140
|
+
if (entries.length < 1) return null
|
|
141
|
+
const [appEventName, payload] = entries[0]
|
|
142
|
+
if (!appEventName || typeof appEventName !== 'string') return null
|
|
143
|
+
return { appEventName, payload: payload ?? {} }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const parseActionEnvelope = (rawPayload) => {
|
|
147
|
+
if (rawPayload && typeof rawPayload === 'object') {
|
|
148
|
+
return parseActionEnvelopeObject(rawPayload)
|
|
149
|
+
}
|
|
150
|
+
if (typeof rawPayload !== 'string') return null
|
|
151
|
+
try {
|
|
152
|
+
return parseActionEnvelopeObject(JSON.parse(rawPayload))
|
|
153
|
+
} catch (_e) {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const resolveIncomingActionEnvelope = (context, participantId, rawPayload) => {
|
|
159
|
+
const participantLookup = context.participantActionLookup?.[participantId]
|
|
160
|
+
if (participantLookup && typeof rawPayload === 'string' && participantLookup[rawPayload]) {
|
|
161
|
+
const parsed = parseActionEnvelopeObject(participantLookup[rawPayload])
|
|
162
|
+
if (!parsed) return null
|
|
163
|
+
return { ...parsed, source: 'token' }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const parsed = parseActionEnvelope(rawPayload)
|
|
167
|
+
if (!parsed) return null
|
|
168
|
+
const payload = parsed.payload
|
|
169
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
170
|
+
if (payload[FORM_PAYLOAD_MARKER] !== true) return null
|
|
171
|
+
|
|
172
|
+
const cleanedPayload = { ...payload }
|
|
173
|
+
delete cleanedPayload[FORM_PAYLOAD_MARKER]
|
|
174
|
+
return { appEventName: parsed.appEventName, payload: cleanedPayload, source: 'form' }
|
|
175
|
+
}
|
|
176
|
+
|
|
135
177
|
const RealtimeMessaging = require('../utils/RealtimeMessaging')
|
|
136
178
|
const EventStats = require('../utils/EventStats')
|
|
137
179
|
const AppMachineTransition = require('../utils/AppMachineTransition')
|
|
@@ -195,6 +237,7 @@ const AnearEventMachineContext = (
|
|
|
195
237
|
participantPrivateChannels: {},
|
|
196
238
|
exitedParticipantPrivateChannels: [],
|
|
197
239
|
participantsActionTimeout: null,
|
|
240
|
+
participantActionLookup: {}, // Per-render ephemeral action token lookup by participant ID.
|
|
198
241
|
eachParticipantCancelActionType: null, // ACTION type that triggers cancellation for eachParticipant timeouts
|
|
199
242
|
pendingCancelAction: null, // ACTION to forward after cancellation completes: { participantId, appEventName, payload }
|
|
200
243
|
consecutiveTimeoutCount: 0, // Global counter tracking consecutive timeouts across all participants for dead-man switch
|
|
@@ -240,15 +283,19 @@ const ActiveEventStatesConfig = {
|
|
|
240
283
|
invoke: {
|
|
241
284
|
src: 'getAttachedCreatorOrHost',
|
|
242
285
|
input: ({ context, event }) => ({ context, event }),
|
|
243
|
-
onDone:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
286
|
+
onDone: [
|
|
287
|
+
{
|
|
288
|
+
// If actions-channel presence.get() found creator/host, register now.
|
|
289
|
+
guard: 'hasAttachedCreatorOrHost',
|
|
290
|
+
actions: ['startNewParticipantSession', 'sendEnterToAppMachine'],
|
|
291
|
+
target: '#eventCreated'
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
// No creator/host found yet via presence.get(); wait for PARTICIPANT_ENTER.
|
|
295
|
+
actions: ['logWaitingForCreatorPresence'],
|
|
296
|
+
target: '#waiting'
|
|
297
|
+
}
|
|
298
|
+
],
|
|
252
299
|
onError: {
|
|
253
300
|
target: '#failure'
|
|
254
301
|
}
|
|
@@ -775,8 +822,9 @@ const ActiveEventStatesConfig = {
|
|
|
775
822
|
guard: ({ context, event }) => {
|
|
776
823
|
const { cancelActionType } = context.participantsActionTimeout || {}
|
|
777
824
|
if (!cancelActionType) return false
|
|
778
|
-
const
|
|
779
|
-
|
|
825
|
+
const actionEnvelope = resolveIncomingActionEnvelope(context, event.data.participantId, event.data.payload)
|
|
826
|
+
if (!actionEnvelope) return false
|
|
827
|
+
const { appEventName } = actionEnvelope
|
|
780
828
|
return cancelActionType === appEventName
|
|
781
829
|
},
|
|
782
830
|
actions: ['storeCancelActionForAllParticipants'],
|
|
@@ -1392,7 +1440,18 @@ const AnearEventMachineFunctions = ({
|
|
|
1392
1440
|
}),
|
|
1393
1441
|
setLastRenderResult: assign(({ event }) => {
|
|
1394
1442
|
const output = event?.output ?? event?.data
|
|
1395
|
-
|
|
1443
|
+
if (!output || typeof output !== 'object') {
|
|
1444
|
+
return {
|
|
1445
|
+
lastRenderResult: null,
|
|
1446
|
+
participantActionLookup: {}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
lastRenderResult: output,
|
|
1451
|
+
participantActionLookup: output.participantActionLookup && typeof output.participantActionLookup === 'object'
|
|
1452
|
+
? output.participantActionLookup
|
|
1453
|
+
: {}
|
|
1454
|
+
}
|
|
1396
1455
|
}),
|
|
1397
1456
|
notifyAppMachineRendered: ({ context }) => {
|
|
1398
1457
|
if (context.appEventMachine) {
|
|
@@ -1871,8 +1930,12 @@ const AnearEventMachineFunctions = ({
|
|
|
1871
1930
|
// e.g. {"MOVE":{"x":1, "y":2}}
|
|
1872
1931
|
// send to the ParticipantMachine to handle state of participant (idle, active, timed-out, etc)
|
|
1873
1932
|
const participantId = event.data.participantId
|
|
1874
|
-
const
|
|
1875
|
-
|
|
1933
|
+
const actionEnvelope = resolveIncomingActionEnvelope(context, participantId, event.data.payload)
|
|
1934
|
+
if (!actionEnvelope) {
|
|
1935
|
+
logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
|
|
1936
|
+
return
|
|
1937
|
+
}
|
|
1938
|
+
const { appEventName, payload } = actionEnvelope
|
|
1876
1939
|
const participantName = getParticipantName(context, participantId)
|
|
1877
1940
|
|
|
1878
1941
|
logger.info(`[AEM] Event ${context.anearEvent.id} ACTION participantId=${participantId} name=${participantName} action=${appEventName}`)
|
|
@@ -1890,8 +1953,12 @@ const AnearEventMachineFunctions = ({
|
|
|
1890
1953
|
const { nonResponders } = context.participantsActionTimeout;
|
|
1891
1954
|
|
|
1892
1955
|
// Forward to AppM with the finalAction flag
|
|
1893
|
-
const
|
|
1894
|
-
|
|
1956
|
+
const actionEnvelope = resolveIncomingActionEnvelope(context, participantId, event.data.payload)
|
|
1957
|
+
if (!actionEnvelope) {
|
|
1958
|
+
logger.warn(`[AEM] Event ${context.anearEvent.id} dropping ACTION with unrecognized payload from participantId=${participantId}`)
|
|
1959
|
+
return
|
|
1960
|
+
}
|
|
1961
|
+
const { appEventName, payload } = actionEnvelope
|
|
1895
1962
|
const participantName = getParticipantName(context, participantId);
|
|
1896
1963
|
|
|
1897
1964
|
logger.info(`[AEM] Event ${context.anearEvent.id} ACTION participantId=${participantId} name=${participantName} action=${appEventName}`)
|
|
@@ -1914,8 +1981,12 @@ const AnearEventMachineFunctions = ({
|
|
|
1914
1981
|
},
|
|
1915
1982
|
storeCancelActionForAllParticipants: assign(({ context, event }) => {
|
|
1916
1983
|
const participantId = event.data.participantId
|
|
1917
|
-
const
|
|
1918
|
-
|
|
1984
|
+
const actionEnvelope = resolveIncomingActionEnvelope(context, participantId, event.data.payload)
|
|
1985
|
+
if (!actionEnvelope) {
|
|
1986
|
+
logger.warn(`[AEM] Event ${context.anearEvent.id} dropping cancel ACTION with unrecognized payload from participantId=${participantId}`)
|
|
1987
|
+
return {}
|
|
1988
|
+
}
|
|
1989
|
+
const { appEventName, payload } = actionEnvelope
|
|
1919
1990
|
const participantName = getParticipantName(context, participantId)
|
|
1920
1991
|
|
|
1921
1992
|
logger.info(`[AEM] Event ${context.anearEvent.id} intercepting cancel ACTION ${appEventName} from participantId=${participantId} name=${participantName} (allParticipants timeout)`)
|
|
@@ -2143,6 +2214,7 @@ const AnearEventMachineFunctions = ({
|
|
|
2143
2214
|
context.coreServiceMachine.send({ type: 'EVENT_MACHINE_EXIT', eventId: context.anearEvent.id })
|
|
2144
2215
|
},
|
|
2145
2216
|
logCreatorEnter: ({ event }) => logger.debug("[AEM] got creator PARTICIPANT_ENTER: ", event.data.id),
|
|
2217
|
+
logWaitingForCreatorPresence: ({ context }) => logger.debug(`[AEM] Event ${context.anearEvent.id} creator/host not found via actions presence.get(); waiting for PARTICIPANT_ENTER`),
|
|
2146
2218
|
logAPMReady: () => logger.debug('[AEM] participant session registered'),
|
|
2147
2219
|
logInvalidParticipantEnter: ({ context, event }) => logger.info(`[AEM] Event ${context.anearEvent.id} unexpected PARTICIPANT_ENTER participantId=${event.data.id}`),
|
|
2148
2220
|
logDeferringAppmFinal: ({ context }) => logger.info(`[AEM] Event ${context.anearEvent.id} deferring APPM_FINAL (already transitioning to terminated)`),
|
|
@@ -2533,6 +2605,10 @@ const AnearEventMachineFunctions = ({
|
|
|
2533
2605
|
})
|
|
2534
2606
|
},
|
|
2535
2607
|
guards: {
|
|
2608
|
+
hasAttachedCreatorOrHost: ({ event }) => {
|
|
2609
|
+
const anearParticipant = event?.output?.anearParticipant ?? event?.data?.anearParticipant
|
|
2610
|
+
return !!anearParticipant
|
|
2611
|
+
},
|
|
2536
2612
|
isReconnectUpdate: ({ context, event }) => {
|
|
2537
2613
|
// participant exists and event.data.type is undefined
|
|
2538
2614
|
return context.participants[event.data.id] && !event.data.type
|
|
@@ -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
|
+
|