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
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,20 @@ Activity after the fact is the only thing that separates a genuine remedy from a
old thread being used to launder a fresh violation. Acknowledged, not resolved:
the 1:1 still happened.

**The browser gets two pages, and the second is the same door as Slack.**
`src/web/` serves a landing page at `/` and a configuration page at `/config`
(this is what mod.redhawkrobotics.org shows). `/config` signs people in with
Slack — OpenID Connect against the same app, identity only, no stored token —
and then asks `administrator()` exactly as the slash command does, on every
request, so the cookie only says _who_ and losing Slack admin locks the page
within a minute. Writes go through `slack/settingsAdmin.ts`, the one
implementation of setting validation shared with `/hawkmod config set`; keep it
that way, or a value one door refuses becomes reachable through the other. The
session cookie and OAuth state are stateless HMAC tokens (`web/session.ts`,
signed with `SLACK_STATE_SECRET`, purpose-bound so one kind can never replay as
the other). Every string a page interpolates goes through `esc()` — setting
values and display names are whatever their owner typed.

**Two paths reach the same log.** Events (`src/slack/events.ts`) give real-time
capture; the hourly backfill (`src/monitor/backfill.ts`) re-walks each adult's
DM list to catch history predating enrollment and anything missed while the
Expand Down
12 changes: 12 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ before setup — running and waiting.
4. Send every adult the same install URL, and watch `/hawkmod status` until
coverage reads N/N.

## Web pages

The root URL serves a landing page — what a person who types the domain into a
browser should see — with the enrollment link and a link to `/config`, a
configuration page equivalent to `/hawkmod config`. It uses Sign in with Slack
(OpenID Connect, identity only — no scopes, no stored token) and then applies
the same rule as every other entry point: workspace Owners and Admins only.
Both redirect URLs in the manifest must be registered or the matching flow
fails at Slack before anyone sees a consent screen:
`/slack/oauth_redirect` for installs and enrollment, `/auth/slack/callback`
for sign-in.

## Upgrades

Migrations are applied on boot, so an upgrade is:
Expand Down
3 changes: 3 additions & 0 deletions docs/slack-app-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ features:
oauth_config:
redirect_urls:
- https://hawk-mod.example.org/slack/oauth_redirect
# Sign in with Slack (OpenID Connect), for the web configuration page.
# Identity only — signing in grants no scopes and stores no token.
- https://hawk-mod.example.org/auth/slack/callback
scopes:
bot:
- chat:write
Expand Down
4 changes: 4 additions & 0 deletions src/slack/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerCommands } from "./commands.js";
import { registerEvents } from "./events.js";
import { registerViews } from "./modals.js";
import { GROUP_ADMIN_METADATA, installationStore } from "./installStore.js";
import { webRoutes } from "../web/routes.js";

/** Read-only apart from posting alerts. hawk-mod never needs to act as a user. */
export const BOT_SCOPES = [
Expand Down Expand Up @@ -122,6 +123,9 @@ export function createApp(): App {
method: ["GET"],
handler: authorizeGroups,
},
// The landing page and the Sign in with Slack–gated configuration page —
// what mod.redhawkrobotics.org serves to a browser.
...webRoutes,
],
redirectUri: `${cfg.PUBLIC_URL}/slack/oauth_redirect`,
installerOptions: {
Expand Down
90 changes: 1 addition & 89 deletions src/slack/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,15 @@ import { screeningStatus } from "../domain/rules/screening.js";
import { log } from "../logger.js";
import {
isSettingKey,
parseHandles,
SETTING_KEYS,
SETTINGS,
setting,
settingValue,
type SettingKey,
} from "../settings.js";
import { describeValue, validateSetting } from "./settingsAdmin.js";
import { backfillAll } from "../monitor/backfill.js";
import { administrator, type Actor, NOT_PERMITTED } from "./authz.js";
import { applyGroupEdit } from "./groupAdmin.js";
import { resolveGroup } from "./userGroups.js";
import { openConsent, openScreening } from "./modals.js";
import { runSweep } from "../jobs/sweep.js";
import { syncRolesFromUserGroups } from "../jobs/syncRoles.js";
Expand Down Expand Up @@ -640,35 +638,6 @@ async function configText(
return lines.join("\n");
}

/**
* Renders a stored value the way a person wrote it.
*
* Channels are stored by id, deliberately — an id survives the channel being
* renamed, and a stored `#name` would quietly stop resolving the day somebody
* tidied it up. But `C0BPAV78LKZ` tells a reader nothing, so the id is what is
* kept and the name is what is shown. Falls back to the raw value if Slack
* cannot be asked: a settings listing that throws is worse than one that is
* briefly ugly.
*/
async function describeValue(
client: WebClient,
key: SettingKey,
value: string
): Promise<string> {
if (SETTINGS[key].kind === "channel") {
try {
const info = await client.conversations.info({ channel: value });
return info.channel?.name ? `#${info.channel.name}` : `\`${value}\``;
} catch {
return `\`${value}\``;
}
}
const handles = parseHandles(value);
return handles.length
? handles.map((h) => `@${h}`).join(", ")
: `\`${value}\``;
}

async function configListing(client: WebClient): Promise<string> {
const rows = await Promise.all(
SETTING_KEYS.map(async (key) => {
Expand Down Expand Up @@ -698,60 +667,3 @@ async function configListing(client: WebClient): Promise<string> {
"and cannot be changed from here._",
].join("\n");
}

/**
* Checks a value against Slack before storing it.
*
* A user group handle that does not resolve is a typo, and a stored typo reads
* exactly like an empty group: nobody rostered, nobody monitored, no complaint.
* The sweep would raise that eventually; refusing it here turns tomorrow's
* finding into an error message the person who caused it is still reading.
*/
async function validateSetting(
client: WebClient,
key: SettingKey,
raw: string
): Promise<{ value: string } | { error: string }> {
const kind = SETTINGS[key].kind;

if (kind === "channel") {
// `<#C123|name>` when escaping is on, a bare id or #name when it is not.
const id = raw.match(/^<#([A-Z0-9]+)/i)?.[1] ?? raw.replace(/^#/, "");
try {
const info = await client.conversations.info({ channel: id });
if (!info.channel?.id) return { error: `No channel \`${raw}\`.` };
return { value: info.channel.id };
} catch (err) {
return {
error:
`Couldn't read \`${raw}\`: ${String(err)}\n` +
`hawk-mod must be a member of the channel it posts findings to.`,
};
}
}

const handles = raw
.split(",")
.map((h) => h.trim())
.filter(Boolean)
.map((h) => h.match(/^<!subteam\^[A-Z0-9]+\|@?([^>]+)>$/i)?.[1] ?? h)
.map((h) => h.replace(/^@/, ""));

if (kind === "usergroup" && handles.length !== 1) {
return { error: `\`${key}\` takes exactly one user group.` };
}

for (const handle of handles) {
const group = await resolveGroup(client, handle);
if (!group) {
return {
error:
`No user group @${handle} in this workspace. Nothing was changed — ` +
`a stored typo looks exactly like an empty group, which is why this ` +
`is checked before saving.`,
};
}
}

return { value: handles.join(",") };
}
95 changes: 95 additions & 0 deletions src/slack/settingsAdmin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { WebClient } from "@slack/web-api";
import { parseHandles, SETTINGS, type SettingKey } from "../settings.js";
import { resolveGroup } from "./userGroups.js";

/**
* The write path for settings, shared by the two doors that change them: the
* `/hawkmod config` slash command and the web configuration page. One
* implementation, so a value the command would refuse cannot be slipped in
* from a browser, and vice versa.
*/

/**
* Renders a stored value the way a person wrote it.
*
* Channels are stored by id, deliberately — an id survives the channel being
* renamed, and a stored `#name` would quietly stop resolving the day somebody
* tidied it up. But `C0BPAV78LKZ` tells a reader nothing, so the id is what is
* kept and the name is what is shown. Falls back to the raw value if Slack
* cannot be asked: a settings listing that throws is worse than one that is
* briefly ugly. Plain text — the caller decides what Slack mrkdwn or HTML to
* wrap it in.
*/
export async function describeValue(
client: WebClient,
key: SettingKey,
value: string
): Promise<string> {
if (SETTINGS[key].kind === "channel") {
try {
const info = await client.conversations.info({ channel: value });
return info.channel?.name ? `#${info.channel.name}` : value;
} catch {
return value;
}
}
const handles = parseHandles(value);
return handles.length ? handles.map((h) => `@${h}`).join(", ") : value;
}

/**
* Checks a value against Slack before storing it.
*
* A user group handle that does not resolve is a typo, and a stored typo reads
* exactly like an empty group: nobody rostered, nobody monitored, no complaint.
* The sweep would raise that eventually; refusing it here turns tomorrow's
* finding into an error message the person who caused it is still reading.
*/
export async function validateSetting(
client: WebClient,
key: SettingKey,
raw: string
): Promise<{ value: string } | { error: string }> {
const kind = SETTINGS[key].kind;

if (kind === "channel") {
// `<#C123|name>` when escaping is on, a bare id or #name when it is not.
const id = raw.match(/^<#([A-Z0-9]+)/i)?.[1] ?? raw.replace(/^#/, "");
try {
const info = await client.conversations.info({ channel: id });
if (!info.channel?.id) return { error: `No channel \`${raw}\`.` };
return { value: info.channel.id };
} catch (err) {
return {
error:
`Couldn't read \`${raw}\`: ${String(err)}\n` +
`hawk-mod must be a member of the channel it posts findings to.`,
};
}
}

const handles = raw
.split(",")
.map((h) => h.trim())
.filter(Boolean)
.map((h) => h.match(/^<!subteam\^[A-Z0-9]+\|@?([^>]+)>$/i)?.[1] ?? h)
.map((h) => h.replace(/^@/, ""));

if (kind === "usergroup" && handles.length !== 1) {
return { error: `\`${key}\` takes exactly one user group.` };
}

for (const handle of handles) {
const group = await resolveGroup(client, handle);
if (!group) {
return {
error:
`No user group @${handle} in this workspace. Nothing was changed — ` +
`a stored typo looks exactly like an empty group, which is why this ` +
`is checked before saving.`,
};
}
}

return { value: handles.join(",") };
}
Loading
Loading