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 eventMessagePayload = JSON.parse(event.data.payload)
783
- 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
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
- 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
+ }
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 eventMessagePayload = JSON.parse(event.data.payload) // { eventName: {eventObject} }
1879
- 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
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 eventMessagePayload = JSON.parse(event.data.payload);
1898
- 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
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 eventMessagePayload = JSON.parse(event.data.payload)
1922
- 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
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 { 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.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
+