feat(discord): add Terraform-managed base allowlist and admins#34
Conversation
Adds a two-row DynamoDB design for the Discord config: a Terraform-managed
BASE#discord row holds an immutable floor of guild IDs and admin user/role
IDs that the management UI can never remove. The app-managed CONFIG#discord
row continues to hold dynamic entries added via the UI.
- New Terraform vars: base_allowed_guilds, base_admin_user_ids,
base_admin_role_ids; written to a BASE#discord DynamoDB item on every
apply (no ignore_changes — re-apply updates the base).
- Shared: BaseDiscordConfig type; getBaseDiscordConfig() and
getEffectiveDiscordConfig() (merged union used by canRun()).
- Lambda handlers switch from getDiscordConfig to getEffectiveDiscordConfig
so base entries are always enforced.
- DiscordConfigService: loadBase() with its own cache, getBaseConfig(),
removeAllowedGuild() returns {ok,reason} and rejects base guilds,
getRedacted() includes baseAllowedGuilds and baseAdmins.
- Controller: GET /discord/guilds and GET /discord/admins now return base
lists alongside dynamic ones; DELETE /discord/guilds/:guildId surfaces
a 400 when the guild is Terraform-managed.
https://claude.ai/code/session_01GxkgH9sABMbHDCQqXehHP1
…list - terraform.tfvars.example: add commented-out base_allowed_guilds, base_admin_user_ids, base_admin_role_ids with usage notes. - docs/components/terraform.md: update discord_store.tf description and add the three new variables to the Variables table. - docs/setup.md: add a paragraph under step 7 explaining how to seed a permanent base guild/admin floor via tfvars. - CLAUDE.md: add a "Checklist for Terraform variable changes" section so all four touch-points (variables.tf, tfvars.example, terraform.md, setup.md) are updated together in future changes. https://claude.ai/code/session_01GxkgH9sABMbHDCQqXehHP1
There was a problem hiding this comment.
Pull request overview
Adds a Terraform-managed “base” Discord configuration (immutable via UI) that is merged with the app-managed dynamic Discord config, so allowlisted guilds/admins can have an operator-enforced floor while still supporting UI-managed additions.
Changes:
- Introduces new Terraform variables and a DynamoDB
BASE#discordrow to persist base allowlist/admins on eachterraform apply. - Adds shared code to read the base row and produce an “effective” merged config, and updates Lambdas to use it for permission checks.
- Updates the management server/service/controller + shared types to surface base lists to the UI and prevent deleting base allowlisted guilds.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| terraform/variables.tf | Adds Terraform variables for base allowed guilds/admin user IDs/admin role IDs. |
| terraform/discord_store.tf | Writes the BASE#discord DynamoDB item (conditionally) from Terraform. |
| app/packages/shared/src/types.ts | Adds BaseDiscordConfig and extends RedactedDiscordConfig with base fields. |
| app/packages/shared/src/ddb/configStore.ts | Adds getBaseDiscordConfig() + getEffectiveDiscordConfig() to read/merge base + dynamic config. |
| app/packages/shared/src/ddb/configStore.test.ts | Adds coverage for base config parsing and effective merge behavior. |
| app/packages/server/src/services/DiscordConfigService.ts | Adds base config loading/caching, includes base in redacted config, blocks deleting base guilds. |
| app/packages/server/src/services/DiscordConfigService.test.ts | Updates mocks/tests for base config inclusion + base guild deletion refusal. |
| app/packages/server/src/controllers/discord.controller.ts | Extends guild/admin endpoints to return base lists; returns 400 on delete of base guild. |
| app/packages/lambda/interactions/src/handler.ts | Switches permission-read path to getEffectiveDiscordConfig(). |
| app/packages/lambda/interactions/src/handler.test.ts | Updates mocks to match getEffectiveDiscordConfig() usage. |
| app/packages/lambda/followup/src/handler.ts | Switches permission-read path to getEffectiveDiscordConfig(). |
| app/packages/lambda/followup/src/handler.test.ts | Updates mocks to match getEffectiveDiscordConfig() usage. |
| # ── Discord base allowlist / admins (optional) ─────────────────────────────── | ||
| # These lists are Terraform-managed and written to a separate DynamoDB row | ||
| # (BASE#discord) on every `terraform apply`. They form an immutable floor that | ||
| # the management UI can never remove — operators can only add/remove entries | ||
| # they themselves added via the UI. | ||
| # | ||
| # Leave all three empty (the default) to manage everything through the UI. | ||
| # Edit the lists in terraform.tfvars and re-apply to change the base set. | ||
|
|
||
| variable "base_allowed_guilds" { | ||
| description = "Guild IDs permanently allowlisted by Terraform. Cannot be removed via the management UI." | ||
| type = list(string) | ||
| default = [] |
There was a problem hiding this comment.
PR title doesn't follow the required Conventional Commits format. Per CONTRIBUTING.md, the title becomes the squash-merge commit subject; please rename it to something like "feat(terraform): add Terraform-managed Discord base config" (optionally include a scope) and keep it under ~70 chars.
There was a problem hiding this comment.
Fixed — title updated to feat(discord): add Terraform-managed base allowlist and admins.
Generated by Claude Code
| /** | ||
| * Read the Terraform-managed BASE#discord row. Empty base returned when the | ||
| * row is absent (i.e. no base Terraform variables were set). Result is cached | ||
| * until `invalidateCache()` is called, same as the dynamic config cache. | ||
| */ | ||
| private async loadBase(): Promise<BaseDiscordConfig> { | ||
| if (this.baseCache) return this.baseCache; | ||
| if (this.baseInflight) return this.baseInflight; | ||
| this.baseInflight = (async () => { | ||
| try { | ||
| const tableName = this.config.getTfOutputs()?.discord_table_name; | ||
| if (!tableName) return { allowedGuilds: [], admins: { userIds: [], roleIds: [] } }; | ||
| const base = await getBaseDiscordConfig(tableName); | ||
| this.baseCache = base; | ||
| return base; |
There was a problem hiding this comment.
DiscordConfigService caches the BASE#discord row indefinitely (until invalidateCache), but the base config can change via terraform apply without any app-side invalidation. This can leave the UI (and removeAllowedGuild's base check) using stale base lists until a server restart; consider not caching the base row, or adding a short TTL/refresh mechanism so it eventually reflects Terraform updates.
There was a problem hiding this comment.
Declined — the dynamic CONFIG#discord row has identical caching behaviour (cached until a write invalidates it) and the same stale-after-out-of-band-edit risk. The base config changes only on terraform apply, which is a deliberate infrastructure operation; an operator who just ran apply would restart the app or wait for the next natural invalidation. Adding TTL complexity for an event that happens a handful of times over the lifetime of a deployment isn't warranted.
Generated by Claude Code
Summary
Introduces a Terraform-managed baseline for Discord allowlists and admin lists that cannot be removed via the management UI. This allows operators to enforce a permanent floor of allowed guilds and admins while still permitting the UI to manage additional entries.
Key Changes
New
BaseDiscordConfigtype in shared types representing the read-only Terraform baseline (allowedGuilds and admins)New DynamoDB row
BASE#discordwritten by Terraform on every apply when any base list is non-empty, with three new Terraform variables:base_allowed_guilds: Guild IDs permanently allowlistedbase_admin_user_ids: User IDs with permanent admin privilegesbase_admin_role_ids: Role IDs with permanent admin privilegesNew
getBaseDiscordConfig()function reads the BASE#discord row with strongly consistent reads, returning an empty base when absentNew
getEffectiveDiscordConfig()function merges the Terraform base and dynamic CONFIG#discord rows, deduplicating entries. This is now used by both Lambda handlers for permission checksUpdated
DiscordConfigServiceto:getBaseConfig()method for retrieving the base configUpdated REST API endpoints to return both dynamic and base lists:
GET /discord/guildsandGET /discord/adminsnow includebaseGuildsandbaseAdminsDELETE /discord/guilds/:guildIdreturns 400 if the guild is in the base configUpdated Lambda handlers (interactions and followup) to use
getEffectiveDiscordConfig()instead ofgetDiscordConfig()for permission checks, ensuring base entries are always enforcedImplementation Details
DiscordConfigServiceand invalidated alongside the dynamic configcountto skip creation when all base lists are empty (UI-only deployments)baseAllowedGuildsandbaseAdminsfields in the redacted confighttps://claude.ai/code/session_01GxkgH9sABMbHDCQqXehHP1