Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,25 @@ PNPM_FLAGS=--shamefully-hoist
#
# readonly key for membership base:
# MEMBERSHIP_AIRTABLE_API_KEY=token
#
# Webhooks (Slack / Zoom / co-working bot):
#
# These power the Netlify Functions in /netlify/functions/. Most are only needed if you're
# working on those functions locally — production values live in Netlify's env settings.
#
# Slack app credentials (https://api.slack.com/apps):
# SLACK_BOT_TOKEN=xoxb-...
# SLACK_SIGNING_SECRET=...
# SLACK_ANNOUNCEMENTS_CHANNEL=C...
# SLACK_EVENT_ADMIN_CHANNEL=C...
# SLACK_JOIN_LINK=https://join.slack.com/t/...
#
# Zoom app credentials (https://marketplace.zoom.us/develop/apps):
# ZOOM_WEBHOOK_SECRET_TOKEN=...
# ZOOM_WEBHOOK_AUTH=...
# ZOOM_TUESDAYS=https://zoom.us/j/...
# ZOOM_THURSDAYS=https://zoom.us/j/...
#
# Airtable base for co-working rooms (used by scripts/build-rooms.ts and the zoom webhook handler):
# AIRTABLE_COWORKING_BASE=app...
#
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,25 @@ All of the data points have mock data that is used if the required API key isn't

If you'd like to work on a feature that requires an API key, please reach out to a maintainer and we can probably get that going.

## Webhooks

Netlify Functions in `netlify/functions/` handle webhook events for the Slack and Zoom integrations, plus scheduled event reminders. Shared utilities and types live in `netlify/functions/_shared/`.

HTTP endpoints (rewrites configured in `netlify.toml`):

- **`/slack-events`** — Slack Events API. Currently handles `team_join` (welcome message) and `app_home_opened` (publishes the welcome view to a member's App Home).
- **`/slack-interactivity`** — Slack interactivity URL. Shares the same handler as `/slack-events`; required to keep buttons in Slack messages working.
- **`/zoom-meeting-webhook-handler`** — Zoom meeting webhooks. Tracks `meeting.{started,ended,participant_joined,participant_left}` for the co-working room and posts/updates Slack messages and Airtable records.
- **`/join-coffee`** and **`/join-slack`** — short-link redirects to the Tuesday/Thursday Zoom rooms and the Slack invite link.

Scheduled functions (cron schedules declared inline via `export const config`):

- **`event-reminders-daily`** — `0 12 * * *` (12pm UTC daily). Posts that day's events to the announcements channel; skips Mondays since the weekly reminder runs that day.
- **`event-reminders-hourly`** — `50 * * * *` (50 minutes past every hour). Posts upcoming events starting in the next hour.
- **`event-reminders-weekly`** — `0 12 * * 1` (Monday 12pm UTC). Posts the week's events.

The `scripts/build-rooms.ts` prebuild step pulls co-working room records from Airtable into `data/rooms.json`, which is bundled into the Zoom function via `included_files` in `netlify.toml`.

## Adding content

### Resources
Expand Down
2 changes: 2 additions & 0 deletions data/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
26 changes: 17 additions & 9 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,8 @@
autoLaunch = false
framework="next"

# [functions]
# node_bundler = "esbuild"

# [functions.server]
# included_files = ["app/routes/**/*.mdx"]

# [functions."data-members"]
# external_node_modules = ["shiki"]
# included_files = ["members/**/*.{js,json}"]
[functions]
included_files = ["data/*.json"]

[[headers]]
for = "/_next/static/*"
Expand Down Expand Up @@ -218,6 +211,21 @@
to = "/.netlify/functions/join-slack"
status = 200

[[redirects]]
from = "/zoom-meeting-webhook-handler"
to = "/.netlify/functions/zoom-meeting-webhook-handler"
status = 200

[[redirects]]
from = "/slack-interactivity"
to = "/.netlify/functions/slack"
status = 200

[[redirects]]
from = "/slack-events"
to = "/.netlify/functions/slack"
status = 200

[[redirects]]
from = "/plausible/js/script.js"
to = "https://plausible.io/js/script.js"
Expand Down
31 changes: 31 additions & 0 deletions netlify/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
declare namespace NodeJS {
interface ProcessEnv {
// CMS
CMS_URL?: string;
CMS_TOKEN?: string;

// Slack
SLACK_BOT_TOKEN?: string;
SLACK_SIGNING_SECRET?: string;
SLACK_ANNOUNCEMENTS_CHANNEL?: string;
SLACK_EVENT_ADMIN_CHANNEL?: string;
SLACK_JOIN_LINK?: string;

// Zoom
ZOOM_WEBHOOK_SECRET_TOKEN?: string;
ZOOM_WEBHOOK_AUTH?: string;
ZOOM_TUESDAYS?: string;
ZOOM_THURSDAYS?: string;

// Airtable
AIRTABLE_COWORKING_BASE?: string;

// Test overrides
TEST_SLACK_BOT_TOKEN?: string;
TEST_SLACK_SIGNING_SECRET?: string;
TEST_SLACK_ANNOUNCEMENTS_CHANNEL?: string;
TEST_SLACK_EVENT_ADMIN_CHANNEL?: string;
TEST_ZOOM_WEBHOOK_SECRET_TOKEN?: string;
TEST_ZOOM_WEBHOOK_AUTH?: string;
}
}
7 changes: 7 additions & 0 deletions netlify/functions/_shared/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function requireEnv(name: keyof typeof process.env): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
18 changes: 18 additions & 0 deletions netlify/functions/_shared/slack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { webApi } from '@slack/bolt';

const SLACK_BOT_TOKEN =
process.env.TEST_SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN;

const web = new webApi.WebClient(SLACK_BOT_TOKEN);

export async function postMessage(message: webApi.ChatPostMessageArguments) {
return web.chat.postMessage(message);
}

export async function updateMessage(message: webApi.ChatUpdateArguments) {
return web.chat.update(message);
}

export async function publishView(message: webApi.ViewsPublishArguments) {
return web.views.publish(message);
}
22 changes: 22 additions & 0 deletions netlify/functions/_shared/types/cms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export interface CalendarsResponse {
solspace_calendar: {
calendars: Array<{ handle: string }>;
};
}

export interface CalendarEvent {
id: string;
title: string;
startDateLocalized: string;
endDateLocalized: string;
eventCalendarDescription: string;
eventJoinLink?: string;
eventZoomHostCode?: string;
eventSlackAnnouncementsChannelId?: string;
}

export interface EventsResponse {
solspace_calendar: {
events: CalendarEvent[];
};
}
15 changes: 15 additions & 0 deletions netlify/functions/_shared/types/room.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface Room {
ZoomMeetingId: number;
SlackChannelId: string;
ZoomMeetingInviteUrl: string;
MessageSessionStarted: string;
MessageSessionEnded: string;
ButtonJoin: string;
ButtonStartNew: string;
NoticeTitle: string;
NoticeBody: string;
NoticeConfirm: string;
NoticeCancel: string;
ContextBody?: string;
record_id: string;
}
77 changes: 77 additions & 0 deletions netlify/functions/_shared/verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import crypto from 'node:crypto';

/**
* Core HMAC-SHA256 verification. Computes `v0=HMAC(secret, message)` and
* performs a timing-safe comparison against the expected signature.
*/
export function verifyHmacSignature(
secret: string,
message: string,
expectedSignature: string,
): boolean {
const computed =
'v0=' +
crypto.createHmac('sha256', secret).update(message, 'utf8').digest('hex');

if (computed.length !== expectedSignature.length) {
return false;
}

return crypto.timingSafeEqual(
Buffer.from(computed, 'utf8'),
Buffer.from(expectedSignature, 'utf8'),
);
}

/**
* Verifies a Slack request signature. Checks timestamp staleness (>300s)
* then validates the HMAC signature.
*/
export function verifySlackRequest(
rawBody: string,
headers: Headers,
secret: string,
): { valid: true } | { valid: false; reason: string } {
const slackSignature = headers.get('x-slack-signature');
const timestamp = headers.get('x-slack-request-timestamp');

const time = Math.floor(Date.now() / 1000);
if (!timestamp || Math.abs(time - Number(timestamp)) > 300) {
return { valid: false, reason: 'Ignore this request.' };
}

const message = `v0:${timestamp}:${rawBody}`;

if (slackSignature && verifyHmacSignature(secret, message, slackSignature)) {
return { valid: true };
}

return { valid: false, reason: 'Verification Failed.' };
}

/**
* Verifies a Zoom webhook signature using the x-zm-signature header.
*/
export function verifyZoomSignature(
rawBody: string,
headers: Headers,
secret: string,
): boolean {
const zmSignature = headers.get('x-zm-signature');
const zmTimestamp = headers.get('x-zm-request-timestamp');

if (!zmSignature || !zmTimestamp) {
return false;
}

const message = `v0:${zmTimestamp}:${rawBody}`;
return verifyHmacSignature(secret, message, zmSignature);
}

/**
* Computes an HMAC-SHA256 hex digest. Used for Zoom's endpoint URL
* validation challenge response.
*/
export function hmacSha256Hex(secret: string, data: string): string {
return crypto.createHmac('sha256', secret).update(data).digest('hex');
}
Loading
Loading