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
- // v5 note: onDone of a fromPromise actor provides the resolved value on `event.output`.
245
- // event.output.anearParticipant will be null if no presence enter
246
- // was available on actions channel via get(). We must wait for
247
- // the undeferred PARTICIPANT_ENTER instead. But if get() DID
248
- // return the creator presence, register the participant and continue.
249
- actions: ['startNewParticipantSession', 'sendEnterToAppMachine'],
250
- target: '#eventCreated'
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 eventMessagePayload = JSON.parse(event.data.payload)
779
- const [appEventName] = Object.entries(eventMessagePayload)[0]
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
- return { lastRenderResult: output && typeof output === 'object' ? output : null }
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 eventMessagePayload = JSON.parse(event.data.payload) // { eventName: {eventObject} }
1875
- const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
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 eventMessagePayload = JSON.parse(event.data.payload);
1894
- const [appEventName, payload] = Object.entries(eventMessagePayload)[0];
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 eventMessagePayload = JSON.parse(event.data.payload)
1918
- const [appEventName, payload] = Object.entries(eventMessagePayload)[0]
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 { 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.0",
3
+ "version": "2.3.0",
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
+