teqo/src/lib/googleCalendarPushNotification.ts
Francisco Solla 7629a505e0 feat(C115): edição pelo Google Calendar volta para o Teqo (motor bidirecional + webhook push)
- runSyncPass bidirecional: regra do relógio decide direção (Google mais novo
  aplica título/horário; Teqo re-asserta), cancelamento via showDeleted +
  snapshot lastSeenEventIds, registro no updates feed
- webhook público /campanha/agenda/google-webhook/[secret] fail-closed
  (secret + X-Goog headers constant-time), canal lazy sem cron (ensure em
  toda passada, renovação com id novo + stop do antigo)
- migration hand-written (campos de canal/snapshot em googleCalendarSync);
  mutationKind dedicado libera título via Google sem quebrar slug canônico
- UI: seção 'Edições pelo Google' no dialog + autor 'Google Calendar' no feed
- testes: unit (reverse mapping, validação webhook), int (motor bidirecional
  com stub, 10 cenários), e2e (webhook 404 fail-closed)
2026-08-11 22:38:53 -03:00

42 lines
1.6 KiB
TypeScript

/**
* C115 — fail-closed validation of Google Calendar push deliveries (the
* webhook route's security contract). Google echoes the channel identity on
* every notification: the URL secret, the channel id, the resource id and
* the channel token must ALL match the stored channel — compared in constant
* time, because the endpoint is a public surface. Any mismatch rejects.
*/
import { timingSafeEqual } from 'node:crypto'
type GooglePushChannelConfig = {
pushChannelSecret: string | null
pushChannelId: string | null
pushChannelResourceId: string | null
}
export type GooglePushNotificationInput = {
secret: string
channelId: string | null
resourceId: string | null
channelToken: string | null
config: GooglePushChannelConfig
}
const safeEqual = (a: string, b: string): boolean => {
const aBuffer = Buffer.from(a)
const bBuffer = Buffer.from(b)
return aBuffer.length === bBuffer.length && timingSafeEqual(aBuffer, bBuffer)
}
export const isGooglePushNotificationValid = (input: GooglePushNotificationInput): boolean => {
const { secret, channelId, resourceId, channelToken, config } = input
if (secret.length < 32) return false
if (!config.pushChannelSecret || !config.pushChannelId || !config.pushChannelResourceId) {
return false
}
if (!safeEqual(secret, config.pushChannelSecret)) return false
if (!channelId || !resourceId || !channelToken) return false
if (!safeEqual(channelId, config.pushChannelId)) return false
if (!safeEqual(resourceId, config.pushChannelResourceId)) return false
if (!safeEqual(channelToken, config.pushChannelSecret)) return false
return true
}