Skip to content

Configure hawk-mod from Slack, not from a file on the host - #10

Merged
tytremblay merged 3 commits into
mainfrom
runtime-config
Aug 21, 2026
Merged

Configure hawk-mod from Slack, not from a file on the host#10
tytremblay merged 3 commits into
mainfrom
runtime-config

Conversation

@tytremblay

Copy link
Copy Markdown
Contributor

Stacked on #9 — base is manage-user-groups, so review that one first.

Which user group declares students is a decision a Slack admin makes, and until now only an SSH session could change it. src/slack/authz.ts already rejected that shape of problem once:

"a fresh install had an empty roster, so nobody could run /hawkmod, and the only way out was a shell on the host running the CLI. An app whose first-run instruction is 'SSH into the server' is broken."

That principle got applied to admin authority and then left unapplied to the settings that decide who is monitored.

What moves

student-group, mentor-group, managed-groups, alert-channel.

src/settings.ts resolves each database → environment → unset, so the env var becomes a seed rather than the source of truth. Nothing breaks for a running host and there is no flag day — a value set from Slack simply wins.

/hawkmod config
• student-group — students   (set here)
• mentor-group  — mentors    (set here)
• managed-groups — —         (not set)
• alert-channel — C0123ABCD  (from ALERT_CHANNEL_ID)

/hawkmod config set mentor-group @mentors

Showing where each value came from is half the feature. "Which group is it actually using, and who set it" is the question asked when the roster looks wrong, and answering it previously took two rounds of guessing and an SSH session.

What cannot move, and why that's an allowlist rather than an oversight

Slack credentials can't be configured from Slack — you can't set the thing that lets hawk-mod reach Slack. And TOKEN_ENCRYPTION_KEY must never be reachable: changing it makes every stored token undecryptable and every enrolled adult invisible, with coverage still reporting 100%. A test asserts those keys stay unreachable, so widening the allowlist carelessly fails CI.

Two safety properties worth reviewing closely

Handles are validated against Slack before storing. A stored typo reads exactly like an empty group: nobody rostered, nobody monitored, no complaint. The sweep would raise it eventually; refusing it turns tomorrow's finding into an error the person who caused it is still reading.

Changing a role group re-syncs immediately. Leaving it until 3am would mean the setting looked applied and was not.

The alert channel forced an honest reckoning

Making ALERT_CHANNEL_ID optional made the typechecker surface all five places that assumed a channel exists. An unset alert channel means a finding recorded and announced to nobody — the exact failure this project defines itself against. So it's an error at boot and an error at every use, naming the finding going unreported and the command that fixes it. It is never silent.

Verified

  • CLI on a completely empty environment (env -i) — exits 0, migrations apply. settings.ts reads process.env directly rather than through config(), which would throw at import time and break import-roster / findings. This is the constraint CLAUDE.md is most emphatic about.
  • Precedence end-to-end against a real database: env-only reads env; after setSetting it reads slack; the editable-group allowlist widens; setting_changes records (unset) -> mentors by Ty.

npm run typecheck && npm test && npm run format:check && npm run build — 94 tests, all green.

Not in scope

Cron schedules and TZ stay in the environment. They're read once at boot to register jobs, so making them live means re-registering jobs on change — real work for something set once. Say the word if you want them too.

🤖 Generated with Claude Code

tytremblay and others added 3 commits August 20, 2026 22:35
The roster-row guard on `/hawkmod group` was a deadlock. Rows are created
by the user group sync, which only creates them for people already in a
role group — so the command whose job is putting somebody in their first
group refused everybody who needed it. The only ways onto the roster were
editing the group by hand in Slack, or the CSV import, which needs a
shell on the host. An app whose first-run instruction is "SSH into the
server" is broken, and this had quietly reintroduced that.

The comment justifying the guard — that an edit should never be the first
thing hawk-mod learns about someone — had it backwards. Joining a role
group is *how* someone becomes known; that is what the sync's `create`
decision is for.

So the subject of an edit is now a Slack account, not a roster row.
`GroupEditRequest.subject` already allowed this; only the command
insisted otherwise. The write fires `subteam_members_changed`, the sync
creates the row moments later, and the reply says so rather than leaving
a caller to wonder why `whois` finds nothing yet.

Unchanged: an unrostered person added to the adults group needs no
written reason, because they have no role to lose. That is what the sync
has always done with someone added to that group by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The adults group is @mentors in production. Nothing was broken by the
placeholder — both handles come from STUDENT_USERGROUP/ADULT_USERGROUP at
runtime — but every example, prompt and fallback said "adults", which is
the wrong thing to type and the wrong thing to look for in Slack.

Changed where the text names a handle somebody types: .env.example, the
setup script's prompt, the README's example, the `?? "adults"` fallbacks,
and the test fixtures, so the regression test around moving a student
into the mentors group reads the way production does.

Left alone where "adults" is the role rather than the group. The two-adult
rule, `isAdult`, screened adults, and the guidance text are about people,
not about what a Slack group is called. `domain/rules/rosterSync.ts` stays
handle-agnostic too — it is handed two sets and never learns what anyone
named them, which is what keeps it a pure rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which user group declares students is a decision a Slack admin makes, and
until now only an SSH session could change it. `authz.ts` already rejected
that shape of problem once — "an app whose first-run instruction is 'SSH
into the server' is broken" — and then it was left true of the settings
that decide who is monitored.

Four settings move: the student group, the mentor group, the editable-group
allowlist, and the alert channel. `src/settings.ts` resolves each one
database → environment → unset, so the env var becomes a seed rather than
the source of truth. Nothing breaks for a running host and there is no flag
day; a value set from Slack simply wins.

`/hawkmod config` shows every value *and where it came from*. That second
half is the point: "which group is it actually using, and who set it" is
the question asked when the roster looks wrong, and it cost two rounds of
guessing to answer before this existed.

What cannot move is an allowlist, not an oversight. Slack credentials
cannot be configured from Slack, and TOKEN_ENCRYPTION_KEY must never be
reachable — changing it makes every stored token undecryptable and every
enrolled adult invisible, with coverage still reporting 100%. A test
asserts those keys stay unreachable.

Handles are validated against Slack before they are stored, because a
stored typo reads exactly like an empty group: nobody rostered, nobody
monitored, no complaint. Changing a role group re-syncs immediately rather
than at 3am, so the setting cannot look applied while it is not.

Making the alert channel optional meant every caller had to face what an
unset one means: a finding recorded and announced to nobody. It is an
error at boot and an error at every use, naming the finding going
unreported and the command that fixes it.

`settings.ts` reads process.env directly rather than through `config()`,
which would throw at import time in the CLI. Verified: the CLI still runs
on an empty environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tytremblay
tytremblay changed the base branch from manage-user-groups to main August 21, 2026 02:52
@tytremblay
tytremblay merged commit fa50dc3 into main Aug 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant