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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
SLACK_SIGNING_SECRET=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
# Any long random string; signs the OAuth state param.
# Any long random string; signs the OAuth state param, and the web
# configuration page's session cookies (src/web/session.ts). Rotating it
# signs everyone out of the web page, nothing more.
# openssl rand -hex 32
SLACK_STATE_SECRET=

# Public HTTPS base URL this app is reachable at. Slack posts slash commands
# and events to $PUBLIC_URL/slack/events and redirects OAuth to
# $PUBLIC_URL/slack/oauth_redirect. It must match the app manifest exactly.
# and events to $PUBLIC_URL/slack/events, redirects install OAuth to
# $PUBLIC_URL/slack/oauth_redirect, and redirects web sign-in to
# $PUBLIC_URL/auth/slack/callback. It must match the app manifest exactly.
PUBLIC_URL=https://hawk-bot.example.org
PORT=3000

Expand Down
24 changes: 20 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ src/slack/commands.ts the slash-command router
src/slack/events.ts app_home_opened, app_mention, app_uninstalled
src/slack/authz.ts "is this person a workspace admin", cached 5 minutes
src/slack/home.ts the App Home view, rendered from the command registry
src/slack/settingsWrite.ts
the one write path for settings, shared by the config
command and the web configuration page
src/web/routes.ts the web surface at bot.<domain>: a landing page, and a
configuration page behind Sign in with Slack (OIDC,
identity only — see ADR-0015) plus the HawkBot Admin
check
src/web/session.ts stateless HMAC-signed session cookies; pure, tested
src/web/pages.ts HTML rendering; pure, tested
```

### The command registry is the extension point
Expand Down Expand Up @@ -111,9 +120,14 @@ nothing in the suite — Caddy fronts it.

## Rules that are load-bearing

1. **No user scopes.** Ever. This app is in a workspace shared with minors and
its defensibility rests on being unable to read anything a person could not
see it read. Adding a user scope changes what this app _is_.
1. **No user token scopes.** Ever. This app is in a workspace shared with
minors and its defensibility rests on being unable to read anything a
person could not see it read. Adding a scope that reads or acts as a
person changes what this app _is_. The one deliberate exception: the
OpenID Connect identity scopes (`openid`, `profile`) behind the web
configuration page's Sign in with Slack, which answer "who is this
person" and grant nothing else — the token is used for that single
lookup and discarded, never stored. See ADR-0015.
2. **Never log message text or a token.** `logger.ts` writes JSON to stderr and
that is not an auditable place.
3. **Migrations that have shipped are never edited.** Add another file.
Expand All @@ -122,7 +136,9 @@ nothing in the suite — Caddy fronts it.
`domain/settings.ts`. Nothing team-specific is ever baked into the image.
5. **Keep `BOT_SCOPES` and `docs/slack-app-manifest.yaml` identical.** Slack
grants what the manifest says; Bolt asks for what the code says. When they
disagree the symptom is a command that silently does nothing.
disagree the symptom is a command that silently does nothing. The same
goes for the manifest's OIDC user scopes and `OIDC_SCOPES` in
`src/web/routes.ts`, and for the `/auth/slack/callback` redirect URL.
6. **`.env.example` documents every variable `config.ts` reads.** A missing one
reads as "the feature is off".

Expand Down
3 changes: 3 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ No link out to another surface — the detail lives entirely inside the thread r
**HawkBot Admin**:
Replaces "Slack workspace Owner/Admin" as the authorization model for every admin-gated capability (`/hawkbot config`, `/hawkbot event create`, the season CSV export, and the Reaction Cutoff Verification failure DM) — full replacement, not an additional either/or check. Defined by membership in a dedicated Slack User Group — not Slack's built-in Admins group — so a team can grant bot-admin duties (mentors, team leads) without handing out full Slack workspace administration. An admin configures it by handle (e.g. `hawkbot-admins`); the bot resolves that to the group's Slack-internal id once, at set-time, and checks membership against the id from then on. Slack workspace Owners always retain HawkBot Admin rights regardless of the group's state, as a bootstrap/lockout safety net — otherwise an empty or unconfigured group would mean nobody could run the command needed to fix it. Group membership is cached (same 5-minute-TTL shape as the old per-user admin cache), rather than looked up per check.

**Web Configuration Page**:
The settings editor served at `bot.<domain>/config` (routing by hawk_suite) — the same settings, validation, and usergroup handle→id resolution as `/hawkbot config`, through one shared write path (`slack/settingsWrite.ts`). Gated twice: Sign in with Slack (OpenID Connect, identity-only — see ADR-0015) establishes who the browser is, then the ordinary HawkBot Admin check decides whether they may edit. A landing page at `/` fronts it. No new authorization model and no new stored state beyond a stateless signed session cookie.

**Weekly Summary Post**:
A single Slack message posted to the announcements channel at an admin-configurable day/time (default Sunday, noon), listing every Event scheduled for the 7 days starting the day after that post — a rolling window, not aligned to any fixed Monday–Sunday calendar week (see ADR-0011) — a full planning digest, including Hourly, All-Day, and Multi-Day Events alike, regardless of whether an Event already has (or will soon have) its own Event Check-in Post. A new Weekly Summary Post goes out every cycle without deleting the previous one — see ADR-0014; the channel accumulates one post per week, each a snapshot of what was upcoming as of that post's date, rather than there ever being exactly one "current" post. A Multi-Day Event appears here exactly once, as its own date-range line — its Multi-Day Child Events are never listed individually, so the same competition is never shown more than once.
_Avoid_: "this week" or "calendar week" implying Monday–Sunday alignment — "This Week" is the Slack-facing label, but the underlying window floats with whatever day the post lands on (see ADR-0011).
Expand Down
12 changes: 12 additions & 0 deletions docs/adr/0015-web-config-page-authenticates-with-slack-oidc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# The web configuration page authenticates with Sign in with Slack (OIDC), and OIDC identity scopes are not "user scopes"

Hawk Bot now serves a landing page at `/` and a configuration page at `/config` (`bot.<domain>`, routed by hawk_suite). The configuration page edits the exact same settings as `/hawkbot config` — same `domain/settings.ts` validation, same usergroup handle→id resolution, one shared write path (`slack/settingsWrite.ts`) — and is gated the same way: sign in, then the HawkBot Admin check from `slack/authz.ts` (ADR-0004).

Sign-in is Sign in with Slack, Slack's OpenID Connect flow, with the identity scopes `openid` and `profile`. That deserves a careful reading against the repo's first load-bearing rule, "no user scopes, ever", because in a Slack app manifest OIDC scopes are listed under `oauth_config.scopes.user`. The rule's _reason_ is what governs: the app's defensibility rests on being unable to read anything a person could not watch it read. OIDC identity scopes grant no such ability. The token Slack returns from the sign-in exchange can call exactly one method, `openid.connect.userInfo` — "who is this person" — and nothing else: no messages, no channels, no acting on anyone's behalf. Hawk Bot uses it for that single call and discards it; it is never stored (there is nothing it could be used for later) and never logged (rule 2). The rule's wording in `CLAUDE.md` is sharpened accordingly: no user _token_ scopes — nothing that lets the app read or do anything as a person; identity-only OIDC scopes for sign-in are the one deliberate exception.

Alternatives considered:

- **A magic link DM'd via a slash command** (`/hawkbot weblogin`) would have needed no manifest change at all, but invents a bespoke login flow — with its own token lifetime, single-use, and phishing questions — to avoid a standard one whose entire grant is "name and id". Slack's own hosted sign-in screen is more legible to a mentor than a bot DM containing a login link, which is exactly the shape phishing training teaches people to distrust.
- **A separate password or allow-list** would violate the standing decision that authorization lives in Slack (ADR-0004): a stored credential list is precisely the drift — someone keeping access after they've left — that model exists to prevent.

The web session is a stateless HMAC-signed cookie (12 h, signed with `SLACK_STATE_SECRET`), so the database stores nothing new: a session merely caches "Slack said this is user U in team T", and every page load still runs the live HawkBot Admin check, so removal from the admin group takes effect within its 5-minute cache like everywhere else.
14 changes: 13 additions & 1 deletion docs/slack-app-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ features:
oauth_config:
redirect_urls:
- https://hawk-bot.example.org/slack/oauth_redirect
# Sign in with Slack (OpenID Connect) for the web configuration page —
# see src/web/routes.ts and ADR-0015.
- https://hawk-bot.example.org/auth/slack/callback
scopes:
# Keep identical to BOT_SCOPES in src/slack/app.ts.
bot:
Expand Down Expand Up @@ -69,7 +72,16 @@ oauth_config:
# reading that group's membership — the other half of the
# authorization model, alongside the Owner check above.
- usergroups:read
# Deliberately none. Hawk Bot never acts on a person's behalf.
# These are Sign in with Slack's OpenID Connect identity scopes, NOT user
# token scopes — the deliberate, sole exception to "no user scopes". The
# token they yield can call openid.connect.userInfo ("who is this
# person") and nothing else: no messages, no acting on anyone's behalf.
# It is used for that one call during web sign-in and discarded, never
# stored. See ADR-0015. Keep identical to OIDC_SCOPES in
# src/web/routes.ts.
user:
- openid
- profile

settings:
interactivity:
Expand Down
59 changes: 23 additions & 36 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { WebClient } from "@slack/web-api";
import { SLASH_COMMAND } from "../brand.js";
import { clearSetting, getSetting, setSetting } from "../db/repo.js";
import { getSetting } from "../db/repo.js";
import { formatResolvedValue } from "../domain/configDisplay.js";
import { SETTINGS, checkSetting, findSetting } from "../domain/settings.js";
import { SETTINGS } from "../domain/settings.js";
import { resolveName } from "../slack/nameResolution.js";
import {
applySettingInput,
applySettingUnset,
} from "../slack/settingsWrite.js";
import type { Command } from "./types.js";

async function describeCurrentValue(
Expand Down Expand Up @@ -56,16 +60,12 @@ export const config: Command = {
text: `Usage: \`${SLASH_COMMAND} config unset <key>\``,
};
}
const setting = findSetting(key);
if (!setting) {
const known = SETTINGS.map((s) => s.key).join(", ");
return { text: `Unknown setting \`${key}\`. Known: ${known}` };
}
const removed = clearSetting(setting.key);
const outcome = applySettingUnset(key);
if (!outcome.ok) return { text: outcome.reason };
return {
text: removed
? `Unset \`${setting.key}\`.`
: `\`${setting.key}\` was already unset.`,
text: outcome.removed
? `Unset \`${outcome.key}\`.`
: `\`${outcome.key}\` was already unset.`,
};
}

Expand All @@ -81,33 +81,20 @@ export const config: Command = {
};
}

const checked = checkSetting(key, valueParts.join(" "));
if (!checked.ok) return { text: checked.reason };

// All three usergroup settings are set by handle, but membership checks
// need the Slack-internal group id — resolved here, once, rather than on
// every check. See CONTEXT.md, HawkBot Admin.
if (
checked.key === "admin_usergroup" ||
checked.key === "student_usergroup" ||
checked.key === "mentor_usergroup"
) {
const groups = await ctx.client.usergroups.list({});
const match = groups.usergroups?.find(
(g) => g.handle?.toLowerCase() === checked.value.toLowerCase()
);
if (!match?.id) {
return {
text: `No Slack User Group found with handle \`${checked.value}\`. Check the handle and try again.`,
};
}
setSetting(checked.key, match.id, ctx.userId);
// Validation and the usergroup handle→id resolution live in
// slack/settingsWrite.ts, shared with the web configuration page.
const outcome = await applySettingInput(
ctx.client,
key,
valueParts.join(" "),
ctx.userId
);
if (!outcome.ok) return { text: outcome.reason };
if (outcome.groupHandle) {
return {
text: `Set \`${checked.key}\` to the group \`@${checked.value}\` (\`${match.id}\`).`,
text: `Set \`${outcome.key}\` to the group \`@${outcome.groupHandle}\` (\`${outcome.storedValue}\`).`,
};
}

setSetting(checked.key, checked.value, ctx.userId);
return { text: `Set \`${checked.key}\` to \`${checked.value}\`.` };
return { text: `Set \`${outcome.key}\` to \`${outcome.storedValue}\`.` };
},
};
5 changes: 4 additions & 1 deletion src/slack/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { APP_NAME, BRAND, ICON_SVG, SLASH_COMMAND } from "../brand.js";
import { config } from "../config.js";
import { healthHandler } from "../health.js";
import { log } from "../logger.js";
import { webRoutes } from "../web/routes.js";
import { registerAttendanceEvents } from "./attendanceEvents.js";
import { registerCommands } from "./commands.js";
import { registerEvents } from "./events.js";
Expand Down Expand Up @@ -65,10 +66,12 @@ export function createApp(): App {
stateSecret: cfg.SLACK_STATE_SECRET,
scopes: BOT_SCOPES,
installationStore,
// Bolt owns the HTTP server, so the container's health endpoint has to be
// Bolt owns the HTTP server, so the container's health endpoint and the
// web pages (landing + configuration; see web/routes.ts) have to be
// registered through it rather than served alongside.
customRoutes: [
{ path: "/health", method: ["GET"], handler: healthHandler },
...webRoutes(),
],
redirectUri: `${cfg.PUBLIC_URL}/slack/oauth_redirect`,
installerOptions: {
Expand Down
81 changes: 81 additions & 0 deletions src/slack/settingsWrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { WebClient } from "@slack/web-api";
import { clearSetting, setSetting } from "../db/repo.js";
import type { SettingKey } from "../domain/settings.js";
import { checkSetting, findSetting, SETTINGS } from "../domain/settings.js";

/**
* The one write path for workspace settings, shared by `/hawkbot config set`
* and the web configuration page so the two surfaces cannot drift: same
* validation (domain/settings.ts), same usergroup handle→id resolution, same
* stored value. Callers format the outcome for their own medium — Slack
* markdown or HTML — which is why this returns data, not a message.
*/

export type SetOutcome =
| {
ok: true;
key: SettingKey;
/** What actually went into the settings table. */
storedValue: string;
/** Present when a usergroup handle was resolved to a group id. */
groupHandle?: string;
}
| { ok: false; reason: string };

export async function applySettingInput(
client: WebClient,
rawKey: string,
rawValue: string,
setBy: string
): Promise<SetOutcome> {
const checked = checkSetting(rawKey, rawValue);
if (!checked.ok) return { ok: false, reason: checked.reason };

// All three usergroup settings are set by handle, but membership checks
// need the Slack-internal group id — resolved here, once, rather than on
// every check. See CONTEXT.md, HawkBot Admin. Deliberately a fresh list,
// not nameResolution.ts's 5-minute cache: a group created moments ago must
// be settable immediately.
if (
checked.key === "admin_usergroup" ||
checked.key === "student_usergroup" ||
checked.key === "mentor_usergroup"
) {
const groups = await client.usergroups.list({});
const match = groups.usergroups?.find(
(g) => g.handle?.toLowerCase() === checked.value.toLowerCase()
);
if (!match?.id) {
return {
ok: false,
reason: `No Slack User Group found with handle \`${checked.value}\`. Check the handle and try again.`,
};
}
setSetting(checked.key, match.id, setBy);
return {
ok: true,
key: checked.key,
storedValue: match.id,
groupHandle: checked.value,
};
}

setSetting(checked.key, checked.value, setBy);
return { ok: true, key: checked.key, storedValue: checked.value };
}

export type UnsetOutcome =
| { ok: true; key: SettingKey; removed: boolean }
| { ok: false; reason: string };

export function applySettingUnset(rawKey: string): UnsetOutcome {
const setting = findSetting(rawKey);
if (!setting) {
const known = SETTINGS.map((s) => s.key).join(", ");
return {
ok: false,
reason: `Unknown setting \`${rawKey}\`. Known: ${known}`,
};
}
return { ok: true, key: setting.key, removed: clearSetting(setting.key) };
}
Loading
Loading