@open-everywhere/provider-discord 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/agent/approvals/discord-admin.ts +5 -4
  2. package/agent/approvals/discord-flow.ts +2 -1
  3. package/agent/channels/discord-backscroll.ts +2 -1
  4. package/agent/channels/discord-bot-name.ts +2 -1
  5. package/agent/channels/discord-channel-info.ts +3 -1
  6. package/agent/channels/discord-credentials.ts +41 -0
  7. package/agent/channels/discord-directory.ts +2 -1
  8. package/agent/channels/discord-events.ts +3 -3
  9. package/agent/channels/discord-front.ts +37 -3
  10. package/agent/channels/discord-gateway.ts +10 -9
  11. package/agent/channels/discord-invite.ts +2 -1
  12. package/agent/channels/discord-message-ops.ts +6 -5
  13. package/agent/channels/discord-post.ts +10 -9
  14. package/agent/channels/discord-read.ts +2 -1
  15. package/agent/channels/discord.ts +11 -4
  16. package/agent/commands/discord-command-sync.ts +13 -10
  17. package/agent/docs-generated.ts +55 -34
  18. package/agent/ops.ts +9 -4
  19. package/agent/register.ts +8 -4
  20. package/dist/agent/approvals/discord-admin.d.ts.map +1 -1
  21. package/dist/agent/approvals/discord-admin.js +5 -4
  22. package/dist/agent/approvals/discord-admin.js.map +1 -1
  23. package/dist/agent/approvals/discord-flow.d.ts.map +1 -1
  24. package/dist/agent/approvals/discord-flow.js +2 -1
  25. package/dist/agent/approvals/discord-flow.js.map +1 -1
  26. package/dist/agent/channels/discord-backscroll.d.ts.map +1 -1
  27. package/dist/agent/channels/discord-backscroll.js +2 -1
  28. package/dist/agent/channels/discord-backscroll.js.map +1 -1
  29. package/dist/agent/channels/discord-bot-name.d.ts.map +1 -1
  30. package/dist/agent/channels/discord-bot-name.js +2 -1
  31. package/dist/agent/channels/discord-bot-name.js.map +1 -1
  32. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -1
  33. package/dist/agent/channels/discord-channel-info.js +2 -1
  34. package/dist/agent/channels/discord-channel-info.js.map +1 -1
  35. package/dist/agent/channels/discord-credentials.d.ts +13 -0
  36. package/dist/agent/channels/discord-credentials.d.ts.map +1 -0
  37. package/dist/agent/channels/discord-credentials.js +38 -0
  38. package/dist/agent/channels/discord-credentials.js.map +1 -0
  39. package/dist/agent/channels/discord-directory.d.ts.map +1 -1
  40. package/dist/agent/channels/discord-directory.js +2 -1
  41. package/dist/agent/channels/discord-directory.js.map +1 -1
  42. package/dist/agent/channels/discord-events.d.ts +1 -1
  43. package/dist/agent/channels/discord-events.d.ts.map +1 -1
  44. package/dist/agent/channels/discord-events.js +2 -2
  45. package/dist/agent/channels/discord-events.js.map +1 -1
  46. package/dist/agent/channels/discord-front.d.ts +18 -1
  47. package/dist/agent/channels/discord-front.d.ts.map +1 -1
  48. package/dist/agent/channels/discord-front.js +33 -2
  49. package/dist/agent/channels/discord-front.js.map +1 -1
  50. package/dist/agent/channels/discord-gateway.d.ts +3 -3
  51. package/dist/agent/channels/discord-gateway.d.ts.map +1 -1
  52. package/dist/agent/channels/discord-gateway.js +10 -9
  53. package/dist/agent/channels/discord-gateway.js.map +1 -1
  54. package/dist/agent/channels/discord-invite.d.ts.map +1 -1
  55. package/dist/agent/channels/discord-invite.js +2 -1
  56. package/dist/agent/channels/discord-invite.js.map +1 -1
  57. package/dist/agent/channels/discord-message-ops.d.ts.map +1 -1
  58. package/dist/agent/channels/discord-message-ops.js +6 -5
  59. package/dist/agent/channels/discord-message-ops.js.map +1 -1
  60. package/dist/agent/channels/discord-post.d.ts.map +1 -1
  61. package/dist/agent/channels/discord-post.js +10 -9
  62. package/dist/agent/channels/discord-post.js.map +1 -1
  63. package/dist/agent/channels/discord-read.d.ts.map +1 -1
  64. package/dist/agent/channels/discord-read.js +2 -1
  65. package/dist/agent/channels/discord-read.js.map +1 -1
  66. package/dist/agent/channels/discord.js +11 -4
  67. package/dist/agent/channels/discord.js.map +1 -1
  68. package/dist/agent/commands/discord-command-sync.d.ts +8 -7
  69. package/dist/agent/commands/discord-command-sync.d.ts.map +1 -1
  70. package/dist/agent/commands/discord-command-sync.js +13 -10
  71. package/dist/agent/commands/discord-command-sync.js.map +1 -1
  72. package/dist/agent/docs-generated.d.ts.map +1 -1
  73. package/dist/agent/docs-generated.js +55 -34
  74. package/dist/agent/docs-generated.js.map +1 -1
  75. package/dist/agent/ops.d.ts.map +1 -1
  76. package/dist/agent/ops.js +9 -4
  77. package/dist/agent/ops.js.map +1 -1
  78. package/dist/agent/register.d.ts.map +1 -1
  79. package/dist/agent/register.js +7 -5
  80. package/dist/agent/register.js.map +1 -1
  81. package/package.json +6 -2
@@ -24,6 +24,7 @@
24
24
  // a SECRET — header only, never logged.
25
25
 
26
26
  import { isGuildAdministrator } from "./discord-card.js";
27
+ import { discordBotToken } from "../channels/discord-credentials.js";
27
28
 
28
29
  function log(msg: string): void {
29
30
  console.log(`[open-everywhere:discord-admin] ${msg}`);
@@ -71,7 +72,7 @@ const memberVerdictCache = new Map<string, { verdict: boolean; at: number }>();
71
72
  * (the user's role ids). NEVER throws; every failure is `false` (see the file header).
72
73
  */
73
74
  export async function isGuildAdminViaRest(guildId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<boolean> {
74
- const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
75
+ const token = deps.token ?? (await discordBotToken());
75
76
  if (!token || !guildId || !userId) return false;
76
77
  const cacheKey = `${guildId}:${userId}`;
77
78
  const now = deps.nowMs ?? Date.now();
@@ -130,7 +131,7 @@ export async function isGuildAdminViaRest(guildId: string, userId: string, deps:
130
131
  * error, a malformed body — all "not a member", never a guess. NEVER throws.
131
132
  */
132
133
  export async function isGuildMemberViaRest(guildId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<boolean> {
133
- const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
134
+ const token = deps.token ?? (await discordBotToken());
134
135
  if (!token || !guildId || !userId) return false;
135
136
  const cacheKey = `${guildId}:${userId}`;
136
137
  const now = deps.nowMs ?? Date.now();
@@ -189,7 +190,7 @@ const roleEvidenceCache = new Map<string, { roles: readonly string[]; at: number
189
190
  * NEVER throws.
190
191
  */
191
192
  export async function discordAuthorRoles(guildId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<readonly string[] | undefined> {
192
- const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
193
+ const token = deps.token ?? (await discordBotToken());
193
194
  if (!token || !guildId || !userId) return undefined; // cannot check — never a guess
194
195
  const cacheKey = `${guildId}:${userId}`;
195
196
  const now = deps.nowMs ?? Date.now();
@@ -306,7 +307,7 @@ function overwritesOf(channel: Record<string, unknown>): DiscordOverwrite[] {
306
307
  * only about a person with live cover.
307
308
  */
308
309
  export async function discordMemberCanViewChannel(guildId: string, channelId: string, userId: string, deps: DiscordAdminDeps = {}): Promise<boolean> {
309
- const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
310
+ const token = deps.token ?? (await discordBotToken());
310
311
  if (!token || !guildId || !channelId || !userId) return false;
311
312
  const doFetch = deps.fetchFn ?? fetch;
312
313
  const headers = { authorization: `Bot ${token}` };
@@ -79,6 +79,7 @@ import {
79
79
  type DiscordCardMessage,
80
80
  } from "./discord-card.js";
81
81
  import { guildIdFromScopeKey, isGuildAdminViaRest } from "./discord-admin.js";
82
+ import { discordBotToken } from "../channels/discord-credentials.js";
82
83
 
83
84
  function log(msg: string): void {
84
85
  console.log(`[open-everywhere:discord-approvals] ${msg}`);
@@ -160,7 +161,7 @@ async function postDiscordCardMessage(
160
161
  what: string,
161
162
  render: () => DiscordCardMessage,
162
163
  ): Promise<string | undefined> {
163
- const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
164
+ const token = deps.token ?? (await discordBotToken());
164
165
  if (!token) {
165
166
  log(`DISCORD_BOT_TOKEN is not set — cannot post the ${what} to channel ${row.channelId}`);
166
167
  return undefined;
@@ -79,6 +79,7 @@ import {
79
79
  SUPERVISOR_BACKSCROLL_REACHBACK_HOURS,
80
80
  } from "@open-everywhere/core/agent/adapter-api.js";
81
81
  import { discordAuthorRoles } from "../approvals/discord-admin.js";
82
+ import { discordBotToken } from "./discord-credentials.js";
82
83
 
83
84
  /** The triggering wake, narrowed to what the backscroll needs. */
84
85
  export interface DiscordBackscrollInput {
@@ -109,7 +110,7 @@ export interface DiscordBackscrollDeps {
109
110
  // any throw → undefined. Kept local rather than imported from discord-gateway.ts so the module
110
111
  // graph stays a DAG (discord-gateway → here; importing back up would cycle).
111
112
  const liveRequest: DiscordRequester = async (channelId, query) => {
112
- const token = process.env.DISCORD_BOT_TOKEN;
113
+ const token = await discordBotToken();
113
114
  if (!token || !channelId) return undefined;
114
115
  try {
115
116
  const qs = new URLSearchParams(query).toString();
@@ -34,6 +34,7 @@
34
34
  // deployment missing the token does not re-probe Discord on every turn.
35
35
 
36
36
  import type { BotDisplayNameArm } from "@open-everywhere/core/agent/adapter-api.js";
37
+ import { discordBotToken } from "./discord-credentials.js";
37
38
 
38
39
  const DISCORD_API_BASE = "https://discord.com/api/v10";
39
40
 
@@ -43,7 +44,7 @@ const DISCORD_API_BASE = "https://discord.com/api/v10";
43
44
  export type DiscordSelfRequester = () => Promise<unknown>;
44
45
 
45
46
  const liveDiscordSelfRequest: DiscordSelfRequester = async () => {
46
- const token = process.env.DISCORD_BOT_TOKEN;
47
+ const token = await discordBotToken();
47
48
  if (!token) throw new Error("bot-display-name: DISCORD_BOT_TOKEN is not set.");
48
49
  const res = await fetch(`${DISCORD_API_BASE}/users/@me`, {
49
50
  headers: { authorization: `Bot ${token}` },
@@ -14,6 +14,8 @@
14
14
  // SECRET — header only, never logged. Definitive verdicts are cached in-process (see the
15
15
  // cache note); a transient failure is asked again.
16
16
 
17
+ import { discordBotToken } from "./discord-credentials.js";
18
+
17
19
  /** The channel facts both consumers read. `parentId` is set ONLY for threads. */
18
20
  export interface DiscordChannelInfo {
19
21
  readonly name?: string;
@@ -62,7 +64,7 @@ export function fetchDiscordInfo(
62
64
  // 404, no token — is kept.
63
65
  let transient = false;
64
66
  p = (async () => {
65
- const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
67
+ const token = deps.token ?? (await discordBotToken());
66
68
  if (!token) return undefined;
67
69
  try {
68
70
  const doFetch = deps.fetchFn ?? fetch;
@@ -0,0 +1,41 @@
1
+ // WHERE THE DISCORD BUNDLE'S TWO SECRETS COME FROM (OPE-2650) — the bot token every Discord REST call
2
+ // authenticates with, and the shared secret the gateway relay signs its deliveries with.
3
+ //
4
+ // Both are OPERATOR SECRETS: `oe secret set DISCORD_BOT_TOKEN`. Core's `operatorSecretOrUnset` reads
5
+ // them: the db credential store first, the env var of the same name as its warned on-ramp, each store
6
+ // lookup remembered for thirty seconds. So a deployment that still holds the env vars works unchanged,
7
+ // and one that stored them works with no env var at all.
8
+ //
9
+ // 🔴 A FAULT READS AS "NO TOKEN", AND IT IS LOGGED. Most Discord call sites promise "NEVER throws"
10
+ // (an admin check answers false, a post answers null). A stored row this process cannot decrypt (no
11
+ // armed `OE_CREDENTIAL_ENCRYPTION_KEY`) is therefore reported once per process and answered as
12
+ // `undefined`. Every caller already fails closed on `undefined`: no token means no call.
13
+
14
+ import { operatorSecretOrUnset } from "@open-everywhere/core/agent/adapter-api.js";
15
+
16
+ /** The operator secret holding the bot token. */
17
+ export const DISCORD_BOT_TOKEN_NAME = "DISCORD_BOT_TOKEN";
18
+ /** The operator secret holding the gateway relay's shared secret (the app side; the relay process
19
+ * keeps its own env). */
20
+ const OE_RELAY_SECRET_NAME = "OE_RELAY_SECRET";
21
+
22
+ /** The bot token, or `undefined` when this deployment holds none. */
23
+ export async function discordBotToken(): Promise<string | undefined> {
24
+ return await operatorSecretOrUnset(DISCORD_BOT_TOKEN_NAME);
25
+ }
26
+
27
+ /**
28
+ * eve's outbound bot-token provider (`discordChannel({ credentials: { botToken } })`). Throws eve's OWN
29
+ * message when none resolves, byte for byte. Without it eve reads the DISCORD_BOT_TOKEN env var
30
+ * itself and never sees a stored value.
31
+ */
32
+ export async function discordBotTokenForEve(): Promise<string> {
33
+ const token = await discordBotToken();
34
+ if (!token) throw new Error("DISCORD_BOT_TOKEN is required.");
35
+ return token;
36
+ }
37
+
38
+ /** The gateway relay's shared secret, or `undefined` when this deployment holds none. */
39
+ export async function discordRelaySecret(): Promise<string | undefined> {
40
+ return await operatorSecretOrUnset(OE_RELAY_SECRET_NAME);
41
+ }
@@ -49,6 +49,7 @@
49
49
  // can never answer.
50
50
 
51
51
  import { admittedTenantsFromConfig, type DirectoryCandidate } from "@open-everywhere/core/agent/adapter-api.js";
52
+ import { discordBotToken } from "./discord-credentials.js";
52
53
 
53
54
  const API = "https://discord.com/api/v10";
54
55
 
@@ -88,7 +89,7 @@ interface DiscordGuildMember {
88
89
  * The guild set comes from this deployment's own config instead — see the header.
89
90
  */
90
91
  export async function fetchGuildDirectory(query: string): Promise<readonly DirectoryCandidate[]> {
91
- const token = process.env.DISCORD_BOT_TOKEN;
92
+ const token = await discordBotToken();
92
93
  if (!token) return [];
93
94
  // The same normalization core's matcher applies (`@tommy` and `tommy` are one query to a person),
94
95
  // done here too because Discord's search would take the `@` literally and match nothing.
@@ -28,7 +28,7 @@
28
28
  // verification path — one key, one verifier.
29
29
 
30
30
  import { POST, type Channel, type RouteHandlerArgs } from "eve/channels";
31
- import { verifyDiscordInteraction } from "./discord-front.js";
31
+ import { discordPublicKey, verifyDiscordInteraction } from "./discord-front.js";
32
32
  import { appendAudit, deploymentOrg } from "@open-everywhere/core/agent/adapter-api.js";
33
33
  import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
34
34
 
@@ -115,7 +115,7 @@ export function classifyDiscordEvent(rawBody: string): DiscordEventDecision {
115
115
 
116
116
  /** Injectable seams so the route is testable without live crypto env or the real store. */
117
117
  export interface DiscordEventsOptions {
118
- /** The app public key; defaults to DISCORD_PUBLIC_KEY (the same env the interactions path reads). */
118
+ /** The app public key; defaults to the `discord.publicKey` setting (the same key the interactions path reads). */
119
119
  readonly publicKey?: string;
120
120
  readonly now?: () => number;
121
121
  /** Audit writer (defaults to the real appendAudit under the deployment org). */
@@ -136,7 +136,7 @@ const ACK = () => new Response(null, { status: 204 });
136
136
  */
137
137
  export function buildDiscordEventsHandler(opts: DiscordEventsOptions = {}): (req: Request, args: RouteHandlerArgs) => Promise<Response> {
138
138
  const now = opts.now ?? (() => Date.now());
139
- const publicKey = () => opts.publicKey ?? process.env.DISCORD_PUBLIC_KEY;
139
+ const publicKey = () => opts.publicKey ?? discordPublicKey();
140
140
  const audit =
141
141
  opts.audit ??
142
142
  (async (row) => {
@@ -33,7 +33,7 @@ import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } f
33
33
  import { verifyDiscordSignature } from "eve/channels/discord";
34
34
  import { OE_DISCORD_APPROVAL_PREFIX } from "../approvals/discord-card.js";
35
35
  import { handleDiscordApprovalInteraction, type DiscordInteractionResponse } from "../approvals/discord-flow.js";
36
- import { LINK_CONSENT_CLICK_PREFIX, OE_GRANT_ACTION_PREFIX, friendlyDiscordBody } from "@open-everywhere/core/agent/adapter-api.js";
36
+ import { LINK_CONSENT_CLICK_PREFIX, OE_GRANT_ACTION_PREFIX, friendlyDiscordBody, getSettings } from "@open-everywhere/core/agent/adapter-api.js";
37
37
  import { handleDiscordLinkConsentInteraction } from "../identity/discord-link-consent.js";
38
38
  import { handleDiscordGrantCardInteraction } from "../access/discord-grant-click.js";
39
39
  import { oeCommandName, type AttachSessionForCancel } from "@open-everywhere/core/agent/adapter-api.js";
@@ -175,9 +175,43 @@ export function classifyDiscordInteraction(rawBody: string): DiscordFrontDecisio
175
175
  return { kind: "forward", reason: "component click — eve's HITL owns it" };
176
176
  }
177
177
 
178
+ // WHICH DISCORD APP THIS DEPLOYMENT IS (OPE-2650). The application id and its interaction public key
179
+ // are the `discord.applicationId` and `discord.publicKey` settings, no longer env. Both are public. Each
180
+ // is read per call, so a settings override reaches the next delivery. `undefined` means unset.
181
+
182
+ /** The `discord.publicKey` setting, or `undefined` when it is unset. */
183
+ export function discordPublicKey(): string | undefined {
184
+ return getSettings().discord.publicKey || undefined;
185
+ }
186
+
187
+ /** The `discord.applicationId` setting, or `undefined` when it is unset. */
188
+ export function discordApplicationId(): string | undefined {
189
+ return getSettings().discord.applicationId || undefined;
190
+ }
191
+
192
+ /**
193
+ * The two settings as eve's lazy credential fields, for `discordChannel({ credentials })`.
194
+ *
195
+ * 🔴 A FIELD LEFT UNDEFINED FALLS BACK TO ENV INSIDE EVE (`DISCORD_APPLICATION_ID`,
196
+ * `DISCORD_PUBLIC_KEY`), so the channel must always pass both. Each resolver throws when its setting
197
+ * is unset, which is eve's own "is required" refusal. An unset `discord.publicKey` refuses every
198
+ * delivery. eve resolves `discord.applicationId` only when an interaction carries no application id
199
+ * of its own, so an unset id skips the command sync and does not refuse a delivery.
200
+ */
201
+ export function discordAppCredentials(): { readonly applicationId: () => string; readonly publicKey: () => string } {
202
+ const required = (value: string | undefined, path: string): string => {
203
+ if (value === undefined) throw new Error(`${path} is required: set it in agent/settings/settings.ts.`);
204
+ return value;
205
+ };
206
+ return {
207
+ applicationId: () => required(discordApplicationId(), "discord.applicationId"),
208
+ publicKey: () => required(discordPublicKey(), "discord.publicKey"),
209
+ };
210
+ }
211
+
178
212
  /** Injectable seams so the front route is testable without live crypto env / the real store. */
179
213
  export interface DiscordFrontOptions {
180
- /** The app public key; defaults to DISCORD_PUBLIC_KEY (same env eve's channel reads). */
214
+ /** The app public key; defaults to the `discord.publicKey` setting (the key eve's channel verifies with). */
181
215
  readonly publicKey?: string;
182
216
  readonly now?: () => number;
183
217
  /** Approval-click handler (defaults to the real approvals/discord-flow.ts). Injectable so
@@ -222,7 +256,7 @@ async function interactionJson(body: DiscordInteractionResponse): Promise<Respon
222
256
  */
223
257
  export function buildDiscordFrontHandler(innerHandler: RouteHandler, opts: DiscordFrontOptions = {}): RouteHandler {
224
258
  const now = opts.now ?? (() => Date.now());
225
- const publicKey = () => opts.publicKey ?? process.env.DISCORD_PUBLIC_KEY;
259
+ const publicKey = () => opts.publicKey ?? discordPublicKey();
226
260
  const handleClick = opts.handleApprovalInteraction ?? handleDiscordApprovalInteraction;
227
261
  const handleLinkConsent = opts.handleLinkConsentInteraction ?? handleDiscordLinkConsentInteraction;
228
262
  const handleGrantCard = opts.handleGrantCardInteraction ?? handleDiscordGrantCardInteraction;
@@ -182,6 +182,7 @@ import {
182
182
  } from "@open-everywhere/core/agent/adapter-api.js";
183
183
  import { discordMemberCanViewChannel } from "../approvals/discord-admin.js";
184
184
  import { fetchDiscordBackscrollLines, gradeDiscordContextLines, DISCORD_RAW_FETCH_REACHBACK_HOURS } from "./discord-backscroll.js";
185
+ import { discordBotToken, discordRelaySecret } from "./discord-credentials.js";
185
186
 
186
187
  function log(msg: string): void {
187
188
  console.log(`[open-everywhere:discord-gateway] ${msg}`);
@@ -543,14 +544,14 @@ export interface RelayRejection {
543
544
 
544
545
  /**
545
546
  * Gate one relay delivery. Returns null when it may proceed, else the {@link RelayRejection}.
546
- * `secret` defaults to the live env read but is injectable so the gate is unit-tested without
547
- * touching process.env. Fail-closed: unset/empty secret → 401; missing/malformed/mismatched
547
+ * `secret` is the caller's resolved `OE_RELAY_SECRET` operator secret ({@link discordRelaySecret}),
548
+ * passed in so the gate stays pure and unit-tested with no store. Fail-closed: unset/empty secret → 401; missing/malformed/mismatched
548
549
  * signature → 401 (verifyWebhookSignature returns false for a null or non-`sha256=` header).
549
550
  */
550
551
  export function relayGate(
551
552
  signatureHeader: string | null,
552
553
  rawBody: string,
553
- secret: string | undefined = process.env.OE_RELAY_SECRET,
554
+ secret: string | undefined,
554
555
  ): RelayRejection | null {
555
556
  if (!secret) {
556
557
  return { status: 401, reason: "OE_RELAY_SECRET is not set — rejecting unverified relay delivery" };
@@ -868,7 +869,7 @@ const AFFORDANCE_RETRY: RetryPolicy = { maxAttempts: 2, capMs: 2_000, totalBudge
868
869
  * lives in one place (the bot token is a SECRET — never logged).
869
870
  */
870
871
  export async function discordCreateMessage(channelId: string, content: string, deps: DiscordFetchDeps = {}): Promise<string | null> {
871
- const token = process.env.DISCORD_BOT_TOKEN;
872
+ const token = await discordBotToken();
872
873
  if (!token) {
873
874
  log(`DISCORD_BOT_TOKEN is not set — cannot post to channel ${channelId}`);
874
875
  return null;
@@ -923,7 +924,7 @@ export async function discordCreateMessage(channelId: string, content: string, d
923
924
  * must stay silent rather than fall back to the channel.
924
925
  */
925
926
  export async function openDiscordDm(userId: string, deps: DiscordFetchDeps = {}): Promise<string | null> {
926
- const token = process.env.DISCORD_BOT_TOKEN;
927
+ const token = await discordBotToken();
927
928
  if (!token) return null;
928
929
  try {
929
930
  const res = await discordFetch(
@@ -1101,7 +1102,7 @@ export function typingGateFeed(
1101
1102
  }
1102
1103
 
1103
1104
  export async function triggerDiscordTyping(channelId: string, deps: DiscordFetchDeps = {}): Promise<void> {
1104
- const token = process.env.DISCORD_BOT_TOKEN;
1105
+ const token = await discordBotToken();
1105
1106
  if (!token || !channelId) return;
1106
1107
  try {
1107
1108
  const res = await discordFetch(
@@ -1144,7 +1145,7 @@ export async function setDiscordReaction(
1144
1145
  action: "PUT" | "DELETE",
1145
1146
  deps: DiscordFetchDeps = {},
1146
1147
  ): Promise<void> {
1147
- const token = process.env.DISCORD_BOT_TOKEN;
1148
+ const token = await discordBotToken();
1148
1149
  if (!token) return; // no token → silently skip the affordance (degrades to no reaction)
1149
1150
  if (!channelId || !messageId) return;
1150
1151
  const encoded = encodeURIComponent(emoji);
@@ -1253,7 +1254,7 @@ export async function createDiscordThread(
1253
1254
  name: string,
1254
1255
  deps: DiscordFetchDeps = {},
1255
1256
  ): Promise<string | null> {
1256
- const token = process.env.DISCORD_BOT_TOKEN;
1257
+ const token = await discordBotToken();
1257
1258
  if (!token) {
1258
1259
  log(`DISCORD_BOT_TOKEN is not set — cannot create thread on channel ${channelId}`);
1259
1260
  return null;
@@ -3030,7 +3031,7 @@ export default withEndsTurnFinalChannel(defineChannel<DiscordGatewayState, Disco
3030
3031
 
3031
3032
  // 1) Verify the relay HMAC over the RAW body, fail-closed. Reject BEFORE parsing so we
3032
3033
  // never act on (or even JSON.parse) an unauthenticated payload.
3033
- const rejection = relayGate(req.headers.get("x-oe-relay-signature"), rawBody);
3034
+ const rejection = relayGate(req.headers.get("x-oe-relay-signature"), rawBody, await discordRelaySecret());
3034
3035
  if (rejection) {
3035
3036
  log(`rejected (${rejection.status}): ${rejection.reason}`);
3036
3037
  return new Response(rejection.reason, { status: rejection.status });
@@ -8,6 +8,7 @@
8
8
  // looked up once per ask, and an expired code must read as expired.
9
9
 
10
10
  import type { MeetingInviteLookup, MeetingInviteResolver } from "@open-everywhere/core/agent/adapter-api.js";
11
+ import { discordBotToken } from "./discord-credentials.js";
11
12
 
12
13
  /** Discord's channel types for a voice room: GUILD_VOICE (2) and GUILD_STAGE_VOICE (13). */
13
14
  const GUILD_VOICE = 2;
@@ -22,7 +23,7 @@ export interface DiscordInviteDeps {
22
23
 
23
24
  /** Look one invite code up. NEVER throws: every failure is a `found: false` answer core turns into a plain reason. */
24
25
  export async function lookUpDiscordInvite(code: string, deps: DiscordInviteDeps = {}): Promise<MeetingInviteLookup> {
25
- const token = deps.token ?? process.env.DISCORD_BOT_TOKEN;
26
+ const token = deps.token ?? (await discordBotToken());
26
27
  if (!token) return { found: false, why: "unreachable" };
27
28
  try {
28
29
  const doFetch = deps.fetchFn ?? fetch;
@@ -14,12 +14,13 @@
14
14
 
15
15
  import type { SurfaceMessageOps, OutboundFile } from "@open-everywhere/core/agent/adapter-api.js";
16
16
  import { discordPostMessage } from "./discord-post.js";
17
+ import { discordBotToken } from "./discord-credentials.js";
17
18
  import { discordFetch } from "./discord-rest.js";
18
19
 
19
20
  const API = "https://discord.com/api/v10";
20
21
 
21
- function botToken(): string {
22
- const token = process.env.DISCORD_BOT_TOKEN;
22
+ async function botToken(): Promise<string> {
23
+ const token = await discordBotToken();
23
24
  if (!token) throw new Error("DISCORD_BOT_TOKEN is not set — the bot token is required to post to Discord.");
24
25
  return token;
25
26
  }
@@ -32,7 +33,7 @@ export const discordMessageOps: SurfaceMessageOps = {
32
33
  async editMessage(channelId, messageId, text) {
33
34
  const res = await discordFetch("PATCH /channels/{id}/messages/{id}", `${API}/channels/${channelId}/messages/${messageId}`, {
34
35
  method: "PATCH",
35
- headers: { authorization: `Bot ${botToken()}`, "content-type": "application/json" },
36
+ headers: { authorization: `Bot ${await botToken()}`, "content-type": "application/json" },
36
37
  // 🔴 `components: []` STRIPS THE BUTTONS, AND IT IS LOAD-BEARING (OPE-1260). Discord leaves a
37
38
  // message's components untouched when a PATCH omits them, so without this line a card rewritten
38
39
  // to a closed state kept four live buttons over copy saying the request was already over.
@@ -91,7 +92,7 @@ export const discordMessageOps: SurfaceMessageOps = {
91
92
  const res = await discordFetch(
92
93
  on ? "PUT /channels/{id}/messages/{id}/reactions/{emoji}/@me" : "DELETE /channels/{id}/messages/{id}/reactions/{emoji}/@me",
93
94
  `${API}/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`,
94
- { method: on ? "PUT" : "DELETE", headers: { authorization: `Bot ${botToken()}` } },
95
+ { method: on ? "PUT" : "DELETE", headers: { authorization: `Bot ${await botToken()}` } },
95
96
  );
96
97
  if (!res.ok && !(!on && res.status === 404)) {
97
98
  console.warn(`[open-everywhere:discord] reaction ${on ? "add" : "remove"} failed (${res.status}) for message ${messageId}`);
@@ -112,7 +113,7 @@ export const discordMessageOps: SurfaceMessageOps = {
112
113
  // No explicit content-type header — fetch derives the multipart boundary from the FormData.
113
114
  const res = await discordFetch("POST /channels/{id}/messages", `${API}/channels/${channelId}/messages`, {
114
115
  method: "POST",
115
- headers: { authorization: `Bot ${botToken()}` },
116
+ headers: { authorization: `Bot ${await botToken()}` },
116
117
  body: form,
117
118
  });
118
119
  if (!res.ok) throw new Error(`Discord create-message (files) failed (HTTP ${res.status}) for channel ${channelId}`);
@@ -12,6 +12,7 @@
12
12
  // post-back, a tool must report whether the post actually landed).
13
13
 
14
14
  import { markNotApplied } from "@open-everywhere/core/agent/adapter-api.js";
15
+ import { discordBotToken } from "./discord-credentials.js";
15
16
  import { discordFetch } from "./discord-rest.js";
16
17
 
17
18
  const API = "https://discord.com/api/v10";
@@ -24,8 +25,8 @@ function botHeaders(token: string): Record<string, string> {
24
25
  return { authorization: `Bot ${token}`, "content-type": "application/json" };
25
26
  }
26
27
 
27
- function botToken(): string {
28
- const token = process.env.DISCORD_BOT_TOKEN;
28
+ async function botToken(): Promise<string> {
29
+ const token = await discordBotToken();
29
30
  if (!token) {
30
31
  throw new Error("DISCORD_BOT_TOKEN is not set — the reply tool needs the bot token to post to Discord.");
31
32
  }
@@ -174,7 +175,7 @@ export async function discordPostMessage(channelId: string, content: string, opt
174
175
  export async function discordOpenDmChannel(userId: string): Promise<string> {
175
176
  const res = await discordFetch("POST /users/@me/channels", `${API}/users/@me/channels`, {
176
177
  method: "POST",
177
- headers: botHeaders(botToken()),
178
+ headers: botHeaders(await botToken()),
178
179
  body: JSON.stringify({ recipient_id: userId }),
179
180
  });
180
181
  if (!res.ok) {
@@ -229,7 +230,7 @@ export async function discordPostMessageWithButtons(
229
230
  }
230
231
  const res = await discordFetch("POST /channels/{id}/messages", `${API}/channels/${channelId}/messages`, {
231
232
  method: "POST",
232
- headers: botHeaders(botToken()),
233
+ headers: botHeaders(await botToken()),
233
234
  body: JSON.stringify({
234
235
  content,
235
236
  components: [
@@ -262,7 +263,7 @@ export async function discordPostMessageWithButtons(
262
263
  async function discordPostOne(channelId: string, content: string, replyToMessageId?: string): Promise<string> {
263
264
  const res = await discordFetch("POST /channels/{id}/messages", `${API}/channels/${channelId}/messages`, {
264
265
  method: "POST",
265
- headers: botHeaders(botToken()),
266
+ headers: botHeaders(await botToken()),
266
267
  body: JSON.stringify({
267
268
  content,
268
269
  ...(replyToMessageId ? { message_reference: { message_id: replyToMessageId, fail_if_not_exists: false } } : {}),
@@ -310,7 +311,7 @@ export async function discordEditMessage(channelId: string, messageId: string, c
310
311
  }
311
312
  const res = await discordFetch("PATCH /channels/{id}/messages/{id}", `${API}/channels/${channelId}/messages/${messageId}`, {
312
313
  method: "PATCH",
313
- headers: botHeaders(botToken()),
314
+ headers: botHeaders(await botToken()),
314
315
  body: JSON.stringify({ content }),
315
316
  });
316
317
  if (!res.ok) {
@@ -390,7 +391,7 @@ export async function discordAddReaction(channelId: string, messageId: string, e
390
391
  const res = await discordFetch(
391
392
  "PUT /channels/{id}/messages/{id}/reactions/{emoji}/@me",
392
393
  `${API}/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(unicode)}/@me`,
393
- { method: "PUT", headers: botHeaders(botToken()) },
394
+ { method: "PUT", headers: botHeaders(await botToken()) },
394
395
  );
395
396
  if (!res.ok) {
396
397
  throw Object.assign(new Error(`react couldn't post to Discord — ${res.status} on channel ${channelId}`), { status: res.status });
@@ -412,7 +413,7 @@ export async function discordAddReaction(channelId: string, messageId: string, e
412
413
  export async function discordExistingThreadForMessage(channelId: string, messageId: string): Promise<string | null> {
413
414
  try {
414
415
  const msgRes = await discordFetch("GET /channels/{id}/messages/{id}", `${API}/channels/${channelId}/messages/${messageId}`, {
415
- headers: botHeaders(botToken()),
416
+ headers: botHeaders(await botToken()),
416
417
  });
417
418
  if (!msgRes.ok) return null;
418
419
  const msg = (await msgRes.json()) as { thread?: { id?: unknown } };
@@ -438,7 +439,7 @@ export async function discordThreadForMessage(channelId: string, messageId: stri
438
439
  `${API}/channels/${channelId}/messages/${messageId}/threads`,
439
440
  {
440
441
  method: "POST",
441
- headers: botHeaders(botToken()),
442
+ headers: botHeaders(await botToken()),
442
443
  body: JSON.stringify({ name: name.slice(0, 100) || "thread", auto_archive_duration: 1440 }),
443
444
  },
444
445
  );
@@ -20,6 +20,7 @@ import {
20
20
  } from "@open-everywhere/core/agent/adapter-api.js";
21
21
  import { linesFromDiscordMessages, type DiscordRoleProbe } from "./discord-backscroll.js";
22
22
  import { discordAuthorRoles } from "../approvals/discord-admin.js";
23
+ import { discordBotToken } from "./discord-credentials.js";
23
24
 
24
25
  const DISCORD_CHANNEL_DEFAULT_LIMIT = 50;
25
26
  const DISCORD_THREAD_DEFAULT_LIMIT = 100;
@@ -29,7 +30,7 @@ const DISCORD_MAX_PAGE = 100;
29
30
  export type DiscordReadRequester = (channelId: string, limit: number) => Promise<unknown>;
30
31
 
31
32
  const liveDiscordRead: DiscordReadRequester = async (channelId, limit) => {
32
- const token = process.env.DISCORD_BOT_TOKEN;
33
+ const token = await discordBotToken();
33
34
  if (!token) throw new Error("read: DISCORD_BOT_TOKEN is not set — the Discord read tools need the bot token.");
34
35
  const qs = new URLSearchParams({ limit: String(Math.min(limit, DISCORD_MAX_PAGE)) }).toString();
35
36
  const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?${qs}`, {
@@ -17,9 +17,10 @@
17
17
  // derives a thinner, guild-scoped auth). Kept MINIMAL for launch — identity binding +
18
18
  // dispatch only; no status-UX / onboarding extras (cf. slack.ts).
19
19
  //
20
- // Credentials: omitted here, so eve falls back to DISCORD_PUBLIC_KEY /
21
- // DISCORD_APPLICATION_ID / DISCORD_BOT_TOKEN (see .env.example). Swap the credential
22
- // broker later without touching identity. Capturing the channel type / guild presence is a
20
+ // Credentials: the application id and the public key come from the `discord.applicationId` and
21
+ // `discord.publicKey` settings, and the bot token from `discordBotTokenForEve` (the DISCORD_BOT_TOKEN
22
+ // operator secret, ./discord-credentials.ts) — all OPE-2650, all passed below so eve never falls back
23
+ // to its own env names for them. Capturing the channel type / guild presence is a
23
24
  // RAW-fact job; the security-critical interpretation (fail-closed to private) lives in
24
25
  // the binding (discordVisibility).
25
26
  //
@@ -51,8 +52,9 @@ import {
51
52
  type ChatAuditPartition,
52
53
  } from "@open-everywhere/core/agent/adapter-api.js";
53
54
  import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
54
- import { frontDiscordChannel, INTERNAL_DISCORD_ROUTE } from "./discord-front.js";
55
+ import { discordAppCredentials, frontDiscordChannel, INTERNAL_DISCORD_ROUTE } from "./discord-front.js";
55
56
  import { withDiscordEventsRoute } from "./discord-events.js";
57
+ import { discordBotTokenForEve } from "./discord-credentials.js";
56
58
 
57
59
  // The audit partition for a terminal event, rebuilt from the LIVE session state the same
58
60
  // way slack.ts rebuilds its own (the completion handler has no auth context): guild →
@@ -109,6 +111,11 @@ function authFromInteraction(interaction: DiscordCommandInteraction): OpenEveryw
109
111
  const discordInteractionChannel = discordChannel({
110
112
  // Internal mount — the public URL is the approval-click front (frontDiscordChannel below).
111
113
  route: INTERNAL_DISCORD_ROUTE,
114
+ // 🔴 EVE MUST BE HANDED EVERY FIELD (OPE-2650). With a field missing, eve's `mergeCredentials` falls
115
+ // back to its own env read (DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY, DISCORD_BOT_TOKEN) and never
116
+ // sees a setting or a value `oe secret set` stored. Functions, not values: a value read at module
117
+ // load would pin whatever was true at boot.
118
+ credentials: { ...discordAppCredentials(), botToken: discordBotTokenForEve },
112
119
  // 🔴 QUEUE, NEVER STEER (OPE-412) — eve's default `steer` cancels the turn in flight when a
113
120
  // message arrives. Same reasoning as the Slack channel; see slack.ts for the full note.
114
121
  turnPolicy: "queue",
@@ -35,6 +35,8 @@
35
35
  import type { RetryPolicy, SurfaceCommandOutcome, SurfaceCommandScope } from "@open-everywhere/core/agent/adapter-api.js";
36
36
  import { discordFetch, type DiscordFetchLike } from "../channels/discord-rest.js";
37
37
  import { buildOeDiscordCommandDefinition } from "./discord-commands.js";
38
+ import { discordApplicationId } from "../channels/discord-front.js";
39
+ import { discordBotToken } from "../channels/discord-credentials.js";
38
40
 
39
41
  /**
40
42
  * 🔴 NO 429 BACKOFF AT ALL — the reconcile sits on the FIRST REQUEST's latency path.
@@ -251,22 +253,23 @@ async function reconcileOneGuild(
251
253
  /**
252
254
  * THE RECONCILER the discord bundle registers with core's command plane.
253
255
  *
254
- * Runs at boot, so it is gated on BOTH deployment credentials being present: `DISCORD_APPLICATION_ID`
255
- * says which app this is and `DISCORD_BOT_TOKEN` authenticates the call. Either missing ⇒ `skipped`,
256
- * which is the correct reading of a deployment that carries the bundle but no Discord app — and it is
257
- * also what keeps every offline test tier network-free, since no test publishes an application id.
256
+ * Runs at boot, so it is gated on BOTH halves being present: the `discord.applicationId` setting says
257
+ * which app this is and `DISCORD_BOT_TOKEN` authenticates the call. Either missing ⇒ `skipped`, which
258
+ * is the correct reading of a deployment that carries the bundle but no Discord app — and it is also
259
+ * what keeps every offline test tier network-free, since no test sets the bot token.
258
260
  *
259
- * 🔴 THESE TWO STAY ENV. They are the credential pair this deployment speaks as, which is exactly the
260
- * `secret`/`identity` half of the config-as-code line. The GUILD ids do not: they are tracked literals
261
- * in `agent/commands/discord-commands.ts` and arrive here as the declared scope.
261
+ * The application id is a setting since OPE-2650: which app this deployment is, is a tracked fact.
262
+ * The bot token is an operator secret (OPE-2650): `oe secret set DISCORD_BOT_TOKEN`, with the env var
263
+ * as its on-ramp. The GUILD ids are tracked literals in `agent/commands/discord-commands.ts` and
264
+ * arrive here as the declared scope.
262
265
  */
263
266
  export async function reconcileDiscordCommands(scope: SurfaceCommandScope, deps: DiscordCommandSyncDeps = {}): Promise<SurfaceCommandOutcome> {
264
- const applicationId = deps.applicationId ?? process.env.DISCORD_APPLICATION_ID;
265
- const botToken = deps.botToken ?? process.env.DISCORD_BOT_TOKEN;
267
+ const applicationId = deps.applicationId ?? discordApplicationId();
268
+ const botToken = deps.botToken ?? (await discordBotToken());
266
269
  if (!applicationId || !botToken) {
267
270
  return {
268
271
  status: "skipped",
269
- detail: "DISCORD_APPLICATION_ID and DISCORD_BOT_TOKEN must both be set for this deployment to register its own commands.",
272
+ detail: "the discord.applicationId setting and DISCORD_BOT_TOKEN must both be set for this deployment to register its own commands.",
270
273
  };
271
274
  }
272
275