anear-js-api 2.2.1 → 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
|
|
@@ -779,8 +822,9 @@ const ActiveEventStatesConfig = {
|
|
|
779
822
|
guard: ({ context, event }) => {
|
|
780
823
|
const { cancelActionType } = context.participantsActionTimeout || {}
|
|
781
824
|
if (!cancelActionType) return false
|
|
782
|
-
const
|
|
783
|
-
|
|
825
|
+
const actionEnvelope = resolveIncomingActionEnvelope(context, event.data.participantId, event.data.payload)
|
|
826
|
+
if (!actionEnvelope) return false
|
|
827
|
+
const { appEventName } = actionEnvelope
|
|
784
828
|
return cancelActionType === appEventName
|
|
785
829
|
},
|
|
786
830
|
actions: ['storeCancelActionForAllParticipants'],
|
|
@@ -1396,7 +1440,18 @@ const AnearEventMachineFunctions = ({
|
|
|
1396
1440
|
}),
|
|
1397
1441
|
setLastRenderResult: assign(({ event }) => {
|
|
1398
1442
|
const output = event?.output ?? event?.data
|
|
1399
|
-
|
|
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
|
+
}
|
|
1400
1455
|
}),
|
|
1401
1456
|
notifyAppMachineRendered: ({ context }) => {
|
|
1402
1457
|
if (context.appEventMachine) {
|
|
@@ -1875,8 +1930,12 @@ const AnearEventMachineFunctions = ({
|
|
|
1875
1930
|
// e.g. {"MOVE":{"x":1, "y":2}}
|
|
1876
1931
|
// send to the ParticipantMachine to handle state of participant (idle, active, timed-out, etc)
|
|
1877
1932
|
const participantId = event.data.participantId
|
|
1878
|
-
const
|
|
1879
|
-
|
|
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
|
|
1880
1939
|
const participantName = getParticipantName(context, participantId)
|
|
1881
1940
|
|
|
1882
1941
|
logger.info(`[AEM] Event ${context.anearEvent.id} ACTION participantId=${participantId} name=${participantName} action=${appEventName}`)
|
|
@@ -1894,8 +1953,12 @@ const AnearEventMachineFunctions = ({
|
|
|
1894
1953
|
const { nonResponders } = context.participantsActionTimeout;
|
|
1895
1954
|
|
|
1896
1955
|
// Forward to AppM with the finalAction flag
|
|
1897
|
-
const
|
|
1898
|
-
|
|
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
|
|
1899
1962
|
const participantName = getParticipantName(context, participantId);
|
|
1900
1963
|
|
|
1901
1964
|
logger.info(`[AEM] Event ${context.anearEvent.id} ACTION participantId=${participantId} name=${participantName} action=${appEventName}`)
|
|
@@ -1918,8 +1981,12 @@ const AnearEventMachineFunctions = ({
|
|
|
1918
1981
|
},
|
|
1919
1982
|
storeCancelActionForAllParticipants: assign(({ context, event }) => {
|
|
1920
1983
|
const participantId = event.data.participantId
|
|
1921
|
-
const
|
|
1922
|
-
|
|
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
|
|
1923
1990
|
const participantName = getParticipantName(context, participantId)
|
|
1924
1991
|
|
|
1925
1992
|
logger.info(`[AEM] Event ${context.anearEvent.id} intercepting cancel ACTION ${appEventName} from participantId=${participantId} name=${participantName} (allParticipants timeout)`)
|
|
@@ -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
|
+
|