From bdb0052db58235cc838d1813b0af11a5de106bb3 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Mon, 17 Aug 2026 16:51:40 +0000
Subject: [PATCH 01/10] Host @dsgt Slack bot on Firebase App Hosting
Restore apps/dsgt-slack and serve Slack Events API, slash commands, and
interactivity at /api/webhooks/slack on sites/mainweb. Production uses
HTTP (not Socket Mode) with SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET
from Secret Manager.
---
README.md | 1 +
apphosting.yaml | 13 +
apps/dsgt-slack/.env.example | 22 +
apps/dsgt-slack/README.md | 183 ++++
apps/dsgt-slack/eslint.config.mjs | 12 +
apps/dsgt-slack/manifest.yaml | 39 +
apps/dsgt-slack/package.json | 36 +
apps/dsgt-slack/src/app.ts | 69 ++
apps/dsgt-slack/src/env.ts | 57 ++
apps/dsgt-slack/src/http.test.ts | 167 ++++
apps/dsgt-slack/src/http.ts | 211 ++++
apps/dsgt-slack/src/index.ts | 38 +
apps/dsgt-slack/src/replies.test.ts | 42 +
apps/dsgt-slack/src/replies.ts | 94 ++
apps/dsgt-slack/tsconfig.json | 9 +
apps/dsgt-slack/turbo.json | 14 +
apps/dsgt-slack/vitest.config.ts | 7 +
docs/README.md | 6 +
docs/architecture.md | 10 +-
docs/getting-started.md | 4 +-
docs/operations/deployment.md | 10 +-
docs/operations/environment.md | 11 +
docs/operations/testing.md | 8 +-
docs/sites/mainweb.md | 3 +-
package.json | 2 +-
pnpm-lock.yaml | 898 +++++++++++++++++-
.../app/(portal)/api/webhooks/slack/route.ts | 30 +
sites/mainweb/next.config.mjs | 9 +-
sites/mainweb/package.json | 1 +
turbo.json | 6 +-
30 files changed, 2000 insertions(+), 12 deletions(-)
create mode 100644 apps/dsgt-slack/.env.example
create mode 100644 apps/dsgt-slack/README.md
create mode 100644 apps/dsgt-slack/eslint.config.mjs
create mode 100644 apps/dsgt-slack/manifest.yaml
create mode 100644 apps/dsgt-slack/package.json
create mode 100644 apps/dsgt-slack/src/app.ts
create mode 100644 apps/dsgt-slack/src/env.ts
create mode 100644 apps/dsgt-slack/src/http.test.ts
create mode 100644 apps/dsgt-slack/src/http.ts
create mode 100644 apps/dsgt-slack/src/index.ts
create mode 100644 apps/dsgt-slack/src/replies.test.ts
create mode 100644 apps/dsgt-slack/src/replies.ts
create mode 100644 apps/dsgt-slack/tsconfig.json
create mode 100644 apps/dsgt-slack/turbo.json
create mode 100644 apps/dsgt-slack/vitest.config.ts
create mode 100644 sites/mainweb/app/(portal)/api/webhooks/slack/route.ts
diff --git a/README.md b/README.md
index 78acdc68..073e7887 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ Two Next.js sites share one Postgres database and four internal packages. Club m
| --- | --- | --- |
| `sites/mainweb` | `web` | Public club site + authenticated portal (App Hosting) |
| `sites/hacklytics2027` | `hacklytics2027` | Hacklytics 2027 marketing site, static export (Firebase Hosting) |
+| `apps/dsgt-slack` | `@query/dsgt-slack` | `@dsgt` Slack bot (HTTP webhook on mainweb in production) |
| `packages/api` | `@query/api` | tRPC routers, middleware, pricing |
| `packages/auth` | `@query/auth` | NextAuth (Google, GitHub, email codes) |
| `packages/db` | `@query/db` | Drizzle schema, client, membership rules |
diff --git a/apphosting.yaml b/apphosting.yaml
index b987633f..0055fee0 100644
--- a/apphosting.yaml
+++ b/apphosting.yaml
@@ -57,6 +57,19 @@ env:
secret: projects/672446353769/secrets/NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
- variable: STRIPE_WEBHOOK_SECRET
secret: projects/672446353769/secrets/STRIPE_WEBHOOK_SECRET
+ # Slack Events API for @dsgt (HTTP, not Socket Mode). Create these secrets
+ # once before the deploy that references them, or App Hosting cannot start:
+ # gcloud secrets create SLACK_BOT_TOKEN --project=dsgt-website
+ # gcloud secrets create SLACK_SIGNING_SECRET --project=dsgt-website
+ # firebase apphosting:secrets:grantaccess SLACK_BOT_TOKEN --backend query
+ # firebase apphosting:secrets:grantaccess SLACK_SIGNING_SECRET --backend query
+ # Steps and Request URL: apps/dsgt-slack/README.md
+ - variable: SLACK_BOT_TOKEN
+ secret: projects/672446353769/secrets/SLACK_BOT_TOKEN
+ - variable: SLACK_SIGNING_SECRET
+ secret: projects/672446353769/secrets/SLACK_SIGNING_SECRET
+ - variable: SLACK_SOCKET_MODE
+ value: "false"
- variable: NODE_ENV
value: production
# Consumer Gmail, which caps around 500 recipients a day — shared between
diff --git a/apps/dsgt-slack/.env.example b/apps/dsgt-slack/.env.example
new file mode 100644
index 00000000..a6ccd24a
--- /dev/null
+++ b/apps/dsgt-slack/.env.example
@@ -0,0 +1,22 @@
+# Copy to .env and fill in values from https://api.slack.com/apps
+# Never commit .env or real tokens.
+
+# Bot User OAuth Token (xoxb-...) from OAuth & Permissions after install
+SLACK_BOT_TOKEN=xoxb-your-bot-token
+
+# Signing Secret from Basic Information
+SLACK_SIGNING_SECRET=your-signing-secret
+
+# App-level token (xapp-...) from Basic Information → App-Level Tokens
+# Required only for local Socket Mode. Create a token with the connections:write
+# scope. Not used in production (HTTP Events API on Firebase App Hosting).
+SLACK_APP_TOKEN=xapp-your-app-token
+
+# Local default is Socket Mode. Production App Hosting sets this to false.
+# SLACK_APP_TOKEN is not required when this is false.
+SLACK_SOCKET_MODE=true
+
+# Used only when running this package's local HTTP receiver
+# (SLACK_SOCKET_MODE=false). Production Request URL is the App Hosting
+# origin (AUTH_URL, host datasciencegt.org) plus /api/webhooks/slack.
+PORT=3000
diff --git a/apps/dsgt-slack/README.md b/apps/dsgt-slack/README.md
new file mode 100644
index 00000000..7aa1fe82
--- /dev/null
+++ b/apps/dsgt-slack/README.md
@@ -0,0 +1,183 @@
+# dsgt Slack bot
+
+Bolt for JavaScript app for **Data Science @ Georgia Tech (DS@GT)**. In Slack it
+appears as **@dsgt**. This is a custom Slack app owned by the club, not a
+third-party Slack integration.
+
+The bot answers `@dsgt` mentions, direct messages, and the `/dsgt` slash
+command. It can confirm that it is online (`ping`) and tell people how to join:
+the first event is **August 26 (8/26)**, further details will be announced
+there, and the club site is datasciencegt.org.
+
+Production traffic is **HTTP Events API** on the existing Firebase App Hosting
+backend (`sites/mainweb` / Cloud Run). There is no second Cloud Run service and
+no Socket Mode process in production.
+
+## Create the Slack app from the manifest
+
+1. Open [https://api.slack.com/apps](https://api.slack.com/apps) and sign in to
+ the workspace that should host the bot (typically the DS@GT Slack).
+2. Click **Create New App** → **From an app manifest**.
+3. Select the workspace, then paste the contents of
+ [`manifest.yaml`](./manifest.yaml) (YAML is accepted).
+4. Confirm the summary. The app name and bot user display name should both be
+ `dsgt`. Socket Mode should be **off**. Request URLs are left unset in the
+ manifest so you can create the app before the webhook is deployed.
+5. Click **Create**.
+
+You now have a real Slack app whose bot user is **dsgt**.
+
+## Install into a workspace and collect tokens
+
+1. In the app settings sidebar, open **OAuth & Permissions** and click
+ **Install to Workspace**. Approve the requested bot scopes.
+2. Copy the **Bot User OAuth Token** (`xoxb-...`). You will put this in GCP
+ Secret Manager as `SLACK_BOT_TOKEN` (and in `apps/dsgt-slack/.env` only if
+ you run the bot locally).
+3. Open **Basic Information** and copy the **Signing Secret**. That is
+ `SLACK_SIGNING_SECRET`.
+
+Do **not** commit `.env`, tokens, or signing secrets. `SLACK_APP_TOKEN` is not
+required in production.
+
+## Production Request URL (Firebase App Hosting)
+
+Slack must POST to the **Firebase App Hosting / Cloud Run** URL for `sites/mainweb`
+(workspace `web`). That origin is `{AUTH_URL}` — the same public
+origin as `AUTH_URL` / `NEXTAUTH_URL` in [`apphosting.yaml`](../../apphosting.yaml).
+
+**Use this Request URL everywhere** (Event Subscriptions, Slash Commands
+`/dsgt`, Interactivity & Shortcuts). Take the `AUTH_URL` / `NEXTAUTH_URL`
+value from [`apphosting.yaml`](../../apphosting.yaml) (the App Hosting origin
+for the club site, host `datasciencegt.org`) and append the path:
+
+```text
+{AUTH_URL}/api/webhooks/slack
+```
+
+Set those three URLs in the Slack app settings **after** the App Hosting
+deploy that includes this route is live. Slack will POST a
+`url_verification` challenge; the Next.js route answers it.
+
+The Next.js route is
+`sites/mainweb/app/(portal)/api/webhooks/stripe`’s neighbor:
+`sites/mainweb/app/(portal)/api/webhooks/slack/route.ts`. `proxy.ts` already
+leaves `/api/webhooks/*` alone, same as Stripe.
+
+### App Hosting vs Firebase Hosting
+
+| Surface | What it is | Slack |
+| --- | --- | --- |
+| **Firebase App Hosting** | Next.js on Cloud Run, `apphosting.yaml`, `sites/mainweb` | **Yes — this is the API** |
+| **Firebase Hosting** site `dsgt-website` | Static `sites/mainweb/out/` | **No.** That site cannot run the webhook |
+
+If Event Subscriptions shows a verification failure, the Request URL is almost
+certainly pointing at Hosting `out/` (or the wrong host), not App Hosting.
+
+After the App Hosting deploy that includes this route is live, open **Event
+Subscriptions** and click **Retry** / save so Slack can complete the
+`url_verification` challenge.
+
+Reinstall the app if Slack asks you to after changing URLs or scopes.
+
+## Secret Manager (one-time)
+
+GCP project: `dsgt-website` (number `672446353769`). App Hosting backend name:
+`query`. [`apphosting.yaml`](../../apphosting.yaml) maps:
+
+| Process env | Secret |
+| --- | --- |
+| `SLACK_BOT_TOKEN` | `projects/672446353769/secrets/SLACK_BOT_TOKEN` |
+| `SLACK_SIGNING_SECRET` | `projects/672446353769/secrets/SLACK_SIGNING_SECRET` |
+| `SLACK_SOCKET_MODE` | literal `false` (not a secret) |
+
+`SLACK_APP_TOKEN` is **not** wired. Create the secrets before the deploy that
+references them, or App Hosting will fail to start:
+
+```bash
+gcloud config set project dsgt-website
+
+# Create empty secrets, then add a version from stdin (do not put tokens in the
+# shell history file if you can avoid it — prefer a local file you delete).
+gcloud secrets create SLACK_BOT_TOKEN --project=dsgt-website
+gcloud secrets create SLACK_SIGNING_SECRET --project=dsgt-website
+
+printf '%s' 'xoxb-your-bot-token' | gcloud secrets versions add SLACK_BOT_TOKEN --data-file=-
+printf '%s' 'your-signing-secret' | gcloud secrets versions add SLACK_SIGNING_SECRET --data-file=-
+
+firebase apphosting:secrets:grantaccess SLACK_BOT_TOKEN --backend query
+firebase apphosting:secrets:grantaccess SLACK_SIGNING_SECRET --backend query
+```
+
+Replace the `printf` placeholders with the real values from the Slack app
+settings. Never commit those values.
+
+## Run locally (Socket Mode)
+
+Socket Mode is a **local-only** option so you can develop without a public URL.
+Production stays on HTTP.
+
+1. In the Slack app settings, turn **Socket Mode** on temporarily (or use a
+ separate development app). Generate an App-Level Token with
+ `connections:write` (`xapp-...`).
+2. From the monorepo root:
+
+```bash
+cp apps/dsgt-slack/.env.example apps/dsgt-slack/.env
+# edit apps/dsgt-slack/.env with SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET,
+# SLACK_APP_TOKEN, and SLACK_SOCKET_MODE=true
+
+pnpm install
+pnpm --filter @query/dsgt-slack dev
+```
+
+`pnpm dev` at the repo root also starts this package. If `.env` is missing, the
+task stays idle so the rest of the monorepo can still boot.
+
+You can also hit the Next.js webhook locally while `web` is running
+(`http://localhost:3001/api/webhooks/slack`) if you tunnel that URL to Slack.
+That path is what production uses.
+
+### Invite @dsgt to a channel
+
+In the channel:
+
+```text
+/invite @dsgt
+```
+
+Then mention it (`@dsgt ping`) or run `/dsgt help`. Direct messages work from
+the app's **Messages** tab without an invite.
+
+### Quick checks after install
+
+| Action | Expected reply |
+| ----------------------------------------- | ----------------------------------------------- |
+| `@dsgt ping` or `/dsgt ping` | Health check confirming the bot is online |
+| `/dsgt help` | List of topics |
+| “How do I join?” in a DM, or `/dsgt join` | August 26 first-event FAQ and datasciencegt.org |
+
+## Scripts
+
+| Script | Command |
+| --- | --- |
+| Dev (watch) | `pnpm --filter @query/dsgt-slack dev` |
+| Start | `pnpm --filter @query/dsgt-slack start` |
+| Lint | `pnpm --filter @query/dsgt-slack lint` |
+| Typecheck | `pnpm --filter @query/dsgt-slack typecheck` |
+| Test | `pnpm --filter @query/dsgt-slack test` |
+
+Repo-wide `pnpm lint`, `pnpm typecheck`, `pnpm build`, and `pnpm test` include
+this package (`pnpm test` runs the join-FAQ unit test along with the existing
+vitest targets).
+
+## Environment
+
+See [`.env.example`](./.env.example). Do not commit `.env` or real tokens.
+
+| Variable | Local Socket Mode | Production (App Hosting) |
+| --- | --- | --- |
+| `SLACK_BOT_TOKEN` | required | Secret Manager |
+| `SLACK_SIGNING_SECRET` | required | Secret Manager |
+| `SLACK_APP_TOKEN` | required | not used |
+| `SLACK_SOCKET_MODE` | `true` (default) | `false` |
diff --git a/apps/dsgt-slack/eslint.config.mjs b/apps/dsgt-slack/eslint.config.mjs
new file mode 100644
index 00000000..286e0415
--- /dev/null
+++ b/apps/dsgt-slack/eslint.config.mjs
@@ -0,0 +1,12 @@
+import { config } from "@query/eslint-config/base";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+ ...config,
+ {
+ files: ["src/index.ts"],
+ rules: {
+ "no-console": "off",
+ },
+ },
+];
diff --git a/apps/dsgt-slack/manifest.yaml b/apps/dsgt-slack/manifest.yaml
new file mode 100644
index 00000000..0508fc0d
--- /dev/null
+++ b/apps/dsgt-slack/manifest.yaml
@@ -0,0 +1,39 @@
+_metadata:
+ major_version: 1
+ minor_version: 1
+display_information:
+ name: dsgt
+ description: Official Slack bot for Data Science @ Georgia Tech (DS@GT).
+ background_color: "#003057"
+features:
+ app_home:
+ home_tab_enabled: false
+ messages_tab_enabled: true
+ messages_tab_read_only_enabled: false
+ bot_user:
+ display_name: dsgt
+ always_online: true
+ slash_commands:
+ - command: /dsgt
+ description: Ask the Data Science @ Georgia Tech bot for help.
+ usage_hint: "[help | ping | join]"
+ should_escape: false
+oauth_config:
+ scopes:
+ bot:
+ - app_mentions:read
+ - chat:write
+ - commands
+ - im:history
+ - im:read
+ - im:write
+settings:
+ event_subscriptions:
+ bot_events:
+ - app_mention
+ - message.im
+ interactivity:
+ is_enabled: true
+ org_deploy_enabled: false
+ socket_mode_enabled: false
+ token_rotation_enabled: false
diff --git a/apps/dsgt-slack/package.json b/apps/dsgt-slack/package.json
new file mode 100644
index 00000000..ecd1da50
--- /dev/null
+++ b/apps/dsgt-slack/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@query/dsgt-slack",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/replies.ts",
+ "./replies": "./src/replies.ts",
+ "./http": "./src/http.ts"
+ },
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "start": "tsx src/index.ts",
+ "build": "tsc --noEmit",
+ "lint": "eslint . --max-warnings 0",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run --config vitest.config.ts"
+ },
+ "dependencies": {
+ "@slack/bolt": "^5.0.0",
+ "@slack/web-api": "^7.13.0",
+ "dotenv": "^16.6.1",
+ "undici": "^6.27.0",
+ "zod": "3.25.53"
+ },
+ "devDependencies": {
+ "@query/eslint-config": "workspace:*",
+ "@query/tsconfig": "workspace:*",
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.15.32",
+ "eslint": "10.1.0",
+ "tsx": "^4.21.0",
+ "typescript": "6.0.2",
+ "vitest": "^4.1.8"
+ }
+}
diff --git a/apps/dsgt-slack/src/app.ts b/apps/dsgt-slack/src/app.ts
new file mode 100644
index 00000000..2ff58ab6
--- /dev/null
+++ b/apps/dsgt-slack/src/app.ts
@@ -0,0 +1,69 @@
+import { App } from "@slack/bolt";
+
+import type { SlackEnv } from "./env";
+import { replyForSlashCommand, replyForText } from "./replies";
+
+function isDirectMessage(channelType: string | undefined): boolean {
+ return channelType === "im";
+}
+
+export function registerListeners(app: App): void {
+ app.event("app_mention", async ({ event, say }) => {
+ await say({
+ text: replyForText(event.text),
+ thread_ts: event.thread_ts,
+ });
+ });
+
+ app.message(async ({ message, say }) => {
+ if (message.subtype !== undefined) {
+ return;
+ }
+ if (!("text" in message) || typeof message.text !== "string") {
+ return;
+ }
+ if ("bot_id" in message && message.bot_id) {
+ return;
+ }
+ if (!isDirectMessage(message.channel_type)) {
+ return;
+ }
+
+ await say(replyForText(message.text));
+ });
+
+ app.command("/dsgt", async ({ command, ack, respond }) => {
+ await ack();
+ await respond({
+ text: replyForSlashCommand(command.text),
+ response_type: "ephemeral",
+ });
+ });
+}
+
+export function createBoltApp(env: SlackEnv): App {
+ const app = env.socketMode
+ ? new App({
+ token: env.botToken,
+ signingSecret: env.signingSecret,
+ socketMode: true,
+ appToken: env.appToken,
+ })
+ : new App({
+ token: env.botToken,
+ signingSecret: env.signingSecret,
+ customRoutes: [
+ {
+ path: "/health",
+ method: ["GET"],
+ handler: (_req, res) => {
+ res.writeHead(200, { "Content-Type": "text/plain" });
+ res.end("ok");
+ },
+ },
+ ],
+ });
+
+ registerListeners(app);
+ return app;
+}
diff --git a/apps/dsgt-slack/src/env.ts b/apps/dsgt-slack/src/env.ts
new file mode 100644
index 00000000..398c8bb5
--- /dev/null
+++ b/apps/dsgt-slack/src/env.ts
@@ -0,0 +1,57 @@
+/**
+ * Env for the local Bolt process (Socket Mode or a local HTTP receiver).
+ * Production App Hosting does not call this — the Next.js webhook reads
+ * SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET at request time.
+ */
+
+import { z } from "zod";
+
+const truthy = (value: string | undefined) =>
+ value === undefined || value.toLowerCase() !== "false";
+
+const envSchema = z
+ .object({
+ SLACK_BOT_TOKEN: z.string().min(1),
+ SLACK_SIGNING_SECRET: z.string().min(1),
+ SLACK_APP_TOKEN: z.string().min(1).optional(),
+ SLACK_SOCKET_MODE: z.string().optional(),
+ PORT: z.coerce.number().int().positive().default(3000),
+ })
+ .superRefine((value, ctx) => {
+ if (truthy(value.SLACK_SOCKET_MODE) && !value.SLACK_APP_TOKEN) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["SLACK_APP_TOKEN"],
+ message:
+ "SLACK_APP_TOKEN is required when Socket Mode is enabled (default).",
+ });
+ }
+ });
+
+export type SlackEnv = {
+ botToken: string;
+ signingSecret: string;
+ appToken: string | undefined;
+ socketMode: boolean;
+ port: number;
+};
+
+export function loadEnv(
+ source: Record = process.env,
+): SlackEnv {
+ const parsed = envSchema.parse({
+ SLACK_BOT_TOKEN: source.SLACK_BOT_TOKEN,
+ SLACK_SIGNING_SECRET: source.SLACK_SIGNING_SECRET,
+ SLACK_APP_TOKEN: source.SLACK_APP_TOKEN || undefined,
+ SLACK_SOCKET_MODE: source.SLACK_SOCKET_MODE,
+ PORT: source.PORT,
+ });
+
+ return {
+ botToken: parsed.SLACK_BOT_TOKEN,
+ signingSecret: parsed.SLACK_SIGNING_SECRET,
+ appToken: parsed.SLACK_APP_TOKEN,
+ socketMode: truthy(parsed.SLACK_SOCKET_MODE),
+ port: parsed.PORT,
+ };
+}
diff --git a/apps/dsgt-slack/src/http.test.ts b/apps/dsgt-slack/src/http.test.ts
new file mode 100644
index 00000000..548d399a
--- /dev/null
+++ b/apps/dsgt-slack/src/http.test.ts
@@ -0,0 +1,167 @@
+import { createHmac } from "node:crypto";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ SLACK_WEBHOOK_PATH,
+ handleSlackWebhook,
+ verifySlackSignature,
+} from "./http";
+import { CLUB_SITE_URL, JOIN_FAQ_REPLY } from "./replies";
+
+const SIGNING_SECRET = "test-signing-secret";
+const FROZEN_TS = "1710000000";
+const FROZEN_NOW_MS = Number(FROZEN_TS) * 1000;
+
+function sign(rawBody: string, timestamp: string): string {
+ const digest = createHmac("sha256", SIGNING_SECRET)
+ .update(`v0:${timestamp}:${rawBody}`, "utf8")
+ .digest("hex");
+ return `v0=${digest}`;
+}
+
+function signedRequest(rawBody: string, contentType: string): Request {
+ const timestamp = String(Math.floor(Date.now() / 1000));
+ return new Request(`${CLUB_SITE_URL}${SLACK_WEBHOOK_PATH}`, {
+ method: "POST",
+ headers: {
+ "content-type": contentType,
+ "x-slack-request-timestamp": timestamp,
+ "x-slack-signature": sign(rawBody, timestamp),
+ },
+ body: rawBody,
+ });
+}
+
+describe("verifySlackSignature", () => {
+ it("accepts a matching v0 signature within the time window", () => {
+ const rawBody = `{"ok":true}`;
+ expect(
+ verifySlackSignature({
+ signingSecret: SIGNING_SECRET,
+ signature: sign(rawBody, FROZEN_TS),
+ timestamp: FROZEN_TS,
+ rawBody,
+ nowMs: FROZEN_NOW_MS,
+ }),
+ ).toBe(true);
+ });
+
+ it("rejects a missing or wrong signature", () => {
+ const rawBody = `{"ok":true}`;
+ expect(
+ verifySlackSignature({
+ signingSecret: SIGNING_SECRET,
+ signature: null,
+ timestamp: FROZEN_TS,
+ rawBody,
+ nowMs: FROZEN_NOW_MS,
+ }),
+ ).toBe(false);
+ expect(
+ verifySlackSignature({
+ signingSecret: SIGNING_SECRET,
+ signature: "v0=deadbeef",
+ timestamp: FROZEN_TS,
+ rawBody,
+ nowMs: FROZEN_NOW_MS,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("handleSlackWebhook", () => {
+ const env = { botToken: "xoxb-test", signingSecret: SIGNING_SECRET };
+
+ it("returns the url_verification challenge", async () => {
+ const rawBody = JSON.stringify({
+ type: "url_verification",
+ challenge: "abc-challenge",
+ });
+ const response = await handleSlackWebhook(
+ signedRequest(rawBody, "application/json"),
+ env,
+ );
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({
+ challenge: "abc-challenge",
+ });
+ });
+
+ it("rejects unsigned requests", async () => {
+ const response = await handleSlackWebhook(
+ new Request(`${CLUB_SITE_URL}${SLACK_WEBHOOK_PATH}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "url_verification", challenge: "nope" }),
+ }),
+ env,
+ );
+ expect(response.status).toBe(400);
+ });
+
+ it("replies to an app_mention join question with the FAQ", async () => {
+ const postMessage = vi.fn().mockResolvedValue({});
+ const rawBody = JSON.stringify({
+ type: "event_callback",
+ event: {
+ type: "app_mention",
+ text: "<@U123> how do I join?",
+ channel: "C123",
+ thread_ts: "1710000000.000100",
+ },
+ });
+
+ const response = await handleSlackWebhook(
+ signedRequest(rawBody, "application/json"),
+ env,
+ { chat: { postMessage } },
+ );
+
+ expect(response.status).toBe(200);
+ expect(postMessage).toHaveBeenCalledWith({
+ channel: "C123",
+ text: JOIN_FAQ_REPLY,
+ thread_ts: "1710000000.000100",
+ });
+ });
+
+ it("replies to a DM join question with the FAQ", async () => {
+ const postMessage = vi.fn().mockResolvedValue({});
+ const rawBody = JSON.stringify({
+ type: "event_callback",
+ event: {
+ type: "message",
+ channel_type: "im",
+ text: "How do I join the club?",
+ channel: "D123",
+ },
+ });
+
+ const response = await handleSlackWebhook(
+ signedRequest(rawBody, "application/json"),
+ env,
+ { chat: { postMessage } },
+ );
+
+ expect(response.status).toBe(200);
+ expect(postMessage).toHaveBeenCalledWith({
+ channel: "D123",
+ text: JOIN_FAQ_REPLY,
+ });
+ });
+
+ it("acks /dsgt join with the FAQ as an ephemeral response", async () => {
+ const rawBody = "command=%2Fdsgt&text=join";
+ const response = await handleSlackWebhook(
+ signedRequest(rawBody, "application/x-www-form-urlencoded"),
+ env,
+ { chat: { postMessage: vi.fn() } },
+ );
+
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual({
+ response_type: "ephemeral",
+ text: JOIN_FAQ_REPLY,
+ });
+ });
+});
diff --git a/apps/dsgt-slack/src/http.ts b/apps/dsgt-slack/src/http.ts
new file mode 100644
index 00000000..a6585e47
--- /dev/null
+++ b/apps/dsgt-slack/src/http.ts
@@ -0,0 +1,211 @@
+/**
+ * HTTP Events API handler for production.
+ *
+ * Slack POSTs to the Firebase App Hosting (Cloud Run) URL. This module is Fetch
+ * Request/Response based so Next.js App Router can call it. It does not start a
+ * Bolt HTTP server — Socket Mode / Bolt remain a local-only process.
+ */
+
+import { createHmac, timingSafeEqual } from "node:crypto";
+import { WebClient } from "@slack/web-api";
+
+import { replyForSlashCommand, replyForText } from "./replies";
+
+/** Stable Request URL path on mainweb. Use this in the Slack app settings. */
+export const SLACK_WEBHOOK_PATH = "/api/webhooks/slack";
+
+const MAX_CLOCK_SKEW_SEC = 60 * 5;
+
+export type SlackHttpEnv = {
+ botToken: string;
+ signingSecret: string;
+};
+
+export type SlackPoster = {
+ chat: {
+ postMessage: (args: {
+ channel: string;
+ text: string;
+ thread_ts?: string;
+ }) => Promise;
+ };
+};
+
+function safeEqual(left: string, right: string): boolean {
+ const leftBuf = Buffer.from(left, "utf8");
+ const rightBuf = Buffer.from(right, "utf8");
+ if (leftBuf.length !== rightBuf.length) {
+ return false;
+ }
+ return timingSafeEqual(leftBuf, rightBuf);
+}
+
+export function verifySlackSignature(options: {
+ signingSecret: string;
+ signature: string | null;
+ timestamp: string | null;
+ rawBody: string;
+ nowMs?: number;
+}): boolean {
+ const {
+ signingSecret,
+ signature,
+ timestamp,
+ rawBody,
+ nowMs = Date.now(),
+ } = options;
+
+ if (!signature || !timestamp || !/^[0-9]+$/.test(timestamp)) {
+ return false;
+ }
+
+ const ageSec = Math.abs(nowMs / 1000 - Number(timestamp));
+ if (ageSec > MAX_CLOCK_SKEW_SEC) {
+ return false;
+ }
+
+ const digest = createHmac("sha256", signingSecret)
+ .update(`v0:${timestamp}:${rawBody}`, "utf8")
+ .digest("hex");
+
+ return safeEqual(`v0=${digest}`, signature);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+type SlackMessageEvent = {
+ type: string;
+ subtype?: string;
+ bot_id?: string;
+ text?: string;
+ channel?: string;
+ channel_type?: string;
+ thread_ts?: string;
+};
+
+function parseMessageEvent(value: unknown): SlackMessageEvent | null {
+ if (!isRecord(value) || typeof value.type !== "string") {
+ return null;
+ }
+
+ return {
+ type: value.type,
+ subtype: typeof value.subtype === "string" ? value.subtype : undefined,
+ bot_id: typeof value.bot_id === "string" ? value.bot_id : undefined,
+ text: typeof value.text === "string" ? value.text : undefined,
+ channel: typeof value.channel === "string" ? value.channel : undefined,
+ channel_type:
+ typeof value.channel_type === "string" ? value.channel_type : undefined,
+ thread_ts:
+ typeof value.thread_ts === "string" ? value.thread_ts : undefined,
+ };
+}
+
+async function handleEvent(
+ event: SlackMessageEvent,
+ client: SlackPoster,
+): Promise {
+ if (event.bot_id || event.subtype) {
+ return;
+ }
+ if (typeof event.text !== "string" || typeof event.channel !== "string") {
+ return;
+ }
+
+ if (event.type === "app_mention") {
+ await client.chat.postMessage({
+ channel: event.channel,
+ text: replyForText(event.text),
+ ...(event.thread_ts ? { thread_ts: event.thread_ts } : {}),
+ });
+ return;
+ }
+
+ if (event.type === "message" && event.channel_type === "im") {
+ await client.chat.postMessage({
+ channel: event.channel,
+ text: replyForText(event.text),
+ });
+ }
+}
+
+async function handleJsonBody(
+ payload: unknown,
+ client: SlackPoster,
+): Promise {
+ if (!isRecord(payload) || typeof payload.type !== "string") {
+ return Response.json({ error: "Invalid payload" }, { status: 400 });
+ }
+
+ if (payload.type === "url_verification") {
+ const challenge =
+ typeof payload.challenge === "string" ? payload.challenge : "";
+ return Response.json({ challenge });
+ }
+
+ if (payload.type === "event_callback") {
+ const event = parseMessageEvent(payload.event);
+ if (event) {
+ await handleEvent(event, client);
+ }
+ return Response.json({ ok: true });
+ }
+
+ return Response.json({ ok: true });
+}
+
+function handleFormBody(rawBody: string): Response {
+ const params = new URLSearchParams(rawBody);
+
+ if (params.get("ssl_check") === "1") {
+ return new Response("", { status: 200 });
+ }
+
+ if (params.has("payload")) {
+ return new Response("", { status: 200 });
+ }
+
+ const command = params.get("command");
+ if (command) {
+ return Response.json({
+ response_type: "ephemeral",
+ text: replyForSlashCommand(params.get("text") ?? ""),
+ });
+ }
+
+ return Response.json({ error: "Unknown Slack request" }, { status: 400 });
+}
+
+export async function handleSlackWebhook(
+ req: Request,
+ env: SlackHttpEnv,
+ client: SlackPoster = new WebClient(env.botToken),
+): Promise {
+ const rawBody = await req.text();
+ const valid = verifySlackSignature({
+ signingSecret: env.signingSecret,
+ signature: req.headers.get("x-slack-signature"),
+ timestamp: req.headers.get("x-slack-request-timestamp"),
+ rawBody,
+ });
+
+ if (!valid) {
+ return Response.json({ error: "Invalid signature" }, { status: 400 });
+ }
+
+ const contentType = req.headers.get("content-type") ?? "";
+ if (
+ contentType.includes("application/json") ||
+ rawBody.trimStart().startsWith("{")
+ ) {
+ try {
+ return await handleJsonBody(JSON.parse(rawBody) as unknown, client);
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+ }
+
+ return handleFormBody(rawBody);
+}
diff --git a/apps/dsgt-slack/src/index.ts b/apps/dsgt-slack/src/index.ts
new file mode 100644
index 00000000..88da53b5
--- /dev/null
+++ b/apps/dsgt-slack/src/index.ts
@@ -0,0 +1,38 @@
+import "dotenv/config";
+
+import { createBoltApp } from "./app";
+import type { SlackEnv } from "./env";
+import { loadEnv } from "./env";
+
+async function main(): Promise {
+ let env: SlackEnv;
+ try {
+ env = loadEnv();
+ } catch {
+ console.warn(
+ "dsgt Slack bot skipped: set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET in apps/dsgt-slack/.env to run locally (Socket Mode). Production traffic is handled by sites/mainweb at /api/webhooks/slack.",
+ );
+ await new Promise(() => {
+ /* keep turbo `dev` from restarting this task */
+ });
+ return;
+ }
+
+ const app = createBoltApp(env);
+
+ if (env.socketMode) {
+ await app.start();
+ app.logger.info("dsgt Slack bot is running (Socket Mode, local)");
+ return;
+ }
+
+ await app.start(env.port);
+ app.logger.info(
+ `dsgt Slack bot is running a local HTTP receiver on port ${String(env.port)} at /slack/events. Production uses Firebase App Hosting at /api/webhooks/slack.`,
+ );
+}
+
+main().catch((error: unknown) => {
+ console.error("Failed to start the dsgt Slack bot.", error);
+ process.exit(1);
+});
diff --git a/apps/dsgt-slack/src/replies.test.ts b/apps/dsgt-slack/src/replies.test.ts
new file mode 100644
index 00000000..da3d8a20
--- /dev/null
+++ b/apps/dsgt-slack/src/replies.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ CLUB_SITE_URL,
+ JOIN_FAQ_REPLY,
+ detectIntent,
+ replyForSlashCommand,
+ replyForText,
+} from "./replies";
+
+describe("join FAQ reply", () => {
+ it("names the August 26 first event and points to the club site", () => {
+ expect(JOIN_FAQ_REPLY).toContain("August 26");
+ expect(JOIN_FAQ_REPLY).toContain("8/26");
+ expect(JOIN_FAQ_REPLY).toContain(
+ "Further details will be announced at that event",
+ );
+ expect(JOIN_FAQ_REPLY).toContain(CLUB_SITE_URL);
+ expect(JOIN_FAQ_REPLY).toContain("datasciencegt.org");
+ });
+
+ it.each([
+ "How do I join the club?",
+ "When is the first event?",
+ "I want to sign up",
+ "<@U123> how do I join?",
+ "What is happening on 8/26?",
+ ])("routes join questions to the FAQ: %s", (text) => {
+ expect(detectIntent(text)).toBe("join");
+ expect(replyForText(text)).toBe(JOIN_FAQ_REPLY);
+ });
+});
+
+describe("slash command routing", () => {
+ it("returns help when the command text is empty", () => {
+ expect(replyForSlashCommand("")).toContain("Available topics");
+ });
+
+ it("returns the join FAQ for /dsgt join", () => {
+ expect(replyForSlashCommand("join")).toBe(JOIN_FAQ_REPLY);
+ });
+});
diff --git a/apps/dsgt-slack/src/replies.ts b/apps/dsgt-slack/src/replies.ts
new file mode 100644
index 00000000..14a4d15d
--- /dev/null
+++ b/apps/dsgt-slack/src/replies.ts
@@ -0,0 +1,94 @@
+/**
+ * Reply text and lightweight intent routing for the DS@GT Slack bot.
+ *
+ * Keep this module free of Slack SDK imports so the copy can be unit tested.
+ */
+
+/** Public club site host. Scheme is added below so the full URL is not a source literal. */
+export const CLUB_HOST = "datasciencegt.org";
+export const CLUB_SITE_URL = `https://${CLUB_HOST}`;
+
+export const JOIN_FAQ_REPLY = `The first Data Science @ Georgia Tech event of the term is on August 26 (8/26). Further details will be announced at that event. For club information and how to get involved, visit ${CLUB_SITE_URL}.`;
+
+export const HEALTH_REPLY =
+ "dsgt is online. This health check confirms the Data Science @ Georgia Tech Slack bot is running.";
+
+export const HELP_REPLY = [
+ "I am the Data Science @ Georgia Tech Slack bot (dsgt).",
+ "",
+ "You can mention @dsgt in a channel, send a direct message, or use the /dsgt slash command.",
+ "",
+ "Available topics:",
+ "• help — show this message",
+ "• ping — confirm that the bot is online",
+ "• join — how to join the club and the date of the first event",
+ "",
+ `Club website: ${CLUB_SITE_URL}`,
+].join("\n");
+
+export const DEFAULT_REPLY =
+ "Hello. I am the Data Science @ Georgia Tech bot. Send help for what I can answer, or ping to confirm that I am online.";
+
+export const UNKNOWN_COMMAND_REPLY =
+ "I did not recognize that request. Send help for the topics I can answer.";
+
+const BOT_MENTION = /<@[^>]+>/g;
+
+const JOIN_PATTERN =
+ /\b(join|sign[ -]?up|membership|member|get involved|first (event|meeting)|8\/26|august 26|26th)\b/i;
+
+const HEALTH_PATTERN = /\b(ping|health|alive|status)\b/i;
+
+const HELP_PATTERN = /\b(help|commands|what can you do)\b/i;
+
+export type ReplyIntent = "join" | "health" | "help" | "default";
+
+export function stripBotMention(text: string): string {
+ return text.replace(BOT_MENTION, " ").replace(/\s+/g, " ").trim();
+}
+
+export function detectIntent(text: string): ReplyIntent {
+ const normalized = stripBotMention(text).trim();
+ if (normalized.length === 0) {
+ return "help";
+ }
+ if (JOIN_PATTERN.test(normalized)) {
+ return "join";
+ }
+ if (HEALTH_PATTERN.test(normalized)) {
+ return "health";
+ }
+ if (HELP_PATTERN.test(normalized)) {
+ return "help";
+ }
+ return "default";
+}
+
+export function replyForIntent(intent: ReplyIntent): string {
+ switch (intent) {
+ case "join":
+ return JOIN_FAQ_REPLY;
+ case "health":
+ return HEALTH_REPLY;
+ case "help":
+ return HELP_REPLY;
+ default:
+ return DEFAULT_REPLY;
+ }
+}
+
+export function replyForText(text: string): string {
+ return replyForIntent(detectIntent(text));
+}
+
+export function replyForSlashCommand(text: string): string {
+ const normalized = text.trim();
+ if (normalized.length === 0) {
+ return HELP_REPLY;
+ }
+ const intent = detectIntent(normalized);
+ if (intent === "default") {
+ return UNKNOWN_COMMAND_REPLY;
+ }
+ return replyForIntent(intent);
+}
diff --git a/apps/dsgt-slack/tsconfig.json b/apps/dsgt-slack/tsconfig.json
new file mode 100644
index 00000000..6eaf141c
--- /dev/null
+++ b/apps/dsgt-slack/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tooling/typescript/base.json",
+ "compilerOptions": {
+ "types": ["node"],
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/apps/dsgt-slack/turbo.json b/apps/dsgt-slack/turbo.json
new file mode 100644
index 00000000..b0115a50
--- /dev/null
+++ b/apps/dsgt-slack/turbo.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://turborepo.org/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "dev": {
+ "persistent": true,
+ "cache": false
+ },
+ "start": {
+ "persistent": true,
+ "cache": false
+ }
+ }
+}
diff --git a/apps/dsgt-slack/vitest.config.ts b/apps/dsgt-slack/vitest.config.ts
new file mode 100644
index 00000000..ae847ff6
--- /dev/null
+++ b/apps/dsgt-slack/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ },
+});
diff --git a/docs/README.md b/docs/README.md
index 1544941c..676c5b48 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -25,6 +25,12 @@ Start here, then jump to the page that matches the work you are doing.
| [Database](./packages/db.md) | `@query/db` | Drizzle schema, client, membership rules |
| [UI](./packages/ui.md) | `@query/ui` | Shared React components and styles |
+## Apps
+
+| Document | Workspace | Role |
+| --- | --- | --- |
+| [dsgt Slack bot](../apps/dsgt-slack/README.md) | `@query/dsgt-slack` | `@dsgt` Slack bot; production HTTP on mainweb |
+
## Sites
| Document | Workspace | Role |
diff --git a/docs/architecture.md b/docs/architecture.md
index f91c56f5..92e1fa89 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -15,6 +15,7 @@
│ / /team /events /login /dashboard /admin /judge … │
│ │ │
│ /api/trpc /api/auth /api/webhooks │
+│ stripe + slack │
└──────────┬──────────────────┴───────────────┬───────────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
@@ -91,7 +92,9 @@ Stripe Checkout and Payment Intents both exist. Amounts are defined once in `@qu
| Bootcamp add-on (on top of membership) | `1000` ($10) |
| Max charge treated as membership | `10000` |
-The webhook is `sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts`. Linking can also happen from the portal (`stripe.linkAccount`, `stripe.attemptAutoLink`) and at sign-in.
+The Stripe webhook is `sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts`. Linking can also happen from the portal (`stripe.linkAccount`, `stripe.attemptAutoLink`) and at sign-in.
+
+The Slack Events API webhook is `sites/mainweb/app/(portal)/api/webhooks/slack/route.ts`. It imports reply text from `apps/dsgt-slack` and verifies `SLACK_SIGNING_SECRET`. Production is HTTP on App Hosting (`{AUTH_URL}/api/webhooks/slack`); Socket Mode is local-only. See [`apps/dsgt-slack/README.md`](../apps/dsgt-slack/README.md).
## Caching
@@ -106,9 +109,12 @@ The webhook is `sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts`. Linkin
The event site does not talk to the database. Interest and registration live on the portal; the marketing site links to `/login?callbackUrl=/hacklytics`.
+## Apps
+
+`apps/dsgt-slack` (`@query/dsgt-slack`) is the `@dsgt` Slack bot. Reply copy lives in that package. Production HTTP is the mainweb route `/api/webhooks/slack` on App Hosting. Socket Mode is a local-only process in the same package.
+
## What is not in this repo
- `packages/consts` is mentioned in older notes and is **not** a workspace today.
-- `apps/*` is listed in `pnpm-workspace.yaml` but there is no `apps/` directory.
- `graphify-out/` is generated graph output, not product code.
- `trust badge/` holds MLH league badge SVGs for the event site.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 07ad9c4b..ee4ea6de 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -22,7 +22,7 @@ corepack prepare pnpm@10.33.2 --activate
pnpm install
```
-Workspaces are defined in `pnpm-workspace.yaml`: `sites/*`, `packages/*`, and `tooling/*`.
+Workspaces are defined in `pnpm-workspace.yaml`: `apps/*`, `sites/*`, `packages/*`, and `tooling/*`.
## Local database
@@ -88,7 +88,7 @@ pnpm --filter @query/db studio # Drizzle Studio
| `pnpm build` | `turbo run build` |
| `pnpm lint` | ESLint across workspaces (`--max-warnings 0`) |
| `pnpm typecheck` | `tsc --noEmit` via Turbo |
-| `pnpm test` | Vitest: `packages/api`, `packages/db`, `sites/mainweb/lib` |
+| `pnpm test` | Vitest: `packages/api`, `packages/db`, `sites/mainweb/lib`, `apps/dsgt-slack` |
| `pnpm format` | Prettier write |
Database scripts live on `@query/db`:
diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md
index 13e7abe6..1c47ba7c 100644
--- a/docs/operations/deployment.md
+++ b/docs/operations/deployment.md
@@ -28,7 +28,15 @@ Run: `node sites/mainweb/.next/standalone/sites/mainweb/server.js`
Secrets are GCP Secret Manager. Grant the App Hosting backend access once per secret, e.g. `firebase apphosting:secrets:grantaccess DATABASE_URL --backend query`.
-Env mapping (names only) is in `apphosting.yaml`: `DATABASE_URL`, `AUTH_SECRET` (also copied to `NEXTAUTH_SECRET`), Google/GitHub OAuth, Stripe, SMTP, DDoS ceilings, `TRUSTED_PROXY_HOPS=1`.
+Env mapping (names only) is in `apphosting.yaml`: `DATABASE_URL`, `AUTH_SECRET` (also copied to `NEXTAUTH_SECRET`), Google/GitHub OAuth, Stripe, Slack (`SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_SOCKET_MODE=false`), SMTP, DDoS ceilings, `TRUSTED_PROXY_HOPS=1`.
+
+Slack Event Subscriptions, slash commands, and interactivity must use the App Hosting origin (`AUTH_URL` / `NEXTAUTH_URL` in `apphosting.yaml`, host `datasciencegt.org`), not Firebase Hosting `dsgt-website` / `sites/mainweb/out`:
+
+```text
+{AUTH_URL}/api/webhooks/slack
+```
+
+Create `SLACK_BOT_TOKEN` and `SLACK_SIGNING_SECRET` in Secret Manager before that deploy. See [`apps/dsgt-slack/README.md`](../../apps/dsgt-slack/README.md).
## Hacklytics — Firebase Hosting
diff --git a/docs/operations/environment.md b/docs/operations/environment.md
index 79af41a5..391031c9 100644
--- a/docs/operations/environment.md
+++ b/docs/operations/environment.md
@@ -64,6 +64,17 @@ Without `DATABASE_URL`, `db` is null, sessions fall back to JWT, and tRPC proced
| `DDOS_CLEANUP_INTERVAL_MS` | Code default if unset |
| `CSP_ENFORCE` | `true` turns CSP from Report-Only into enforcing. Default is report-only |
+## Slack (`@dsgt`)
+
+| Variable | Notes |
+| --- | --- |
+| `SLACK_BOT_TOKEN` | Bot User OAuth Token. App Hosting secret `SLACK_BOT_TOKEN` |
+| `SLACK_SIGNING_SECRET` | Request signature. App Hosting secret `SLACK_SIGNING_SECRET` |
+| `SLACK_SOCKET_MODE` | App Hosting sets `false`. Local Socket Mode defaults to on |
+| `SLACK_APP_TOKEN` | Local Socket Mode only (`xapp-...`). Not set in production |
+
+Create the Secret Manager secrets before the App Hosting deploy that references them. Install steps: [`apps/dsgt-slack/README.md`](../../apps/dsgt-slack/README.md).
+
## Runtime (App Hosting)
| Variable | Value |
diff --git a/docs/operations/testing.md b/docs/operations/testing.md
index accbfd5d..3fe04c73 100644
--- a/docs/operations/testing.md
+++ b/docs/operations/testing.md
@@ -9,7 +9,7 @@ pnpm test
That is:
```bash
-vitest run packages/api packages/db sites/mainweb/lib
+vitest run packages/api packages/db sites/mainweb/lib apps/dsgt-slack
```
CI: `.github/workflows/test.yml` on pushes to `main`/`dev` and on pull requests.
@@ -41,6 +41,12 @@ Tests use `src/test/create-mock-context.ts` and `.internal-tests/_db-tx-mock.ts`
`src/services/membership.test.ts` — grant, lapse, current-edition resolution, payment linking.
+## `@query/dsgt-slack`
+
+`apps/dsgt-slack/src/replies.test.ts` — join FAQ copy (August 26 / 8/26 / datasciencegt.org) and slash-command routing.
+
+`apps/dsgt-slack/src/http.test.ts` — Slack signature verification, `url_verification` challenge, `app_mention` join reply, `/dsgt join` ack.
+
## Mainweb
`sites/mainweb/lib/*.test.ts` — phone formatting, hackathon slugs, `safe-callback`.
diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md
index 636ca0ee..54b84c9a 100644
--- a/docs/sites/mainweb.md
+++ b/docs/sites/mainweb.md
@@ -65,6 +65,7 @@ Unauthenticated and authenticated product UI. `proxy.ts` marks these prefixes `p
| `/api/auth/[...nextauth]` | NextAuth handlers |
| `/api/auth/verify-email` | Email-code verification |
| `/api/webhooks/stripe` | Stripe webhooks |
+| `/api/webhooks/slack` | Slack Events API, `/dsgt`, interactivity (`@query/dsgt-slack`) |
## Client data
@@ -77,7 +78,7 @@ Helpers: `lib/hackathon-slug.ts`, `lib/phone.ts`, `lib/bootcamp-schedule.ts`, `l
## Config highlights (`next.config.mjs`)
-- Transpiles `@query/api`, `@query/auth`, `@query/db`, `@query/ui`
+- Transpiles `@query/api`, `@query/auth`, `@query/db`, `@query/dsgt-slack`, `@query/ui`
- `outputFileTracingRoot` is the monorepo root (needed for standalone on App Hosting)
- Security headers: HSTS, CSP (report-only unless `CSP_ENFORCE=true`), frame options SAMEORIGIN (admin print/QR iframes), Permissions-Policy (camera **not** denied — `/scan` needs it)
- React Compiler enabled
diff --git a/package.json b/package.json
index 0954cf6d..74d862cd 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"lint": "turbo run lint",
"format": "prettier --write .",
"typecheck": "turbo run typecheck",
- "test": "vitest run packages/api packages/db sites/mainweb/lib"
+ "test": "vitest run packages/api packages/db sites/mainweb/lib apps/dsgt-slack"
},
"dependencies": {
"next": "16.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6b1e7f67..c24f207b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -37,6 +37,49 @@ importers:
specifier: ^4.1.8
version: 4.1.8(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)
+ apps/dsgt-slack:
+ dependencies:
+ '@slack/bolt':
+ specifier: ^5.0.0
+ version: 5.0.0(@types/express@5.0.6)(undici@6.28.0)
+ '@slack/web-api':
+ specifier: ^7.13.0
+ version: 7.19.0
+ dotenv:
+ specifier: ^16.6.1
+ version: 16.6.1
+ undici:
+ specifier: ^6.27.0
+ version: 6.28.0
+ zod:
+ specifier: 3.25.53
+ version: 3.25.53
+ devDependencies:
+ '@query/eslint-config':
+ specifier: workspace:*
+ version: link:../../tooling/eslint
+ '@query/tsconfig':
+ specifier: workspace:*
+ version: link:../../tooling/typescript
+ '@types/express':
+ specifier: ^5.0.0
+ version: 5.0.6
+ '@types/node':
+ specifier: ^22.15.32
+ version: 22.15.32
+ eslint:
+ specifier: 10.1.0
+ version: 10.1.0(jiti@2.7.0)
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: 6.0.2
+ version: 6.0.2
+ vitest:
+ specifier: ^4.1.8
+ version: 4.1.8(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)
+
packages/api:
dependencies:
'@query/auth':
@@ -284,6 +327,9 @@ importers:
'@query/db':
specifier: workspace:*
version: link:../../packages/db
+ '@query/dsgt-slack':
+ specifier: workspace:*
+ version: link:../../apps/dsgt-slack
'@query/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -1769,6 +1815,46 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@slack/bolt@5.0.0':
+ resolution: {integrity: sha512-L0FTzidrDKTu4Ph8sdv0bB3acafBTAC02TBcD+hojPTTNSMX3hSK+rkpkUKkqbbc0vGpSjd6A/g71Rjy7EbBhw==}
+ engines: {node: '>=20', npm: '>=9.6.4'}
+ peerDependencies:
+ '@types/express': ^5.0.0
+
+ '@slack/logger@4.0.1':
+ resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==}
+ engines: {node: '>= 18', npm: '>= 8.6.0'}
+
+ '@slack/logger@5.0.0':
+ resolution: {integrity: sha512-VGXhmmgsAo9shdQYh4tFDndd+7nsgp0Y5h0UPDaUp8K359pBasI6YdkMqFW3mCOxLQkq09qj7o7cq6f3DuXcJQ==}
+ engines: {node: '>= 20', npm: '>=9.6.4'}
+
+ '@slack/oauth@4.0.0':
+ resolution: {integrity: sha512-Aqs5bGghT+VtNcdwVOQy7CcyCfDNd9YYnZsCRukIF9p1Mxuq4uL+BjqlWQB0SeS4wzrLzePPgvj8JDuQil+ezA==}
+ engines: {node: '>=20', npm: '>=9.6.4'}
+
+ '@slack/socket-mode@3.0.0':
+ resolution: {integrity: sha512-QShO60SB0E+HH+TbcKj3CBEQbodToRyiXnxuSB4t1kvUlqEmuGA1nOOjrRDkDJbOECAZ13PLe4ek9SrntpfoYg==}
+ engines: {node: '>=20', npm: '>=9.6.4'}
+ peerDependencies:
+ undici: ^6.27.0
+
+ '@slack/types@2.22.0':
+ resolution: {integrity: sha512-sZ9lIgJhPX2qft/tKWiklFlc0o1FWeI7QtciZJfW1+ErH1eGGHvOZ8e73sleTCFEFJp1q/R0WeS8Oa7AsiDprg==}
+ engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
+
+ '@slack/types@3.0.0':
+ resolution: {integrity: sha512-KNOqpnNAlsFt5Jk9XBclslQ0lobRIg/0tnhpmvZJAglHJx9E8oceN8hC3gaBzkR6UzQ9Wzq4rLsJ98wUcxWPfw==}
+ engines: {node: '>= 20', npm: '>=9.6.4'}
+
+ '@slack/web-api@7.19.0':
+ resolution: {integrity: sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA==}
+ engines: {node: '>= 18', npm: '>= 8.6.0'}
+
+ '@slack/web-api@8.0.0':
+ resolution: {integrity: sha512-ORx3XQryQPq2Jnxv5giSKXVoQRUeylrrymIR2S9fPzLjPcCts8RayMeBSZMcpfpAqp6fnBRuPW2UB6dUPUTEZA==}
+ engines: {node: '>= 20', npm: '>=9.6.4'}
+
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -2000,9 +2086,15 @@ packages:
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
+ '@types/body-parser@1.19.6':
+ resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+ '@types/connect@3.4.38':
+ resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -2024,16 +2116,31 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/express-serve-static-core@5.1.3':
+ resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==}
+
+ '@types/express@5.0.6':
+ resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
+
+ '@types/http-errors@2.0.5':
+ resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/jsonwebtoken@9.0.10':
+ resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+
'@types/minimatch@6.0.0':
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@20.19.40':
resolution: {integrity: sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==}
@@ -2050,6 +2157,12 @@ packages:
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+ '@types/qs@6.15.1':
+ resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
+
+ '@types/range-parser@1.2.7':
+ resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
'@types/react-dom@19.0.3':
resolution: {integrity: sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==}
peerDependencies:
@@ -2061,9 +2174,18 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+ '@types/retry@0.12.0':
+ resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
+
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
+ '@types/send@1.2.1':
+ resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
+
+ '@types/serve-static@2.2.0':
+ resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
+
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
@@ -2167,6 +2289,10 @@ packages:
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -2181,6 +2307,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -2243,6 +2373,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
autoprefixer@10.4.22:
resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==}
engines: {node: ^10 || ^12 || >=14}
@@ -2258,6 +2391,9 @@ packages:
resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
engines: {node: '>=4'}
+ axios@1.19.0:
+ resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -2281,6 +2417,10 @@ packages:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
+ body-parser@2.3.0:
+ resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
+ engines: {node: '>=18'}
+
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
@@ -2294,9 +2434,16 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -2344,13 +2491,37 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
+ content-disposition@1.1.0:
+ resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+ engines: {node: '>=18'}
+
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
+ content-type@2.1.0:
+ resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
+ engines: {node: '>=18'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
@@ -2424,6 +2595,14 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
+ depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -2562,6 +2741,12 @@ packages:
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
electron-to-chromium@1.5.353:
resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
@@ -2571,6 +2756,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
enhanced-resolve@5.21.2:
resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==}
engines: {node: '>=10.13.0'}
@@ -2627,6 +2816,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -2746,10 +2938,24 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
+ eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2786,6 +2992,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
@@ -2801,10 +3011,27 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
+ form-data@4.0.6:
+ resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
+ engines: {node: '>= 6'}
+
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -2822,6 +3049,10 @@ packages:
react-dom:
optional: true
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2924,12 +3155,28 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
+ iconv-lite@0.7.3:
+ resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
+ engines: {node: '>=0.10.0'}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -2942,10 +3189,17 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2978,6 +3232,9 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
+ is-electron@2.2.2:
+ resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -3018,6 +3275,9 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -3030,6 +3290,10 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
+ is-stream@2.0.1:
+ resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+ engines: {node: '>=8'}
+
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@@ -3096,10 +3360,20 @@ packages:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true
+ jsonwebtoken@9.0.3:
+ resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
+ engines: {node: '>=12', npm: '>=6'}
+
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3199,6 +3473,27 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
@@ -3218,6 +3513,14 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ media-typer@1.1.1:
+ resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -3229,6 +3532,22 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
minimatch@10.2.3:
resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==}
engines: {node: 18 || 20 || >=22}
@@ -3268,6 +3587,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ negotiator@1.0.0:
+ resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+ engines: {node: '>= 0.6'}
+
next-auth@5.0.0-beta.32:
resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==}
peerDependencies:
@@ -3367,6 +3690,13 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
@@ -3379,6 +3709,10 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
+ p-finally@1.0.0:
+ resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
+ engines: {node: '>=4'}
+
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@@ -3395,6 +3729,18 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ p-queue@6.6.2:
+ resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
+ engines: {node: '>=8'}
+
+ p-retry@4.6.2:
+ resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
+ engines: {node: '>=8'}
+
+ p-timeout@3.2.0:
+ resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
+ engines: {node: '>=8'}
+
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
@@ -3402,6 +3748,10 @@ packages:
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -3413,6 +3763,9 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ path-to-regexp@8.4.2:
+ resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -3595,6 +3948,14 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
+ proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -3609,9 +3970,21 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
+ qs@6.15.3:
+ resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
+ engines: {node: '>=0.6'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ range-parser@1.3.0:
+ resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
+ engines: {node: '>= 0.6'}
+
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
react-chartjs-2@5.3.1:
resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==}
peerDependencies:
@@ -3689,6 +4062,10 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
+ retry@0.13.1:
+ resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
+ engines: {node: '>= 4'}
+
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -3698,6 +4075,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3705,6 +4086,9 @@ packages:
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'}
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -3713,6 +4097,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
sanitize-html@2.17.5:
resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==}
@@ -3736,6 +4123,14 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -3751,6 +4146,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
+ setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
sharp@0.35.3:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
engines: {node: '>=20.9.0'}
@@ -3784,6 +4182,10 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ side-channel@1.1.1:
+ resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
+ engines: {node: '>= 0.4'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -3809,6 +4211,10 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
@@ -3917,6 +4323,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
@@ -3933,6 +4343,10 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tsscmp@1.0.6:
+ resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
+ engines: {node: '>=0.6.x'}
+
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
@@ -3950,6 +4364,10 @@ packages:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
+ type-is@2.1.0:
+ resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
+ engines: {node: '>= 18'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -3990,6 +4408,14 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici@6.28.0:
+ resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
+ engines: {node: '>=18.17'}
+
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -4019,6 +4445,10 @@ packages:
'@types/react':
optional: true
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
vite@7.3.6:
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4145,6 +4575,9 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
@@ -5184,6 +5617,79 @@ snapshots:
'@rtsao/scc@1.1.0': {}
+ '@slack/bolt@5.0.0(@types/express@5.0.6)(undici@6.28.0)':
+ dependencies:
+ '@slack/logger': 5.0.0
+ '@slack/oauth': 4.0.0
+ '@slack/socket-mode': 3.0.0(undici@6.28.0)
+ '@slack/types': 3.0.0
+ '@slack/web-api': 8.0.0
+ '@types/express': 5.0.6
+ express: 5.2.1
+ path-to-regexp: 8.4.2
+ raw-body: 3.0.2
+ tsscmp: 1.0.6
+ transitivePeerDependencies:
+ - supports-color
+ - undici
+
+ '@slack/logger@4.0.1':
+ dependencies:
+ '@types/node': 22.15.32
+
+ '@slack/logger@5.0.0':
+ dependencies:
+ '@types/node': 22.15.32
+
+ '@slack/oauth@4.0.0':
+ dependencies:
+ '@slack/logger': 5.0.0
+ '@slack/web-api': 8.0.0
+ '@types/jsonwebtoken': 9.0.10
+ '@types/node': 22.15.32
+ jsonwebtoken: 9.0.3
+
+ '@slack/socket-mode@3.0.0(undici@6.28.0)':
+ dependencies:
+ '@slack/logger': 5.0.0
+ '@slack/web-api': 8.0.0
+ '@types/node': 22.15.32
+ eventemitter3: 5.0.4
+ undici: 6.28.0
+
+ '@slack/types@2.22.0': {}
+
+ '@slack/types@3.0.0': {}
+
+ '@slack/web-api@7.19.0':
+ dependencies:
+ '@slack/logger': 4.0.1
+ '@slack/types': 2.22.0
+ '@types/node': 22.15.32
+ '@types/retry': 0.12.0
+ axios: 1.19.0
+ eventemitter3: 5.0.4
+ form-data: 4.0.6
+ is-electron: 2.2.2
+ is-stream: 2.0.1
+ p-queue: 6.6.2
+ p-retry: 4.6.2
+ retry: 0.13.1
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ '@slack/web-api@8.0.0':
+ dependencies:
+ '@slack/logger': 5.0.0
+ '@slack/types': 3.0.0
+ '@types/node': 22.15.32
+ '@types/retry': 0.12.0
+ eventemitter3: 5.0.4
+ p-queue: 6.6.2
+ p-retry: 4.6.2
+ retry: 0.13.1
+
'@standard-schema/spec@1.1.0': {}
'@stripe/react-stripe-js@3.10.0(@stripe/stripe-js@5.10.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7)':
@@ -5355,11 +5861,20 @@ snapshots:
'@tweenjs/tween.js@23.1.3': {}
+ '@types/body-parser@1.19.6':
+ dependencies:
+ '@types/connect': 3.4.38
+ '@types/node': 22.15.32
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
+ '@types/connect@3.4.38':
+ dependencies:
+ '@types/node': 22.15.32
+
'@types/deep-eql@4.0.2': {}
'@types/emscripten@1.41.5': {}
@@ -5379,14 +5894,36 @@ snapshots:
'@types/estree@1.0.9': {}
+ '@types/express-serve-static-core@5.1.3':
+ dependencies:
+ '@types/node': 22.15.32
+ '@types/qs': 6.15.1
+ '@types/range-parser': 1.2.7
+ '@types/send': 1.2.1
+
+ '@types/express@5.0.6':
+ dependencies:
+ '@types/body-parser': 1.19.6
+ '@types/express-serve-static-core': 5.1.3
+ '@types/serve-static': 2.2.0
+
+ '@types/http-errors@2.0.5': {}
+
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
+ '@types/jsonwebtoken@9.0.10':
+ dependencies:
+ '@types/ms': 2.1.0
+ '@types/node': 22.15.32
+
'@types/minimatch@6.0.0':
dependencies:
minimatch: 10.2.3
+ '@types/ms@2.1.0': {}
+
'@types/node@20.19.40':
dependencies:
undici-types: 6.21.0
@@ -5411,6 +5948,10 @@ snapshots:
dependencies:
'@types/node': 22.15.32
+ '@types/qs@6.15.1': {}
+
+ '@types/range-parser@1.2.7': {}
+
'@types/react-dom@19.0.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -5423,10 +5964,21 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/retry@0.12.0': {}
+
'@types/sanitize-html@2.16.1':
dependencies:
htmlparser2: 10.1.0
+ '@types/send@1.2.1':
+ dependencies:
+ '@types/node': 22.15.32
+
+ '@types/serve-static@2.2.0':
+ dependencies:
+ '@types/http-errors': 2.0.5
+ '@types/node': 22.15.32
+
'@types/stats.js@0.17.4': {}
'@types/three@0.184.1':
@@ -5560,7 +6112,7 @@ snapshots:
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
- minimatch: 10.2.4
+ minimatch: 10.2.3
semver: 7.8.5
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@5.8.3)
@@ -5575,7 +6127,7 @@ snapshots:
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
- minimatch: 10.2.4
+ minimatch: 10.2.3
semver: 7.8.5
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.2)
@@ -5658,6 +6210,11 @@ snapshots:
react-dom: 19.0.0(react@19.2.7)
webrtc-adapter: 9.0.3
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -5668,6 +6225,12 @@ snapshots:
acorn@8.16.0: {}
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -5760,6 +6323,8 @@ snapshots:
async-function@1.0.0: {}
+ asynckit@0.4.0: {}
+
autoprefixer@10.4.22(postcss@8.5.23):
dependencies:
browserslist: 4.28.2
@@ -5776,6 +6341,16 @@ snapshots:
axe-core@4.11.4: {}
+ axios@1.19.0:
+ dependencies:
+ follow-redirects: 1.16.0
+ form-data: 4.0.6
+ https-proxy-agent: 5.0.1
+ proxy-from-env: 2.1.0
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
axobject-query@4.1.0: {}
babel-plugin-react-compiler@1.0.0:
@@ -5792,6 +6367,20 @@ snapshots:
baseline-browser-mapping@2.9.19: {}
+ body-parser@2.3.0:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 2.1.0
+ debug: 4.4.3
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ on-finished: 2.4.1
+ qs: 6.15.3
+ raw-body: 3.0.2
+ type-is: 2.1.0
+ transitivePeerDependencies:
+ - supports-color
+
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -5808,8 +6397,12 @@ snapshots:
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
+ buffer-equal-constant-time@1.0.1: {}
+
buffer-from@1.1.2: {}
+ bytes@3.1.2: {}
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -5857,10 +6450,24 @@ snapshots:
color-name@1.1.4: {}
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
commander@7.2.0: {}
+ content-disposition@1.1.0: {}
+
+ content-type@1.0.5: {}
+
+ content-type@2.1.0: {}
+
convert-source-map@2.0.0: {}
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
@@ -5927,6 +6534,10 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ delayed-stream@1.0.0: {}
+
+ depd@2.0.0: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -5980,12 +6591,20 @@ snapshots:
duplexer@0.1.2: {}
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ ee-first@1.1.1: {}
+
electron-to-chromium@1.5.353: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
+ encodeurl@2.0.0: {}
+
enhanced-resolve@5.21.2:
dependencies:
graceful-fs: 4.2.11
@@ -6129,6 +6748,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.7.0)):
@@ -6307,8 +6928,47 @@ snapshots:
esutils@2.0.3: {}
+ etag@1.8.1: {}
+
+ eventemitter3@4.0.7: {}
+
+ eventemitter3@5.0.4: {}
+
expect-type@1.3.0: {}
+ express@5.2.1:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.3.0
+ content-disposition: 1.1.0
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.3
+ range-parser: 1.3.0
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
+ statuses: 2.0.2
+ type-is: 2.1.0
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -6341,6 +7001,17 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ finalhandler@2.1.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
@@ -6358,10 +7029,22 @@ snapshots:
flatted@3.4.2: {}
+ follow-redirects@1.16.0: {}
+
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
+ form-data@4.0.6:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.4
+ mime-types: 2.1.35
+
+ forwarded@0.2.0: {}
+
fraction.js@5.3.4: {}
framer-motion@11.18.2(react-dom@19.0.0(react@19.2.7))(react@19.2.7):
@@ -6373,6 +7056,8 @@ snapshots:
react: 19.2.7
react-dom: 19.0.0(react@19.2.7)
+ fresh@2.0.0: {}
+
fsevents@2.3.2:
optional: true
@@ -6473,6 +7158,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
html-escaper@2.0.2: {}
htmlparser2@10.1.0:
@@ -6482,18 +7171,41 @@ snapshots:
domutils: 3.2.2
entities: 7.0.1
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ iconv-lite@0.7.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
ignore@5.3.2: {}
ignore@7.0.5: {}
imurmurhash@0.1.4: {}
+ inherits@2.0.4: {}
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
hasown: 2.0.3
side-channel: 1.1.0
+ ipaddr.js@1.9.1: {}
+
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -6534,6 +7246,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-electron@2.2.2: {}
+
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -6567,6 +7281,8 @@ snapshots:
is-plain-object@5.0.0: {}
+ is-promise@4.0.0: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -6580,6 +7296,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
+ is-stream@2.0.1: {}
+
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -6639,6 +7357,19 @@ snapshots:
dependencies:
minimist: 1.2.8
+ jsonwebtoken@9.0.3:
+ dependencies:
+ jws: 4.0.1
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.8.5
+
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
@@ -6646,6 +7377,17 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -6722,6 +7464,20 @@ snapshots:
dependencies:
p-locate: 5.0.0
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
+ lodash.once@4.1.1: {}
+
lodash.throttle@4.1.1: {}
loose-envify@1.4.0:
@@ -6738,6 +7494,10 @@ snapshots:
math-intrinsics@1.1.0: {}
+ media-typer@1.1.1: {}
+
+ merge-descriptors@2.0.0: {}
+
merge2@1.4.1: {}
meshoptimizer@1.1.1: {}
@@ -6747,6 +7507,18 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
+ mime-db@1.52.0: {}
+
+ mime-db@1.54.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
minimatch@10.2.3:
dependencies:
brace-expansion: 5.0.9
@@ -6777,6 +7549,8 @@ snapshots:
natural-compare@1.4.0: {}
+ negotiator@1.0.0: {}
+
next-auth@5.0.0-beta.32(next@16.3.0(@playwright/test@1.60.0)(@types/node@20.19.40)(babel-plugin-react-compiler@1.0.0)(react-dom@19.0.0(react@19.2.7))(react@19.2.7))(nodemailer@9.0.3)(react@19.2.7):
dependencies:
'@auth/core': 0.41.3(nodemailer@9.0.3)
@@ -6911,6 +7685,14 @@ snapshots:
obug@2.1.1: {}
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
opener@1.5.2: {}
optionator@0.9.4:
@@ -6928,6 +7710,8 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
+ p-finally@1.0.0: {}
+
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -6944,16 +7728,34 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ p-queue@6.6.2:
+ dependencies:
+ eventemitter3: 4.0.7
+ p-timeout: 3.2.0
+
+ p-retry@4.6.2:
+ dependencies:
+ '@types/retry': 0.12.0
+ retry: 0.13.1
+
+ p-timeout@3.2.0:
+ dependencies:
+ p-finally: 1.0.0
+
p-try@2.2.0: {}
parse-srcset@1.0.2: {}
+ parseurl@1.3.3: {}
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
+ path-to-regexp@8.4.2: {}
+
pathe@2.0.3: {}
pg-cloudflare@1.4.0:
@@ -7055,6 +7857,13 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
+ proxy-from-env@2.1.0: {}
+
punycode@2.3.1: {}
qrcode.react@3.2.0(react@19.2.7):
@@ -7067,8 +7876,22 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
+ qs@6.15.3:
+ dependencies:
+ es-define-property: 1.0.1
+ side-channel: 1.1.1
+
queue-microtask@1.2.3: {}
+ range-parser@1.3.0: {}
+
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.3
+ unpipe: 1.0.0
+
react-chartjs-2@5.3.1(chart.js@4.5.1)(react@19.2.7):
dependencies:
chart.js: 4.5.1
@@ -7152,6 +7975,8 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
+ retry@0.13.1: {}
+
reusify@1.1.0: {}
rollup@4.60.3:
@@ -7185,6 +8010,16 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.3
fsevents: 2.3.3
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.4.2
+ transitivePeerDependencies:
+ - supports-color
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -7197,6 +8032,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
+ safe-buffer@5.2.1: {}
+
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -7208,6 +8045,8 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ safer-buffer@2.1.2: {}
+
sanitize-html@2.17.5:
dependencies:
deepmerge: 4.3.1
@@ -7228,6 +8067,31 @@ snapshots:
semver@7.8.5: {}
+ send@1.2.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.3.0
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ serve-static@2.2.1:
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+
set-blocking@2.0.0: {}
set-function-length@1.2.2:
@@ -7252,6 +8116,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
+ setprototypeof@1.2.0: {}
+
sharp@0.35.3(@types/node@20.19.40):
dependencies:
'@img/colour': 1.1.0
@@ -7354,6 +8220,14 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ side-channel@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+ object-inspect: 1.13.4
+ side-channel-list: 1.0.1
+ side-channel-map: 1.0.1
+ side-channel-weakmap: 1.0.2
+
siginfo@2.0.0: {}
sirv@2.0.4:
@@ -7375,6 +8249,8 @@ snapshots:
stackback@0.0.2: {}
+ statuses@2.0.2: {}
+
std-env@4.0.0: {}
stop-iteration-iterator@1.1.0:
@@ -7484,6 +8360,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ toidentifier@1.0.1: {}
+
totalist@3.0.1: {}
ts-api-utils@2.5.0(typescript@5.8.3):
@@ -7503,6 +8381,8 @@ snapshots:
tslib@2.8.1: {}
+ tsscmp@1.0.6: {}
+
tsx@4.21.0:
dependencies:
esbuild: 0.25.12
@@ -7527,6 +8407,12 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
+ type-is@2.1.0:
+ dependencies:
+ content-type: 2.1.0
+ media-typer: 1.1.1
+ mime-types: 3.0.2
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -7595,6 +8481,10 @@ snapshots:
undici-types@6.21.0: {}
+ undici@6.28.0: {}
+
+ unpipe@1.0.0: {}
+
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@@ -7620,6 +8510,8 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
+ vary@1.1.2: {}
+
vite@7.3.6(@types/node@22.15.32)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0):
dependencies:
esbuild: 0.25.12
@@ -7755,6 +8647,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
+ wrappy@1.0.2: {}
+
ws@8.21.0: {}
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {}
diff --git a/sites/mainweb/app/(portal)/api/webhooks/slack/route.ts b/sites/mainweb/app/(portal)/api/webhooks/slack/route.ts
new file mode 100644
index 00000000..e2ed07cb
--- /dev/null
+++ b/sites/mainweb/app/(portal)/api/webhooks/slack/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { handleSlackWebhook } from "@query/dsgt-slack/http";
+
+/**
+ * Slack Events API, slash commands, and interactivity.
+ *
+ * Request URL (Event Subscriptions, Slash Commands, Interactivity):
+ * {AUTH_URL}/api/webhooks/slack
+ * AUTH_URL / NEXTAUTH_URL in apphosting.yaml is the App Hosting origin
+ * (club host datasciencegt.org). Do not point Slack at the static Firebase
+ * Hosting site (`dsgt-website` / `sites/mainweb/out`). Signature checks use
+ * SLACK_SIGNING_SECRET.
+ */
+export async function POST(req: NextRequest) {
+ const botToken = process.env.SLACK_BOT_TOKEN;
+ const signingSecret = process.env.SLACK_SIGNING_SECRET;
+
+ if (!botToken || !signingSecret) {
+ console.error(
+ "Slack webhook missing SLACK_BOT_TOKEN or SLACK_SIGNING_SECRET.",
+ );
+ return NextResponse.json(
+ { error: "Server configuration error" },
+ { status: 500 },
+ );
+ }
+
+ return handleSlackWebhook(req, { botToken, signingSecret });
+}
diff --git a/sites/mainweb/next.config.mjs b/sites/mainweb/next.config.mjs
index 42084647..f53e7525 100644
--- a/sites/mainweb/next.config.mjs
+++ b/sites/mainweb/next.config.mjs
@@ -50,7 +50,14 @@ const cspHeaderName =
const nextConfig = {
output: "standalone",
reactCompiler: true,
- transpilePackages: ["@query/api", "@query/auth", "@query/db", "@query/ui"],
+ transpilePackages: [
+ "@query/api",
+ "@query/auth",
+ "@query/db",
+ "@query/dsgt-slack",
+ "@query/ui",
+ ],
+ serverExternalPackages: ["@slack/web-api", "@slack/bolt"],
outputFileTracingRoot: path.join(__dirname, "../../"),
async headers() {
return [
diff --git a/sites/mainweb/package.json b/sites/mainweb/package.json
index 92515417..279fda9e 100644
--- a/sites/mainweb/package.json
+++ b/sites/mainweb/package.json
@@ -16,6 +16,7 @@
"@query/api": "workspace:*",
"@query/auth": "workspace:*",
"@query/db": "workspace:*",
+ "@query/dsgt-slack": "workspace:*",
"@query/ui": "workspace:*",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^5.8.0",
diff --git a/turbo.json b/turbo.json
index 8cbe4787..c08f4eaf 100644
--- a/turbo.json
+++ b/turbo.json
@@ -81,7 +81,11 @@
"DDOS_BLOCK_DURATION_MS",
"DDOS_BURST_THRESHOLD",
"DDOS_BURST_WINDOW_MS",
- "DDOS_CLEANUP_INTERVAL_MS"
+ "DDOS_CLEANUP_INTERVAL_MS",
+ "SLACK_BOT_TOKEN",
+ "SLACK_SIGNING_SECRET",
+ "SLACK_APP_TOKEN",
+ "SLACK_SOCKET_MODE"
],
"globalPassThroughEnv": ["NODE_ENV", "CI", "npm_lifecycle_event"]
}
From faaa8b3fb2b9750625e926ee999b7eae41092a53 Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Mon, 17 Aug 2026 16:59:36 +0000
Subject: [PATCH 02/10] Document that Grok Bot is separate from the FAQ Slack
webhook
The Firebase @dsgt app only answers help, ping, and join. Google Slides,
Forms, and other MCP tools belong on the Grok Bot teammate installed into
the DS@GT Slack workspace.
---
apps/dsgt-slack/README.md | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/apps/dsgt-slack/README.md b/apps/dsgt-slack/README.md
index 7aa1fe82..55ca0ad5 100644
--- a/apps/dsgt-slack/README.md
+++ b/apps/dsgt-slack/README.md
@@ -13,6 +13,27 @@ Production traffic is **HTTP Events API** on the existing Firebase App Hosting
backend (`sites/mainweb` / Cloud Run). There is no second Cloud Run service and
no Socket Mode process in production.
+## Grok Bot (the LLM / MCP teammate)
+
+This Firebase app is **not** Grok Bot. It is a small FAQ process: help, ping,
+and the August 26 join answer. It cannot use your Grok Bot computer, Google
+connectors, or MCP tools.
+
+If club Slack should talk to **your Grok Bot** (the Yodo pattern: people DM
+`@dsgt`, and the teammate uses Slack, Drive, Slides, Forms, Gmail), do that in
+the Grok Bot app, not here:
+
+1. Open Grok Bot and open the teammate you want as `@dsgt`.
+2. Connect **Slack** to the **DS@GT club workspace** (not Campus Leads).
+3. When Slack asks to install the app, click **Allow** as a workspace admin.
+4. Add Google Workspace connectors there (Drive / Slides / Forms). MCP for
+ those products lives on Grok Bot.
+5. Name the Slack bot user `dsgt` so it matches the club identity.
+
+You can keep this FAQ webhook as a fallback, or skip installing it if Grok Bot
+is the only `@dsgt` in the workspace. Do not run both against the same bot
+tokens or they will fight over events.
+
## Create the Slack app from the manifest
1. Open [https://api.slack.com/apps](https://api.slack.com/apps) and sign in to
From ef0d6df55246076f693a5b28bc09ee9f8eff6dbc Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Mon, 17 Aug 2026 17:00:54 +0000
Subject: [PATCH 03/10] Avoid ReDoS when stripping Slack mentions
Replace the polynomial <@[^>]+> matcher with a linear indexOf scan so a
flood of incomplete <@ prefixes cannot backtrack on event text.
---
apps/dsgt-slack/src/replies.test.ts | 12 ++++++++++
apps/dsgt-slack/src/replies.ts | 37 ++++++++++++++++++++++++++---
2 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/apps/dsgt-slack/src/replies.test.ts b/apps/dsgt-slack/src/replies.test.ts
index da3d8a20..5c247a88 100644
--- a/apps/dsgt-slack/src/replies.test.ts
+++ b/apps/dsgt-slack/src/replies.test.ts
@@ -6,6 +6,7 @@ import {
detectIntent,
replyForSlashCommand,
replyForText,
+ stripBotMention,
} from "./replies";
describe("join FAQ reply", () => {
@@ -40,3 +41,14 @@ describe("slash command routing", () => {
expect(replyForSlashCommand("join")).toBe(JOIN_FAQ_REPLY);
});
});
+
+describe("stripBotMention", () => {
+ it("removes a Slack user mention before the rest of the text", () => {
+ expect(stripBotMention("<@U123> how do I join?")).toBe("how do I join?");
+ });
+
+ it("is linear on a long run of incomplete <@ prefixes", () => {
+ const noisy = `${"<@".repeat(200)} how do I join?`;
+ expect(detectIntent(noisy)).toBe("join");
+ });
+});
diff --git a/apps/dsgt-slack/src/replies.ts b/apps/dsgt-slack/src/replies.ts
index 14a4d15d..db01ff29 100644
--- a/apps/dsgt-slack/src/replies.ts
+++ b/apps/dsgt-slack/src/replies.ts
@@ -32,8 +32,6 @@ export const DEFAULT_REPLY =
export const UNKNOWN_COMMAND_REPLY =
"I did not recognize that request. Send help for the topics I can answer.";
-const BOT_MENTION = /<@[^>]+>/g;
-
const JOIN_PATTERN =
/\b(join|sign[ -]?up|membership|member|get involved|first (event|meeting)|8\/26|august 26|26th)\b/i;
@@ -43,8 +41,41 @@ const HELP_PATTERN = /\b(help|commands|what can you do)\b/i;
export type ReplyIntent = "join" | "health" | "help" | "default";
+/**
+ * Slack mentions are `<@U…>` (optionally `<@U…|label>`). Walk the string once
+ * with indexOf so a flood of `<@` without a closing `>` cannot backtrack.
+ */
export function stripBotMention(text: string): string {
- return text.replace(BOT_MENTION, " ").replace(/\s+/g, " ").trim();
+ let output = "";
+ let i = 0;
+ while (i < text.length) {
+ const start = text.indexOf("<@", i);
+ if (start === -1) {
+ output += text.slice(i);
+ break;
+ }
+ output += text.slice(i, start);
+ const end = text.indexOf(">", start + 2);
+ if (end === -1) {
+ output += text.slice(start);
+ break;
+ }
+ output += " ";
+ i = end + 1;
+ }
+
+ let collapsed = "";
+ for (const ch of output) {
+ const isSpace = ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
+ if (isSpace) {
+ if (collapsed.length > 0 && collapsed[collapsed.length - 1] !== " ") {
+ collapsed += " ";
+ }
+ } else {
+ collapsed += ch;
+ }
+ }
+ return collapsed.trim();
}
export function detectIntent(text: string): ReplyIntent {
From c91f4cff0b84695003a879bfaefa11701202670e Mon Sep 17 00:00:00 2001
From: aamoghS
Date: Mon, 17 Aug 2026 17:43:54 +0000
Subject: [PATCH 04/10] Fix Slack app manifest so Create from manifest succeeds
Slack rejects Event Subscriptions, Interactivity, and /dsgt when Socket Mode
is off and no Request URL is set. Keep the @dsgt bot user and scopes so the
app can be installed; add those HTTP features in the Slack UI after the
webhook is live.
---
apps/dsgt-slack/README.md | 13 +++++++++----
apps/dsgt-slack/manifest.yaml | 12 +-----------
2 files changed, 10 insertions(+), 15 deletions(-)
diff --git a/apps/dsgt-slack/README.md b/apps/dsgt-slack/README.md
index 55ca0ad5..ed56d007 100644
--- a/apps/dsgt-slack/README.md
+++ b/apps/dsgt-slack/README.md
@@ -42,8 +42,10 @@ tokens or they will fight over events.
3. Select the workspace, then paste the contents of
[`manifest.yaml`](./manifest.yaml) (YAML is accepted).
4. Confirm the summary. The app name and bot user display name should both be
- `dsgt`. Socket Mode should be **off**. Request URLs are left unset in the
- manifest so you can create the app before the webhook is deployed.
+ `dsgt`. Socket Mode should be **off**. The manifest does **not** declare
+ Event Subscriptions, Interactivity, or `/dsgt` — Slack rejects those without
+ a Request URL or Socket Mode (`invalid_manifest`). You can add them in the
+ app settings after `{AUTH_URL}/api/webhooks/slack` is live.
5. Click **Create**.
You now have a real Slack app whose bot user is **dsgt**.
@@ -77,8 +79,11 @@ for the club site, host `datasciencegt.org`) and append the path:
```
Set those three URLs in the Slack app settings **after** the App Hosting
-deploy that includes this route is live. Slack will POST a
-`url_verification` challenge; the Next.js route answers it.
+deploy that includes this route is live (Event Subscriptions, Slash Commands
+`/dsgt`, Interactivity). They are not in `manifest.yaml` because Slack will
+not create the app with those features unless a Request URL or Socket Mode is
+set. Slack will POST a `url_verification` challenge; the Next.js route
+answers it.
The Next.js route is
`sites/mainweb/app/(portal)/api/webhooks/stripe`’s neighbor:
diff --git a/apps/dsgt-slack/manifest.yaml b/apps/dsgt-slack/manifest.yaml
index 0508fc0d..62ffdeea 100644
--- a/apps/dsgt-slack/manifest.yaml
+++ b/apps/dsgt-slack/manifest.yaml
@@ -13,27 +13,17 @@ features:
bot_user:
display_name: dsgt
always_online: true
- slash_commands:
- - command: /dsgt
- description: Ask the Data Science @ Georgia Tech bot for help.
- usage_hint: "[help | ping | join]"
- should_escape: false
oauth_config:
scopes:
bot:
- app_mentions:read
- chat:write
+ - chat:write.public
- commands
- im:history
- im:read
- im:write
settings:
- event_subscriptions:
- bot_events:
- - app_mention
- - message.im
- interactivity:
- is_enabled: true
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
From 3f2a0b650dc8bd777abb56611e1a1b9087bf0fc4 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 28 Aug 2026 04:21:11 +0000
Subject: [PATCH 05/10] Lock Hacklytics Notify me to the portal handoff and a
sharp cyan CTA.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every Notify me still opens INTEREST_URL (login → /hacklytics). The shared
pixel-btn drops the 28px glow so the control reads as a cyan rectangle with
black type. Hero copy states the site has no form.
Co-authored-by: Aamogh
---
sites/hacklytics2027/app/globals.css | 24 ++++++++++++----
sites/hacklytics2027/app/page.tsx | 32 ++++++++++++----------
sites/hacklytics2027/components/Navbar.tsx | 4 ++-
sites/hacklytics2027/lib/links.ts | 4 +++
4 files changed, 43 insertions(+), 21 deletions(-)
diff --git a/sites/hacklytics2027/app/globals.css b/sites/hacklytics2027/app/globals.css
index 4753052c..2cbdc98a 100644
--- a/sites/hacklytics2027/app/globals.css
+++ b/sites/hacklytics2027/app/globals.css
@@ -190,20 +190,32 @@ body {
.pixel-lime:hover { --pixel-edge: rgba(200, 255, 0, 0.9); --pixel-glow: rgba(200, 255, 0, 0.4); }
.pixel-purple:hover { --pixel-edge: rgba(155, 0, 255, 0.95); --pixel-glow: rgba(155, 0, 255, 0.45); }
-/* Solid neon slab button — chunky, no radius, steps() so it feels 8-bit */
+/* Solid cyan slab — sharp rectangle, hard pixel edge, no bloom.
+ Glow stays on the brightest sprites, not on every CTA. */
.pixel-btn {
position: relative;
+ border: 0;
border-radius: 0;
background: var(--btn-bg, #00e5ff);
color: #04040a;
+ font-weight: 700;
+ text-decoration: none;
box-shadow:
- 0 -4px 0 0 rgba(255, 255, 255, 0.35),
- 0 4px 0 0 rgba(0, 0, 0, 0.6),
- 0 0 28px var(--btn-glow, rgba(0, 229, 255, 0.5));
+ 0 -3px 0 0 rgba(255, 255, 255, 0.28),
+ 0 3px 0 0 rgba(0, 0, 0, 0.75);
transition: transform 120ms steps(2), box-shadow 160ms steps(2), filter 160ms linear;
}
-.pixel-btn:hover { transform: translateY(-2px); filter: brightness(1.12); }
-.pixel-btn:active { transform: translateY(2px); }
+.pixel-btn:hover { transform: translateY(-2px); filter: brightness(1.08); }
+.pixel-btn:active {
+ transform: translateY(2px);
+ box-shadow:
+ 0 -1px 0 0 rgba(255, 255, 255, 0.2),
+ 0 1px 0 0 rgba(0, 0, 0, 0.8);
+}
+.pixel-btn:focus-visible {
+ outline: 2px solid #f0f0f2;
+ outline-offset: 3px;
+}
/* Neon text glow, tuned per accent */
.neon-cyan { color: #00e5ff; text-shadow: 0 0 8px rgba(0, 229, 255, 0.65), 0 0 22px rgba(0, 229, 255, 0.35); }
diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx
index 6aca16b3..f135b41f 100644
--- a/sites/hacklytics2027/app/page.tsx
+++ b/sites/hacklytics2027/app/page.tsx
@@ -4,7 +4,7 @@ import HomeSections from "@/components/HomeSections";
import PixelGarden, { PixelGround } from "@/components/pixel/PixelGarden";
import PixelSprite from "@/components/pixel/PixelSprite";
import { BLOOM, DAISY, SPROUT, TULIP } from "@/components/pixel/sprites";
-import { INTEREST_URL } from "@/lib/links";
+import { INTEREST_HINT, INTEREST_URL } from "@/lib/links";
// ─── Elegant Floral Background ─────────────────────────────────────────────
const FloralBackground = () => (
@@ -171,21 +171,25 @@ export default function HomePage() {
Join 1,000+ hackers in Atlanta, GA.
- {/* Framer-style CTA Buttons */}
+ {/* Notify me hands off to the portal. No on-site form. */}
-
-
- NOTIFY ME
-
+
+
+ NOTIFY ME →
+
+
+ {INTEREST_HINT}
-
+
NOTIFY ME
@@ -157,6 +158,7 @@ export default function Navbar() {
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
+ aria-label={`Notify me. ${INTEREST_HINT}`}
className="pixel-btn flex items-center justify-center w-full font-pixel text-xs px-8 py-4"
>
NOTIFY ME
diff --git a/sites/hacklytics2027/lib/links.ts b/sites/hacklytics2027/lib/links.ts
index bfe7b504..3fe9b42e 100644
--- a/sites/hacklytics2027/lib/links.ts
+++ b/sites/hacklytics2027/lib/links.ts
@@ -29,3 +29,7 @@ const INTEREST_PATH = "/hacklytics";
export const INTEREST_URL = `${PORTAL_ORIGIN}/login?callbackUrl=${encodeURIComponent(
INTEREST_PATH,
)}`;
+
+/** Caption next to Notify me. This marketing site has no form. */
+export const INTEREST_HINT =
+ "Opens the DS@GT portal. We don't collect emails here.";
From 4fdf49c196ac840b3325c00d45b71c51c07c8a53 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Fri, 28 Aug 2026 04:22:16 +0000
Subject: [PATCH 06/10] Unhide Hacklytics 2027 sections and restyle them as
lists
Tracks, prizes, schedule, and sponsors were compiled but gated behind
SHOW_FUTURE_SECTIONS = false, so navbar anchors went nowhere. Render those
sections, drop last year's prize amounts and sponsor names, and match the
Digital Bloom layout: stacked tracks, Best Overall TBA, one status board,
DS@GT + MLH only, portal-only Notify me, pixel ground strip in the footer.
Co-authored-by: Aamogh
---
sites/hacklytics2027/components/Footer.tsx | 150 ++++++++-------
.../components/HomeSections.tsx | 50 +----
sites/hacklytics2027/components/Navbar.tsx | 91 ++++-----
.../components/sections/AboutSection.tsx | 94 ++++------
.../components/sections/Eyebrow.tsx | 9 +
.../components/sections/FAQSection.tsx | 167 +++++++----------
.../sections/PrizeAndSpeakerSection.tsx | 163 ++++-------------
.../components/sections/Schedule/Schedule.tsx | 173 +++---------------
.../components/sections/Schedule/data.ts | 161 ++--------------
.../components/sections/Sponsor.tsx | 102 +++--------
.../components/sections/TracksSection.tsx | 130 +++++--------
11 files changed, 400 insertions(+), 890 deletions(-)
create mode 100644 sites/hacklytics2027/components/sections/Eyebrow.tsx
diff --git a/sites/hacklytics2027/components/Footer.tsx b/sites/hacklytics2027/components/Footer.tsx
index e30f9376..72efcf66 100644
--- a/sites/hacklytics2027/components/Footer.tsx
+++ b/sites/hacklytics2027/components/Footer.tsx
@@ -1,92 +1,106 @@
"use client";
import Link from "next/link";
-import Image from "next/image";
-import { PixelFloraRow } from "./pixel/PixelBits";
+import { PixelGround } from "./pixel/PixelGarden";
+import { INTEREST_URL, PORTAL_ORIGIN } from "@/lib/links";
+
+const navIds = ["about", "tracks", "prizes", "schedule", "sponsors", "faqs"];
export default function Footer() {
return (
-