From c16336b883af64873bd530dbec1d386faf3a43c2 Mon Sep 17 00:00:00 2001 From: Mateusz Wiktor Date: Wed, 23 Sep 2026 09:34:11 +0200 Subject: [PATCH 1/3] Fix TypeScript-in-JS, security, and timing bugs in sports use-case snippets The platform review found that every sports/media/entertainment snippet file cast errors with `as PubNubError`, which is TypeScript syntax and fails a plain-JS parser when the docs extract these regions as JavaScript. Some files also carried type annotations, `type`/`interface` declarations, and `as` casts inside the extracted regions. Replace the cast pattern everywhere with a runtime `instanceof Error && 'status' in error` check, drop now-unused `PubNubError` imports, and remove or rework every other type-only construct inside a snippet marker (Map instead of a `Record<...>`-annotated object, default-valued parameters instead of typed ones, `typeof`/`in`-narrowed property reads instead of `as` casts on a subscribed message). Fix the specific defects the review's SDK-review findings pointed at: - live-commentary.ts: `unsubscribe()` was top-level code that ran the moment the file loaded. Move it into a `SIGINT` handler, and add a status listener that logs on `PNConnectedCategory` so the docs can tell the reader when the viewer is actually ready to receive. - fan-behavior-management.ts: mute and ban only revoked the most recently issued token, leaving any earlier writable token usable. Track every token issued per fan in a small JSON file store, revoke every writable one on mute and every token on ban, and refuse to grant write back while muted or the fan at all while banned. Add real `process.argv` dispatch (`grant`/`mute`/`unmute`/`ban`) and a `fan.js` that actually parses its token argument, installs its status listener before subscribing, and reports a rejected publish from its own `try`/`catch` rather than the status listener. - game-chat-moderation.ts: any client with write access to `game.chat` could publish the same `moderation`/`hidden` message action a moderator would, since Access Manager scopes permissions to channels, not to action type/value strings. Move the hide decision to its own `game.chat.moderation` channel that only a moderator-scoped token can write to, and fetch that channel's history alongside `game.chat`'s so a late-joining client still renders the hide. Also stop rounding a 17-digit timetoken through `Number` arithmetic before the single-message delete; keep it as a string and use `BigInt` for the exclusive-start calculation. - fan-re-engagement.ts: the occupancy/leave logic ran under the same `userId` it was checking, and the alert published unconditionally to a shared channel every registered device received. Separate the monitoring identity from any fan's, gate the publish behind a `whereNow` check for the specific fan who triggered the presence event, and target a per-fan `game.moment-alerts.` channel instead of a shared one. Add a `fan-viewer.js` so the two-client case (one fan stays, one leaves) is actually runnable. - score-alerts.ts: device registration set `environment: 'production'` while the notification payload's APNs target left `environment` at its SDK default of `'development'`, so a registered device could never receive what got published. Introduce one `APNS_ENVIRONMENT` constant used by registration, the payload target, and new APNs-specific listing and removal snippets. Verified with `npm run test:snippets` (tsc against the whole docs-snippets tree) and by replicating EmbeddedCode's exact snippet extraction to `node --check` every region, and the per-page combined assembly, for all 11 sports use-case pages. Co-Authored-By: Claude Sonnet 5 --- docs-snippets/use-cases/automated-polling.ts | 67 ++--- .../use-cases/fan-behavior-management.ts | 241 ++++++++++++++---- docs-snippets/use-cases/fan-re-engagement.ts | 122 +++++---- .../use-cases/game-chat-moderation.ts | 131 ++++++---- docs-snippets/use-cases/live-commentary.ts | 30 ++- .../use-cases/live-event-rate-limiting.ts | 13 +- docs-snippets/use-cases/live-polls.ts | 30 +-- docs-snippets/use-cases/match-stats.ts | 16 +- docs-snippets/use-cases/real-time-ads.ts | 39 +-- docs-snippets/use-cases/real-time-chat.ts | 44 +--- docs-snippets/use-cases/score-alerts.ts | 89 ++++--- 11 files changed, 525 insertions(+), 297 deletions(-) diff --git a/docs-snippets/use-cases/automated-polling.ts b/docs-snippets/use-cases/automated-polling.ts index 731fdfe61..3d1ff65f5 100644 --- a/docs-snippets/use-cases/automated-polling.ts +++ b/docs-snippets/use-cases/automated-polling.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -7,25 +7,31 @@ const pubnub = new PubNub({ }); // snippet.automatedPollingPublishTriggeredPoll -const pollsByReaction: Record = { - '\u{1F621}': { - title: 'Which team is playing dirtiest?', - options: [ - { id: 1, text: 'Home team' }, - { id: 2, text: 'Away team' }, - ], - }, - '\u{1F389}': { - title: 'Whose fans are celebrating hardest?', - options: [ - { id: 1, text: 'Home team' }, - { id: 2, text: 'Away team' }, - ], - }, -}; +const pollsByReaction = new Map([ + [ + '\u{1F621}', + { + title: 'Which team is playing dirtiest?', + options: [ + { id: 1, text: 'Home team' }, + { id: 2, text: 'Away team' }, + ], + }, + ], + [ + '\u{1F389}', + { + title: 'Whose fans are celebrating hardest?', + options: [ + { id: 1, text: 'Home team' }, + { id: 2, text: 'Away team' }, + ], + }, + ], +]); -async function openPollForReaction(reaction: string) { - const template = pollsByReaction[reaction]; +async function openPollForReaction(reaction = '') { + const template = pollsByReaction.get(reaction); if (!template) { console.log('no poll is defined for', reaction); @@ -46,11 +52,8 @@ async function openPollForReaction(reaction: string) { }); console.log('triggered poll published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the triggered poll failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the triggered poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } } // snippet.end @@ -73,17 +76,23 @@ function shouldOpenPoll() { // snippet.end // snippet.automatedPollingReceiveTrigger -type PollTrigger = { reaction: string }; - const triggerSubscription = pubnub.channel('game.poll-triggers').subscription({ receivePresenceEvents: false }); triggerSubscription.onMessage = (event) => { - const trigger = event.message as PollTrigger; + const trigger = event.message; + const rawReaction = + typeof trigger === 'object' && trigger !== null && !Array.isArray(trigger) && 'reaction' in trigger + ? trigger.reaction + : undefined; + + if (typeof rawReaction !== 'string') return; + + const reaction = rawReaction; - console.log('open a poll because fans keep tapping', trigger.reaction); + console.log('open a poll because fans keep tapping', reaction); if (shouldOpenPoll()) { - void openPollForReaction(trigger.reaction); + void openPollForReaction(reaction); } }; diff --git a/docs-snippets/use-cases/fan-behavior-management.ts b/docs-snippets/use-cases/fan-behavior-management.ts index 5e4b82e01..b4b638b78 100644 --- a/docs-snippets/use-cases/fan-behavior-management.ts +++ b/docs-snippets/use-cases/fan-behavior-management.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; // Restricting what a fan may do is a server-side operation, so this client is // configured with the keyset's secret key and runs on your own infrastructure. @@ -16,75 +16,224 @@ const fanClient = new PubNub({ userId: 'fan-42', }); +// snippet.fanBehaviorStateStore +import fs from 'node:fs'; + +// A real token service tracks issued tokens and each fan's status in a database. +// This tutorial persists the same information in a JSON file next to the script, +// so `grant`, `mute`, `unmute`, and `ban` share state across separate `node server.js` +// invocations without needing a database just to run the tutorial. +const stateFilePath = new URL('./fan-state.json', import.meta.url); + +function loadState() { + try { + return JSON.parse(fs.readFileSync(stateFilePath, 'utf8')); + } catch { + return {}; + } +} + +function saveState(state = {}) { + fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2)); +} +// snippet.end + // snippet.fanBehaviorGrantChatAccess -try { - const token = await server.grantToken({ - ttl: 60, - authorizedUserId: 'fan-42', - resources: { - channels: { - 'game.chat': { read: true, write: true }, +async function grantChatAccess(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + if (entry.status === 'banned') { + console.log(`${userId} is banned, so no token was issued`); + return; + } + + const canWrite = entry.status !== 'muted'; + + try { + const token = await server.grantToken({ + ttl: 60, + authorizedUserId: userId, + resources: { + channels: { + 'game.chat': { read: true, write: canWrite }, + }, }, - }, - }); - console.log('token that allows reading and writing chat:', token); -} catch (error) { - console.error( - `Granting chat access failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + }); + + entry.status = entry.status ?? 'active'; + entry.tokens = [...entry.tokens, { token, write: canWrite }]; + state[userId] = entry; + saveState(state); + + console.log(`token that allows ${canWrite ? 'reading and writing' : 'reading'} chat:`, token); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Granting chat access failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end // snippet.fanBehaviorMuteFan -try { - const token = await server.grantToken({ - ttl: 15, - authorizedUserId: 'fan-42', - resources: { - channels: { - 'game.chat': { read: true }, +async function muteFan(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + if (entry.status === 'banned') { + console.log(`${userId} is already banned, so there is nothing left to mute`); + return; + } + + const writableTokens = entry.tokens.filter((issued = { token: '', write: false }) => issued.write); + + for (const issued of writableTokens) { + try { + await server.revokeToken(issued.token); + console.log('revoked an outstanding writable token'); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Revoking a writable token failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } + } + + entry.status = 'muted'; + entry.tokens = entry.tokens.filter((issued = { token: '', write: false }) => !issued.write); + + try { + const token = await server.grantToken({ + ttl: 15, + authorizedUserId: userId, + resources: { + channels: { + 'game.chat': { read: true }, + }, }, - }, - }); - console.log('token that allows reading chat but not writing to it:', token); -} catch (error) { - console.error( - `Muting the fan failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + }); + + entry.tokens = [...entry.tokens, { token, write: false }]; + state[userId] = entry; + saveState(state); + + console.log('token that allows reading chat but not writing to it:', token); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Muting the fan failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end -// snippet.fanBehaviorRevokeToken -try { - const response = await server.revokeToken('replace-with-the-token-to-revoke'); - console.log('token revoked:', response); -} catch (error) { - console.error( - `Revoking the token failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); +// snippet.fanBehaviorUnmuteFan +async function unmuteFan(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + // This is the one command that clears a ban as well as a mute. Calling it is always + // a deliberate decision by whoever operates server.js, never a side effect of anything + // else in this tutorial. + try { + const token = await server.grantToken({ + ttl: 60, + authorizedUserId: userId, + resources: { + channels: { + 'game.chat': { read: true, write: true }, + }, + }, + }); + + entry.status = 'active'; + entry.tokens = [...entry.tokens, { token, write: true }]; + state[userId] = entry; + saveState(state); + + console.log('token that allows reading and writing chat again:', token); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Unmuting the fan failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end -// snippet.fanBehaviorApplyToken -fanClient.setToken('replace-with-the-token-your-server-returned'); +// snippet.fanBehaviorBanFan +async function banFan(userId = '') { + const state = loadState(); + const entry = state[userId] ?? { status: 'active', tokens: [] }; + + for (const issued of entry.tokens) { + try { + await server.revokeToken(issued.token); + console.log('revoked an outstanding token'); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Revoking a token failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } + } + + entry.status = 'banned'; + entry.tokens = []; + state[userId] = entry; + saveState(state); + + console.log(`${userId} is banned. Every outstanding token, read-only and writable, is now revoked.`); +} +// snippet.end + +// snippet.fanBehaviorServerDispatch +const [, , command, userIdArgument] = process.argv; +const targetUserId = userIdArgument ?? 'fan-42'; + +if (command === 'grant') { + await grantChatAccess(targetUserId); +} else if (command === 'mute') { + await muteFan(targetUserId); +} else if (command === 'unmute') { + await unmuteFan(targetUserId); +} else if (command === 'ban') { + await banFan(targetUserId); +} else { + console.log('usage: node server.js [userId]'); +} // snippet.end // snippet.fanBehaviorHandleAccessDenied fanClient.addListener({ status: (event) => { - if (event.category === 'PNAccessDeniedCategory') { + if (event.category === 'PNConnectedCategory') { + console.log('connected to game.chat'); + } else if (event.category === 'PNAccessDeniedCategory') { console.log('this fan may no longer write to', event.affectedChannels); } }, }); // snippet.end +// snippet.fanBehaviorApplyToken +const suppliedToken = process.argv[2]; + +if (!suppliedToken) { + console.error('usage: node fan.js '); + process.exit(1); +} + +fanClient.setToken(suppliedToken); +// snippet.end + +// snippet.fanBehaviorSubscribeAndPublish +const subscription = fanClient.channel('game.chat').subscription(); +subscription.subscribe(); + +try { + const response = await fanClient.publish({ + channel: 'game.chat', + message: { text: 'Come on!' }, + }); + console.log('chat message published at timetoken:', response.timetoken); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing to game.chat failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + // snippet.fanBehaviorInspectToken const parsed = fanClient.parseToken('replace-with-the-token-to-inspect'); diff --git a/docs-snippets/use-cases/fan-re-engagement.ts b/docs-snippets/use-cases/fan-re-engagement.ts index 04fc391c8..60504ed2e 100644 --- a/docs-snippets/use-cases/fan-re-engagement.ts +++ b/docs-snippets/use-cases/fan-re-engagement.ts @@ -1,5 +1,8 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; +// Presence monitoring and the decision to alert a fan run under their own service +// identity, separate from any fan's own client, so this script's watcher is never +// the same connection whose absence it is trying to detect. const pubnub = new PubNub({ publishKey: 'demo', subscribeKey: 'demo', @@ -14,29 +17,53 @@ try { }); console.log('fans currently watching the stream:', response.totalOccupancy); } catch (error) { - console.error( - `Counting the fans watching failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Counting the fans watching failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + +// snippet.reEngagementPublishMomentAlert +async function sendMomentAlert(userId = '') { + const alertChannel = `game.moment-alerts.${userId}`; + const moment = PubNub.notificationPayload('Injury time', 'Two minutes left, and it is still 2-2.'); + + moment.sound = 'default'; + moment.apns.configurations = [{ targets: [{ topic: 'com.example.matchday' }] }]; + + try { + const response = await pubnub.publish({ + channel: alertChannel, + message: { + ...moment.buildPayload(['apns2', 'fcm']), + moment: 'injury-time', + }, + customMessageType: 'moment-alert', + }); + console.log(`moment alert published to ${alertChannel} at timetoken:`, response.timetoken); + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the moment alert failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + } } // snippet.end // snippet.reEngagementCheckOneFan -try { - const response = await pubnub.whereNow({ uuid: 'fan-42' }); +async function notifyIfAbsent(userId = '') { + try { + const response = await pubnub.whereNow({ uuid: userId }); - if (response.channels.includes('game.stream')) { - console.log('fan-42 is watching, so no alert is needed'); - } else { - console.log('fan-42 left the stream, so a push alert can bring them back'); + if (response.channels.includes('game.stream')) { + console.log(`${userId} is still subscribed to game.stream, so no alert is needed`); + return; + } + } catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Checking where ${userId} is subscribed failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + return; } -} catch (error) { - console.error( - `Checking where the fan is failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + + console.log(`${userId} is not subscribed to game.stream, so sending a moment alert`); + await sendMomentAlert(userId); } // snippet.end @@ -46,50 +73,47 @@ const streamSubscription = pubnub.channel('game.stream').subscription({ receiveP streamSubscription.onPresence = (event) => { if (event.action === 'leave' || event.action === 'timeout') { console.log(`${event.uuid} stopped watching, and ${event.occupancy} fans remain`); + void notifyIfAbsent(event.uuid); } }; streamSubscription.subscribe(); // snippet.end -// snippet.reEngagementPublishMomentAlert -const moment = PubNub.notificationPayload('Injury time', 'Two minutes left, and it is still 2-2.'); +// snippet.reEngagementFanViewer +const viewerUserId = process.argv[2] ?? 'fan-a'; -moment.sound = 'default'; -moment.apns.configurations = [{ targets: [{ topic: 'com.example.matchday' }] }]; +const viewerClient = new PubNub({ + publishKey: 'demo', + subscribeKey: 'demo', + userId: viewerUserId, +}); -try { - const response = await pubnub.publish({ - channel: 'game.moment-alerts', - message: { - ...moment.buildPayload(['apns2', 'fcm']), - moment: 'injury-time', - }, - customMessageType: 'moment-alert', - }); - console.log('moment alert published at timetoken:', response.timetoken); -} catch (error) { - console.error( - `Publishing the moment alert failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); -} -// snippet.end +const watchSubscription = viewerClient.channel('game.stream').subscription({ receivePresenceEvents: false }); +watchSubscription.subscribe(); -// snippet.reEngagementRegisterForMomentAlerts try { - const response = await pubnub.push.addChannels({ - channels: ['game.moment-alerts'], + const response = await viewerClient.push.addChannels({ + channels: [`game.moment-alerts.${viewerUserId}`], device: 'replace-with-the-fcm-registration-token', pushGateway: 'fcm', }); - console.log('device registered for moment alerts:', response); + console.log(`${viewerUserId} registered its device for game.moment-alerts.${viewerUserId}:`, response); } catch (error) { - console.error( - `Registering for moment alerts failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Registering the device failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } + +const alertSubscription = viewerClient.channel(`game.moment-alerts.${viewerUserId}`).subscription(); + +alertSubscription.onMessage = (event) => { + console.log(`${viewerUserId} received a moment alert:`, event.message); +}; + +alertSubscription.subscribe(); + +process.on('SIGINT', () => { + console.log(`${viewerUserId} left game.stream, but is still reachable for a moment alert`); + watchSubscription.unsubscribe(); +}); // snippet.end diff --git a/docs-snippets/use-cases/game-chat-moderation.ts b/docs-snippets/use-cases/game-chat-moderation.ts index fd1c314ec..f8c708623 100644 --- a/docs-snippets/use-cases/game-chat-moderation.ts +++ b/docs-snippets/use-cases/game-chat-moderation.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -6,8 +6,10 @@ const pubnub = new PubNub({ userId: 'moderator-7', }); -// Removing a message from Message Persistence is a server-side operation, so this -// client is configured with the keyset's secret key and runs on your own infrastructure. +// Removing a message from Message Persistence, and granting the token that lets +// moderator.js write hide decisions to the control channel, are both server-side +// operations, so this client is configured with the keyset's secret key and runs +// on your own infrastructure. const server = new PubNub({ publishKey: 'demo', subscribeKey: 'demo', @@ -15,32 +17,66 @@ const server = new PubNub({ userId: 'moderation-service', }); +// snippet.chatModerationGrantControlChannelAccess +try { + const token = await server.grantToken({ + ttl: 60, + authorizedUserId: 'moderator-7', + resources: { + channels: { + 'game.chat': { read: true }, + 'game.chat.moderation': { read: true, write: true }, + }, + }, + }); + console.log('token that lets moderator-7 publish hide decisions:', token); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Granting moderator access failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + +// snippet.chatModerationApplyModeratorToken +pubnub.setToken('replace-with-the-token-server-js-printed'); +// snippet.end + // snippet.chatModerationFlagMessage try { - const response = await pubnub.addMessageAction({ - channel: 'game.chat', - messageTimetoken: 'replace-with-message-timetoken', - action: { - type: 'moderation', - value: 'hidden', + const response = await pubnub.publish({ + channel: 'game.chat.moderation', + message: { + action: 'hide', + messageTimetoken: 'replace-with-message-timetoken', }, + customMessageType: 'moderation-hide', + storeInHistory: true, }); - console.log('message flagged at timetoken:', response.data.actionTimetoken); + console.log('message flagged at timetoken:', response.timetoken); } catch (error) { - console.error( - `Flagging the message failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Flagging the message failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.chatModerationReceiveModerationDecisions -const moderationSubscription = pubnub.channel('game.chat').subscription(); +const hiddenTimetokens = new Set(); + +const moderationSubscription = pubnub.channel('game.chat.moderation').subscription(); + +moderationSubscription.onMessage = (event) => { + const decision = event.message; + const action = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'action' in decision + ? decision.action + : undefined; + const messageTimetoken = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'messageTimetoken' in decision + ? decision.messageTimetoken + : undefined; -moderationSubscription.onMessageAction = (event) => { - if (event.data.type === 'moderation' && event.data.value === 'hidden') { - console.log('hide the message published at', event.data.messageTimetoken); + if (action === 'hide' && typeof messageTimetoken === 'string') { + hiddenTimetokens.add(messageTimetoken); + console.log('hide the message published at', messageTimetoken); } }; @@ -48,51 +84,56 @@ moderationSubscription.subscribe(); // snippet.end // snippet.chatModerationLoadHistoryWithFlags -// Requesting message actions alongside the messages adds an `actions` map to each -// entry, keyed by action type and then by action value. -type ModeratedEntry = { - timetoken: string | number; - message: unknown; - actions?: Record>; -}; - try { const response = await pubnub.fetchMessages({ - channels: ['game.chat'], + channels: ['game.chat', 'game.chat.moderation'], count: 25, - includeMessageActions: true, }); - const entries = (response.channels['game.chat'] ?? []) as ModeratedEntry[]; + const moderationEntries = response.channels['game.chat.moderation'] ?? []; + + moderationEntries.forEach((entry) => { + const decision = entry.message; + const action = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'action' in decision + ? decision.action + : undefined; + const messageTimetoken = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'messageTimetoken' in decision + ? decision.messageTimetoken + : undefined; + + if (action === 'hide' && typeof messageTimetoken === 'string') { + hiddenTimetokens.add(messageTimetoken); + } + }); + + const chatEntries = response.channels['game.chat'] ?? []; - entries.forEach((entry) => { - const hidden = entry.actions?.moderation?.hidden !== undefined; + chatEntries.forEach((entry) => { + const hidden = hiddenTimetokens.has(entry.timetoken.toString()); console.log(entry.timetoken, hidden ? '[hidden by a moderator]' : entry.message); }); } catch (error) { - console.error( - `Loading the moderated history failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Loading the moderated history failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.chatModerationDeleteMessage try { - const messageTimetoken = 17000000000000000; + const messageTimetoken = 'replace-with-message-timetoken'; + const start = (BigInt(messageTimetoken) - BigInt(1)).toString(); + const end = messageTimetoken; const response = await server.deleteMessages({ channel: 'game.chat', - start: (messageTimetoken - 1).toString(), - end: messageTimetoken.toString(), + start, + end, }); console.log('message deleted from Message Persistence:', response); } catch (error) { - console.error( - `Deleting the message failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Deleting the message failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/live-commentary.ts b/docs-snippets/use-cases/live-commentary.ts index 22ad7f894..1fe2c7588 100644 --- a/docs-snippets/use-cases/live-commentary.ts +++ b/docs-snippets/use-cases/live-commentary.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -19,11 +19,8 @@ try { }); console.log('commentary published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the commentary failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the commentary failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -35,6 +32,14 @@ commentarySubscription.onMessage = (event) => { console.log(`[${event.timetoken}] ${JSON.stringify(event.message)}`); }; +pubnub.addListener({ + status: (event) => { + if (event.category === 'PNConnectedCategory') { + console.log('connected and ready to receive commentary'); + } + }, +}); + commentarySubscription.subscribe(); // snippet.end @@ -51,14 +56,15 @@ try { console.log(entry.timetoken, entry.message); }); } catch (error) { - console.error( - `Loading the commentary backlog failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Loading the commentary backlog failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.liveCommentaryUnsubscribe -commentarySubscription.unsubscribe(); +process.on('SIGINT', () => { + console.log('viewer shutting down, closing the commentary subscription'); + commentarySubscription.unsubscribe(); + process.exit(0); +}); // snippet.end diff --git a/docs-snippets/use-cases/live-event-rate-limiting.ts b/docs-snippets/use-cases/live-event-rate-limiting.ts index 1f2930ed9..87da6f1f8 100644 --- a/docs-snippets/use-cases/live-event-rate-limiting.ts +++ b/docs-snippets/use-cases/live-event-rate-limiting.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -17,16 +17,13 @@ try { console.log(`${channel} holds ${data.occupancy} fans`); }); } catch (error) { - console.error( - `Reading the shard occupancy failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Reading the shard occupancy failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.rateLimitingPickShardForFan -async function pickShardForFan(shardCount: number, maxFansPerShard: number) { +async function pickShardForFan(shardCount = 0, maxFansPerShard = 0) { const channels = Array.from({ length: shardCount }, (_, index) => `game.chat.shard-${index}`); const response = await pubnub.hereNow({ channels, includeUUIDs: false }); @@ -49,7 +46,7 @@ console.log('this fan joins', shard); const minimumMillisecondsBetweenMessages = 2000; let lastPublishedAt = 0; -async function sendChatMessage(text: string) { +async function sendChatMessage(text = '') { const now = Date.now(); if (now - lastPublishedAt < minimumMillisecondsBetweenMessages) { diff --git a/docs-snippets/use-cases/live-polls.ts b/docs-snippets/use-cases/live-polls.ts index ee1a90165..aaeb050ae 100644 --- a/docs-snippets/use-cases/live-polls.ts +++ b/docs-snippets/use-cases/live-polls.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -27,11 +27,8 @@ try { }); console.log('poll published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the poll failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -59,11 +56,8 @@ try { console.log('poll that is already open:', entries[0].message); } } catch (error) { - console.error( - `Fetching the open poll failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Fetching the open poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -76,11 +70,8 @@ try { }); console.log('vote published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the vote failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the vote failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -98,11 +89,8 @@ try { }); console.log('results published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the results failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the results failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/match-stats.ts b/docs-snippets/use-cases/match-stats.ts index 32e0336d1..c3880d3f1 100644 --- a/docs-snippets/use-cases/match-stats.ts +++ b/docs-snippets/use-cases/match-stats.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -16,11 +16,8 @@ try { }); console.log('score published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the stat failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the stat failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -55,10 +52,7 @@ try { } }); } catch (error) { - console.error( - `Fetching the current stats failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Fetching the current stats failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/real-time-ads.ts b/docs-snippets/use-cases/real-time-ads.ts index 8559b9c7f..786a21e42 100644 --- a/docs-snippets/use-cases/real-time-ads.ts +++ b/docs-snippets/use-cases/real-time-ads.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -15,24 +15,27 @@ try { }); console.log('reaction published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the reaction failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the reaction failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end // snippet.realTimeAdsReceiveAdDecision -type AdDecision = { adId: number; clickPoints: number }; - const adSubscription = pubnub.channel('game.ad-decisions').subscription({ receivePresenceEvents: false }); adSubscription.onMessage = (event) => { - const decision = event.message as AdDecision; + const decision = event.message; + const adId = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'adId' in decision + ? decision.adId + : undefined; + const clickPoints = + typeof decision === 'object' && decision !== null && !Array.isArray(decision) && 'clickPoints' in decision + ? decision.clickPoints + : undefined; - if (decision.adId) { - console.log(`show ad ${decision.adId}, worth ${decision.clickPoints} points`); + if (adId) { + console.log(`show ad ${adId}, worth ${clickPoints} points`); } else { console.log('no ad to show, so clear the ad slot'); } @@ -42,14 +45,20 @@ adSubscription.subscribe(); // snippet.end // snippet.realTimeAdsReceiveReactionUpgrade -type ReactionUpgrade = { reaction: string; replacement: string }; - const upgradeSubscription = pubnub.channel('game.reaction-upgrades').subscription({ receivePresenceEvents: false }); upgradeSubscription.onMessage = (event) => { - const upgrade = event.message as ReactionUpgrade; + const upgrade = event.message; + const reaction = + typeof upgrade === 'object' && upgrade !== null && !Array.isArray(upgrade) && 'reaction' in upgrade + ? upgrade.reaction + : undefined; + const replacement = + typeof upgrade === 'object' && upgrade !== null && !Array.isArray(upgrade) && 'replacement' in upgrade + ? upgrade.replacement + : undefined; - console.log(`render ${upgrade.reaction} as ${upgrade.replacement} from now on`); + console.log(`render ${reaction} as ${replacement} from now on`); }; upgradeSubscription.subscribe(); diff --git a/docs-snippets/use-cases/real-time-chat.ts b/docs-snippets/use-cases/real-time-chat.ts index 4345ce9a4..7af2ed4d2 100644 --- a/docs-snippets/use-cases/real-time-chat.ts +++ b/docs-snippets/use-cases/real-time-chat.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -17,11 +17,8 @@ try { }); console.log('channel metadata set:', response.data); } catch (error) { - console.error( - `Setting the channel metadata failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Setting the channel metadata failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -37,11 +34,8 @@ try { }); console.log('fan profile set:', response.data); } catch (error) { - console.error( - `Setting the fan profile failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Setting the fan profile failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -72,11 +66,8 @@ try { }); console.log('chat message published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the chat message failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the chat message failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -94,11 +85,8 @@ try { console.log(entry.timetoken, entry.message); }); } catch (error) { - console.error( - `Loading recent messages failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Loading recent messages failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -110,11 +98,8 @@ try { }); console.log('fans in the chat:', response.totalOccupancy); } catch (error) { - console.error( - `Counting the fans online failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Counting the fans online failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -130,11 +115,8 @@ try { }); console.log('reaction added at timetoken:', response.data.actionTimetoken); } catch (error) { - console.error( - `Adding the reaction failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Adding the reaction failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end diff --git a/docs-snippets/use-cases/score-alerts.ts b/docs-snippets/use-cases/score-alerts.ts index d97b1895a..61cae46b0 100644 --- a/docs-snippets/use-cases/score-alerts.ts +++ b/docs-snippets/use-cases/score-alerts.ts @@ -1,4 +1,4 @@ -import PubNub, { PubNubError } from '../../lib/types'; +import PubNub from '../../lib/types'; const pubnub = new PubNub({ publishKey: 'demo', @@ -6,22 +6,32 @@ const pubnub = new PubNub({ userId: 'fan-42', }); +// snippet.scoreAlertsEnvironmentConstant +// Every APNs call below, device registration, the notification payload's target, +// listing, and removal, reads this same value. An iOS device token only works in the +// APNs environment that issued it: a development (sandbox) token comes from a +// debug or development-signed build and only works with environment: 'development'; +// a production token comes from a TestFlight or App Store build and only works with +// environment: 'production'. Registering with one value and publishing toward the +// other is why a registration can succeed while the notification it's supposed to +// produce never arrives. Change this one constant when you move from a development +// build to a TestFlight or App Store build, rather than editing every call below. +const APNS_ENVIRONMENT = 'development'; +// snippet.end + // snippet.scoreAlertsRegisterDeviceAPNs try { const response = await pubnub.push.addChannels({ channels: ['game.score-alerts'], device: 'replace-with-the-apns-device-token', pushGateway: 'apns2', - environment: 'production', + environment: APNS_ENVIRONMENT, topic: 'com.example.matchday', }); console.log('iOS device registered for score alerts:', response); } catch (error) { - console.error( - `Registering the iOS device failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Registering the iOS device failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -34,11 +44,8 @@ try { }); console.log('Android device registered for score alerts:', response); } catch (error) { - console.error( - `Registering the Android device failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Registering the Android device failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end @@ -46,7 +53,7 @@ try { const goal = PubNub.notificationPayload('Leeds score!', 'Southampton 0 - 2 Leeds'); goal.sound = 'default'; -goal.apns.configurations = [{ targets: [{ topic: 'com.example.matchday' }] }]; +goal.apns.configurations = [{ targets: [{ topic: 'com.example.matchday', environment: APNS_ENVIRONMENT }] }]; const payload = goal.buildPayload(['apns2', 'fcm']); @@ -66,15 +73,27 @@ try { }); console.log('score alert published at timetoken:', response.timetoken); } catch (error) { - console.error( - `Publishing the score alert failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Publishing the score alert failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + +// snippet.scoreAlertsListDeviceRegistrationsAPNs +try { + const response = await pubnub.push.listChannels({ + device: 'replace-with-the-apns-device-token', + pushGateway: 'apns2', + environment: APNS_ENVIRONMENT, + topic: 'com.example.matchday', + }); + console.log('this device receives alerts on:', response.channels); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end -// snippet.scoreAlertsListDeviceRegistrations +// snippet.scoreAlertsListDeviceRegistrationsFCM try { const response = await pubnub.push.listChannels({ device: 'replace-with-the-fcm-registration-token', @@ -82,15 +101,28 @@ try { }); console.log('this device receives alerts on:', response.channels); } catch (error) { - console.error( - `Listing the device registrations failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`); +} +// snippet.end + +// snippet.scoreAlertsRemoveDeviceRegistrationAPNs +try { + const response = await pubnub.push.removeChannels({ + channels: ['game.score-alerts'], + device: 'replace-with-the-apns-device-token', + pushGateway: 'apns2', + environment: APNS_ENVIRONMENT, + topic: 'com.example.matchday', + }); + console.log('device no longer receives score alerts:', response); +} catch (error) { + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end -// snippet.scoreAlertsRemoveDeviceRegistration +// snippet.scoreAlertsRemoveDeviceRegistrationFCM try { const response = await pubnub.push.removeChannels({ channels: ['game.score-alerts'], @@ -99,10 +131,7 @@ try { }); console.log('device no longer receives score alerts:', response); } catch (error) { - console.error( - `Removing the device registration failed: ${error}.${ - (error as PubNubError).status ? ` Additional information: ${(error as PubNubError).status}` : '' - }`, - ); + const status = error instanceof Error && 'status' in error ? error.status : undefined; + console.error(`Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`); } // snippet.end From e6c8873f67c05fb1179314ee95c20954417e7d6b Mon Sep 17 00:00:00 2001 From: Mateusz Wiktor Date: Wed, 23 Sep 2026 09:38:55 +0200 Subject: [PATCH 2/3] Keep tracking tokens whose revoke fails during mute and ban A failed revokeToken call dropped the token from the registry, leaving a valid writable token untracked. Mute also saved its status only after the read-only grant succeeded. Record the status first and keep unrevoked tokens so a repeated mute or ban retries them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../use-cases/fan-behavior-management.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs-snippets/use-cases/fan-behavior-management.ts b/docs-snippets/use-cases/fan-behavior-management.ts index b4b638b78..b153f990a 100644 --- a/docs-snippets/use-cases/fan-behavior-management.ts +++ b/docs-snippets/use-cases/fan-behavior-management.ts @@ -85,6 +85,7 @@ async function muteFan(userId = '') { } const writableTokens = entry.tokens.filter((issued = { token: '', write: false }) => issued.write); + const stillValid = []; for (const issued of writableTokens) { try { @@ -93,11 +94,20 @@ async function muteFan(userId = '') { } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; console.error(`Revoking a writable token failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + // Keep tracking a token you couldn't revoke, so the next mute or ban retries it. + stillValid.push(issued); } } + // Record the mute before issuing anything new, so a later grant can't hand out write access. entry.status = 'muted'; - entry.tokens = entry.tokens.filter((issued = { token: '', write: false }) => !issued.write); + entry.tokens = [...entry.tokens.filter((issued = { token: '', write: false }) => !issued.write), ...stillValid]; + state[userId] = entry; + saveState(state); + + if (stillValid.length > 0) { + console.error(`${stillValid.length} writable token(s) are still valid. Run mute again to retry revoking them.`); + } try { const token = await server.grantToken({ @@ -159,6 +169,8 @@ async function banFan(userId = '') { const state = loadState(); const entry = state[userId] ?? { status: 'active', tokens: [] }; + const stillValid = []; + for (const issued of entry.tokens) { try { await server.revokeToken(issued.token); @@ -166,15 +178,21 @@ async function banFan(userId = '') { } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; console.error(`Revoking a token failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + // Keep tracking a token you couldn't revoke, so the next ban retries it. + stillValid.push(issued); } } entry.status = 'banned'; - entry.tokens = []; + entry.tokens = stillValid; state[userId] = entry; saveState(state); - console.log(`${userId} is banned. Every outstanding token, read-only and writable, is now revoked.`); + if (stillValid.length > 0) { + console.error(`${userId} is banned, but ${stillValid.length} token(s) are still valid. Run ban again to retry revoking them.`); + } else { + console.log(`${userId} is banned. Every outstanding token, read-only and writable, is now revoked.`); + } } // snippet.end From b0616a3a8d27993236c46bac0c1c7a05d5de3a11 Mon Sep 17 00:00:00 2001 From: Mateusz Wiktor Date: Wed, 23 Sep 2026 13:12:19 +0200 Subject: [PATCH 3/3] Wrap long console.error lines flagged by Codacy Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-snippets/use-cases/automated-polling.ts | 4 +++- .../use-cases/fan-behavior-management.ts | 4 +++- docs-snippets/use-cases/fan-re-engagement.ts | 4 +++- docs-snippets/use-cases/score-alerts.ts | 16 ++++++++++++---- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs-snippets/use-cases/automated-polling.ts b/docs-snippets/use-cases/automated-polling.ts index 3d1ff65f5..c188efb59 100644 --- a/docs-snippets/use-cases/automated-polling.ts +++ b/docs-snippets/use-cases/automated-polling.ts @@ -53,7 +53,9 @@ async function openPollForReaction(reaction = '') { console.log('triggered poll published at timetoken:', response.timetoken); } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; - console.error(`Publishing the triggered poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + console.error( + `Publishing the triggered poll failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); } } // snippet.end diff --git a/docs-snippets/use-cases/fan-behavior-management.ts b/docs-snippets/use-cases/fan-behavior-management.ts index b153f990a..f9656885e 100644 --- a/docs-snippets/use-cases/fan-behavior-management.ts +++ b/docs-snippets/use-cases/fan-behavior-management.ts @@ -189,7 +189,9 @@ async function banFan(userId = '') { saveState(state); if (stillValid.length > 0) { - console.error(`${userId} is banned, but ${stillValid.length} token(s) are still valid. Run ban again to retry revoking them.`); + console.error( + `${userId} is banned, but ${stillValid.length} token(s) are still valid. Run ban again to retry revoking them.`, + ); } else { console.log(`${userId} is banned. Every outstanding token, read-only and writable, is now revoked.`); } diff --git a/docs-snippets/use-cases/fan-re-engagement.ts b/docs-snippets/use-cases/fan-re-engagement.ts index 60504ed2e..919f68bb5 100644 --- a/docs-snippets/use-cases/fan-re-engagement.ts +++ b/docs-snippets/use-cases/fan-re-engagement.ts @@ -58,7 +58,9 @@ async function notifyIfAbsent(userId = '') { } } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; - console.error(`Checking where ${userId} is subscribed failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + console.error( + `Checking where ${userId} is subscribed failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); return; } diff --git a/docs-snippets/use-cases/score-alerts.ts b/docs-snippets/use-cases/score-alerts.ts index 61cae46b0..98c0dd2eb 100644 --- a/docs-snippets/use-cases/score-alerts.ts +++ b/docs-snippets/use-cases/score-alerts.ts @@ -89,7 +89,9 @@ try { console.log('this device receives alerts on:', response.channels); } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; - console.error(`Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + console.error( + `Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); } // snippet.end @@ -102,7 +104,9 @@ try { console.log('this device receives alerts on:', response.channels); } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; - console.error(`Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + console.error( + `Listing the device registrations failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); } // snippet.end @@ -118,7 +122,9 @@ try { console.log('device no longer receives score alerts:', response); } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; - console.error(`Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + console.error( + `Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); } // snippet.end @@ -132,6 +138,8 @@ try { console.log('device no longer receives score alerts:', response); } catch (error) { const status = error instanceof Error && 'status' in error ? error.status : undefined; - console.error(`Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`); + console.error( + `Removing the device registration failed: ${error}${status ? ` Additional information: ${status}` : ''}`, + ); } // snippet.end