@open-everywhere/provider-discord 0.1.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 (166) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/discord-grant-click.ts +237 -0
  5. package/agent/approvals/discord-admin.ts +227 -0
  6. package/agent/approvals/discord-card.ts +248 -0
  7. package/agent/approvals/discord-flow.ts +469 -0
  8. package/agent/approvals/discord-scope-label.ts +27 -0
  9. package/agent/channels/discord-approval-card.ts +373 -0
  10. package/agent/channels/discord-backscroll.ts +593 -0
  11. package/agent/channels/discord-bot-name.ts +113 -0
  12. package/agent/channels/discord-channel-info.ts +83 -0
  13. package/agent/channels/discord-directory.ts +179 -0
  14. package/agent/channels/discord-dispatch-target.ts +103 -0
  15. package/agent/channels/discord-events.ts +175 -0
  16. package/agent/channels/discord-front.ts +355 -0
  17. package/agent/channels/discord-gateway.ts +3256 -0
  18. package/agent/channels/discord-identity.ts +269 -0
  19. package/agent/channels/discord-interaction-defer.ts +123 -0
  20. package/agent/channels/discord-message-ops.ts +127 -0
  21. package/agent/channels/discord-origin-codec.ts +105 -0
  22. package/agent/channels/discord-post-arm.ts +230 -0
  23. package/agent/channels/discord-post.ts +463 -0
  24. package/agent/channels/discord-read.ts +140 -0
  25. package/agent/channels/discord-rest.ts +85 -0
  26. package/agent/channels/discord.ts +244 -0
  27. package/agent/commands/discord-command-sync.ts +285 -0
  28. package/agent/commands/discord-commands.ts +291 -0
  29. package/agent/contributions.ts +31 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/identity/discord-link-consent.ts +124 -0
  32. package/agent/ops.ts +14 -0
  33. package/agent/provider-events.ts +86 -0
  34. package/agent/register.ts +147 -0
  35. package/agent/surface-parity.ts +95 -0
  36. package/dist/agent/access/discord-grant-click.d.ts +25 -0
  37. package/dist/agent/access/discord-grant-click.d.ts.map +1 -0
  38. package/dist/agent/access/discord-grant-click.js +210 -0
  39. package/dist/agent/access/discord-grant-click.js.map +1 -0
  40. package/dist/agent/approvals/discord-admin.d.ts +63 -0
  41. package/dist/agent/approvals/discord-admin.d.ts.map +1 -0
  42. package/dist/agent/approvals/discord-admin.js +219 -0
  43. package/dist/agent/approvals/discord-admin.js.map +1 -0
  44. package/dist/agent/approvals/discord-card.d.ts +102 -0
  45. package/dist/agent/approvals/discord-card.d.ts.map +1 -0
  46. package/dist/agent/approvals/discord-card.js +217 -0
  47. package/dist/agent/approvals/discord-card.js.map +1 -0
  48. package/dist/agent/approvals/discord-flow.d.ts +109 -0
  49. package/dist/agent/approvals/discord-flow.d.ts.map +1 -0
  50. package/dist/agent/approvals/discord-flow.js +375 -0
  51. package/dist/agent/approvals/discord-flow.js.map +1 -0
  52. package/dist/agent/approvals/discord-scope-label.d.ts +3 -0
  53. package/dist/agent/approvals/discord-scope-label.d.ts.map +1 -0
  54. package/dist/agent/approvals/discord-scope-label.js +27 -0
  55. package/dist/agent/approvals/discord-scope-label.js.map +1 -0
  56. package/dist/agent/channels/discord-approval-card.d.ts +81 -0
  57. package/dist/agent/channels/discord-approval-card.d.ts.map +1 -0
  58. package/dist/agent/channels/discord-approval-card.js +303 -0
  59. package/dist/agent/channels/discord-approval-card.js.map +1 -0
  60. package/dist/agent/channels/discord-backscroll.d.ts +209 -0
  61. package/dist/agent/channels/discord-backscroll.d.ts.map +1 -0
  62. package/dist/agent/channels/discord-backscroll.js +505 -0
  63. package/dist/agent/channels/discord-backscroll.js.map +1 -0
  64. package/dist/agent/channels/discord-bot-name.d.ts +19 -0
  65. package/dist/agent/channels/discord-bot-name.d.ts.map +1 -0
  66. package/dist/agent/channels/discord-bot-name.js +98 -0
  67. package/dist/agent/channels/discord-bot-name.js.map +1 -0
  68. package/dist/agent/channels/discord-channel-info.d.ts +22 -0
  69. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -0
  70. package/dist/agent/channels/discord-channel-info.js +65 -0
  71. package/dist/agent/channels/discord-channel-info.js.map +1 -0
  72. package/dist/agent/channels/discord-directory.d.ts +11 -0
  73. package/dist/agent/channels/discord-directory.d.ts.map +1 -0
  74. package/dist/agent/channels/discord-directory.js +170 -0
  75. package/dist/agent/channels/discord-directory.js.map +1 -0
  76. package/dist/agent/channels/discord-dispatch-target.d.ts +15 -0
  77. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -0
  78. package/dist/agent/channels/discord-dispatch-target.js +99 -0
  79. package/dist/agent/channels/discord-dispatch-target.js.map +1 -0
  80. package/dist/agent/channels/discord-events.d.ts +58 -0
  81. package/dist/agent/channels/discord-events.d.ts.map +1 -0
  82. package/dist/agent/channels/discord-events.js +150 -0
  83. package/dist/agent/channels/discord-events.js.map +1 -0
  84. package/dist/agent/channels/discord-front.d.ts +115 -0
  85. package/dist/agent/channels/discord-front.d.ts.map +1 -0
  86. package/dist/agent/channels/discord-front.js +279 -0
  87. package/dist/agent/channels/discord-front.js.map +1 -0
  88. package/dist/agent/channels/discord-gateway.d.ts +783 -0
  89. package/dist/agent/channels/discord-gateway.d.ts.map +1 -0
  90. package/dist/agent/channels/discord-gateway.js +2591 -0
  91. package/dist/agent/channels/discord-gateway.js.map +1 -0
  92. package/dist/agent/channels/discord-identity.d.ts +110 -0
  93. package/dist/agent/channels/discord-identity.d.ts.map +1 -0
  94. package/dist/agent/channels/discord-identity.js +201 -0
  95. package/dist/agent/channels/discord-identity.js.map +1 -0
  96. package/dist/agent/channels/discord-interaction-defer.d.ts +33 -0
  97. package/dist/agent/channels/discord-interaction-defer.d.ts.map +1 -0
  98. package/dist/agent/channels/discord-interaction-defer.js +102 -0
  99. package/dist/agent/channels/discord-interaction-defer.js.map +1 -0
  100. package/dist/agent/channels/discord-message-ops.d.ts +3 -0
  101. package/dist/agent/channels/discord-message-ops.d.ts.map +1 -0
  102. package/dist/agent/channels/discord-message-ops.js +123 -0
  103. package/dist/agent/channels/discord-message-ops.js.map +1 -0
  104. package/dist/agent/channels/discord-origin-codec.d.ts +7 -0
  105. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -0
  106. package/dist/agent/channels/discord-origin-codec.js +98 -0
  107. package/dist/agent/channels/discord-origin-codec.js.map +1 -0
  108. package/dist/agent/channels/discord-post-arm.d.ts +3 -0
  109. package/dist/agent/channels/discord-post-arm.d.ts.map +1 -0
  110. package/dist/agent/channels/discord-post-arm.js +195 -0
  111. package/dist/agent/channels/discord-post-arm.js.map +1 -0
  112. package/dist/agent/channels/discord-post.d.ts +120 -0
  113. package/dist/agent/channels/discord-post.d.ts.map +1 -0
  114. package/dist/agent/channels/discord-post.js +426 -0
  115. package/dist/agent/channels/discord-post.js.map +1 -0
  116. package/dist/agent/channels/discord-read.d.ts +40 -0
  117. package/dist/agent/channels/discord-read.d.ts.map +1 -0
  118. package/dist/agent/channels/discord-read.js +128 -0
  119. package/dist/agent/channels/discord-read.js.map +1 -0
  120. package/dist/agent/channels/discord-rest.d.ts +33 -0
  121. package/dist/agent/channels/discord-rest.d.ts.map +1 -0
  122. package/dist/agent/channels/discord-rest.js +75 -0
  123. package/dist/agent/channels/discord-rest.js.map +1 -0
  124. package/dist/agent/channels/discord.d.ts +3 -0
  125. package/dist/agent/channels/discord.d.ts.map +1 -0
  126. package/dist/agent/channels/discord.js +230 -0
  127. package/dist/agent/channels/discord.js.map +1 -0
  128. package/dist/agent/commands/discord-command-sync.d.ts +66 -0
  129. package/dist/agent/commands/discord-command-sync.d.ts.map +1 -0
  130. package/dist/agent/commands/discord-command-sync.js +224 -0
  131. package/dist/agent/commands/discord-command-sync.js.map +1 -0
  132. package/dist/agent/commands/discord-commands.d.ts +45 -0
  133. package/dist/agent/commands/discord-commands.d.ts.map +1 -0
  134. package/dist/agent/commands/discord-commands.js +266 -0
  135. package/dist/agent/commands/discord-commands.js.map +1 -0
  136. package/dist/agent/contributions.d.ts +17 -0
  137. package/dist/agent/contributions.d.ts.map +1 -0
  138. package/dist/agent/contributions.js +31 -0
  139. package/dist/agent/contributions.js.map +1 -0
  140. package/dist/agent/docs-generated.d.ts +3 -0
  141. package/dist/agent/docs-generated.d.ts.map +1 -0
  142. package/dist/agent/docs-generated.js +13 -0
  143. package/dist/agent/docs-generated.js.map +1 -0
  144. package/dist/agent/identity/discord-link-consent.d.ts +13 -0
  145. package/dist/agent/identity/discord-link-consent.d.ts.map +1 -0
  146. package/dist/agent/identity/discord-link-consent.js +111 -0
  147. package/dist/agent/identity/discord-link-consent.js.map +1 -0
  148. package/dist/agent/ops.d.ts +2 -0
  149. package/dist/agent/ops.d.ts.map +1 -0
  150. package/dist/agent/ops.js +13 -0
  151. package/dist/agent/ops.js.map +1 -0
  152. package/dist/agent/provider-events.d.ts +3 -0
  153. package/dist/agent/provider-events.d.ts.map +1 -0
  154. package/dist/agent/provider-events.js +78 -0
  155. package/dist/agent/provider-events.js.map +1 -0
  156. package/dist/agent/register.d.ts +5 -0
  157. package/dist/agent/register.d.ts.map +1 -0
  158. package/dist/agent/register.js +125 -0
  159. package/dist/agent/register.js.map +1 -0
  160. package/dist/agent/surface-parity.d.ts +3 -0
  161. package/dist/agent/surface-parity.d.ts.map +1 -0
  162. package/dist/agent/surface-parity.js +90 -0
  163. package/dist/agent/surface-parity.js.map +1 -0
  164. package/package.json +185 -0
  165. package/tsconfig.build.json +22 -0
  166. package/tsconfig.json +15 -0
@@ -0,0 +1,175 @@
1
+ // Discord WEBHOOK EVENTS β€” the app-level install/uninstall audit trail (OPE-118 Discord Β§9).
2
+ //
3
+ // Distinct from BOTH of Discord's other webhook-shaped things, which is the whole reason this file
4
+ // carries a header:
5
+ // β€’ NOT an "incoming webhook" (Β§6) β€” that's a channel-scoped POST-only credential;
6
+ // β€’ NOT the Interactions Endpoint (Β§5, discord-front.ts) β€” that's slash commands / button clicks.
7
+ // Webhook Events are configured on a SEPARATE "Webhooks" page of the app in the developer portal
8
+ // (its own URL), delivered app-level, and β€” critically β€” they do NOT carry ordinary chat events.
9
+ // Channel messages remain Gateway-only (Β§8, deploy/discord-relay); the ONLY thing OE wants here is
10
+ // `APPLICATION_AUTHORIZED` / `APPLICATION_DEAUTHORIZED`: the real-time signal that a guild admin
11
+ // installed or removed the app, which OE previously had no way to see short of noticing silence.
12
+ //
13
+ // OBSERVABILITY ONLY. This route gates nothing, drops nothing, and wakes nothing β€” it writes audit
14
+ // rows. A failure to audit is swallowed (the appendAudit layer is best-effort by contract) and the
15
+ // endpoint still acks, because a non-2xx makes Discord retry and eventually SUSPEND delivery.
16
+ //
17
+ // Payload shape verified against docs.discord.com/developers/events/webhook-events (2026-07):
18
+ // { version: 1, application_id, type, event: { type, timestamp, data } }
19
+ // type 0 = PING (endpoint verification), type 1 = Event.
20
+ // APPLICATION_AUTHORIZED data: { integration_type, user, scopes, guild? }
21
+ // β€” integration_type 0 = guild install (guild present), 1 = user install (no guild).
22
+ // APPLICATION_DEAUTHORIZED data: { user } (no guild, no scopes β€” Discord sends neither)
23
+ // Discord requires a 204 with an EMPTY body within 3s for both the PING and every event, and a 401
24
+ // when signature validation fails; repeated failures get the endpoint unregistered.
25
+ //
26
+ // Signature: the SAME Ed25519 app public key the Interactions Endpoint uses, so this reuses
27
+ // `verifyDiscordInteraction` (discord-front.ts) verbatim rather than standing up a second
28
+ // verification path β€” one key, one verifier.
29
+
30
+ import { POST, type Channel, type RouteHandlerArgs } from "eve/channels";
31
+ import { verifyDiscordInteraction } from "./discord-front.js";
32
+ import { appendAudit, deploymentOrg } from "@open-everywhere/core/agent/adapter-api.js";
33
+
34
+ /** The public URL to paste into the app's developer-portal **Webhooks** page (NOT the
35
+ * Interactions Endpoint URL, which stays {@link PUBLIC_DISCORD_ROUTE}). */
36
+ export const PUBLIC_DISCORD_EVENTS_ROUTE = "/eve/v1/discord/events";
37
+
38
+ /** Discord's outer webhook `type`. */
39
+ const WEBHOOK_TYPE_PING = 0;
40
+ const WEBHOOK_TYPE_EVENT = 1;
41
+
42
+ function elog(msg: string): void {
43
+ console.log(`[open-everywhere:discord-events] ${msg}`);
44
+ }
45
+
46
+ /** The decision one verified Webhook Events body resolves to. `ack` covers everything we
47
+ * deliberately don't act on (a PING, an entitlement/social-SDK event, an unparseable body) β€”
48
+ * all of which still get Discord's required 204. */
49
+ export type DiscordEventDecision =
50
+ | { readonly kind: "ack"; readonly reason: string }
51
+ | { readonly kind: "audit"; readonly action: string; readonly workspace: string; readonly principalId?: string; readonly detail: Record<string, unknown> };
52
+
53
+ function str(v: unknown): string | undefined {
54
+ return typeof v === "string" && v.length > 0 ? v : undefined;
55
+ }
56
+
57
+ function obj(v: unknown): Record<string, unknown> | undefined {
58
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
59
+ }
60
+
61
+ /**
62
+ * Classify one raw Webhook Events body. PURE β€” no I/O, so every branch is unit-tested.
63
+ *
64
+ * The audit partition mirrors what the rest of the Discord surface uses: the GUILD is the
65
+ * workspace (the org boundary — `bindDiscord` maps guild→workspace), and the authorizing user is
66
+ * the principal. A USER install (integration_type 1) has no guild at all, so it partitions under
67
+ * the sentinel workspace `user-install` rather than being forced into a guild it doesn't have β€”
68
+ * a fabricated workspace id would be worse than an honest one.
69
+ */
70
+ export function classifyDiscordEvent(rawBody: string): DiscordEventDecision {
71
+ let json: unknown;
72
+ try {
73
+ json = JSON.parse(rawBody);
74
+ } catch {
75
+ return { kind: "ack", reason: "unparseable body" };
76
+ }
77
+ const p = obj(json);
78
+ if (!p) return { kind: "ack", reason: "non-object body" };
79
+ if (p["type"] === WEBHOOK_TYPE_PING) return { kind: "ack", reason: "PING β€” endpoint verification" };
80
+ if (p["type"] !== WEBHOOK_TYPE_EVENT) return { kind: "ack", reason: `webhook type ${String(p["type"])} β€” not an event` };
81
+
82
+ const event = obj(p["event"]);
83
+ const eventType = str(event?.["type"]);
84
+ if (eventType !== "APPLICATION_AUTHORIZED" && eventType !== "APPLICATION_DEAUTHORIZED") {
85
+ // Entitlements, Social-SDK lobby/DM events, and anything Discord adds later: acked, unaudited.
86
+ return { kind: "ack", reason: `event ${eventType ?? "?"} β€” not an install/uninstall` };
87
+ }
88
+
89
+ const data = obj(event?.["data"]);
90
+ const guild = obj(data?.["guild"]);
91
+ const guildId = str(guild?.["id"]);
92
+ const userId = str(obj(data?.["user"])?.["id"]);
93
+ // integration_type is only sent on AUTHORIZED; 0 = guild install, 1 = user install.
94
+ const integrationType = typeof data?.["integration_type"] === "number" ? (data["integration_type"] as number) : undefined;
95
+ const scopes = Array.isArray(data?.["scopes"]) ? (data["scopes"] as unknown[]).filter((s): s is string => typeof s === "string") : undefined;
96
+
97
+ return {
98
+ kind: "audit",
99
+ action: eventType === "APPLICATION_AUTHORIZED" ? "surface.app_installed" : "surface.app_uninstalled",
100
+ // A DEAUTHORIZED payload carries no guild (Discord sends only `user`), so an uninstall of a
101
+ // guild install partitions as unknown β€” honest, and the paired install row names the guild.
102
+ workspace: guildId ?? (integrationType === 1 ? "user-install" : "unknown"),
103
+ ...(userId ? { principalId: userId } : {}),
104
+ detail: {
105
+ event: eventType,
106
+ ...(integrationType !== undefined ? { integrationType } : {}),
107
+ ...(guildId ? { guildId } : {}),
108
+ // Scope names only β€” the grant's shape, not any member data.
109
+ ...(scopes && scopes.length > 0 ? { scopes } : {}),
110
+ ...(str(event?.["timestamp"]) ? { eventTimestamp: str(event?.["timestamp"]) } : {}),
111
+ },
112
+ };
113
+ }
114
+
115
+ /** Injectable seams so the route is testable without live crypto env or the real store. */
116
+ export interface DiscordEventsOptions {
117
+ /** The app public key; defaults to DISCORD_PUBLIC_KEY (the same env the interactions path reads). */
118
+ readonly publicKey?: string;
119
+ readonly now?: () => number;
120
+ /** Audit writer (defaults to the real appendAudit under the deployment org). */
121
+ readonly audit?: (row: { action: string; workspace: string; principalId?: string; detail: Record<string, unknown> }) => Promise<void>;
122
+ }
123
+
124
+ /** Discord requires an EMPTY 204 body for both the PING and every event, within 3 seconds. */
125
+ const ACK = () => new Response(null, { status: 204 });
126
+
127
+ /**
128
+ * Build the Webhook Events route handler: verify (fail-closed 401), classify, audit, ack.
129
+ *
130
+ * The audit write is deliberately AWAITED rather than backgrounded β€” a `surface.app_*` row is one
131
+ * cheap insert and Discord's budget is 3 seconds, so we'd rather the row land than shave a
132
+ * millisecond. It is also wrapped: an audit failure must never turn into a non-2xx, because
133
+ * Discord retries a failure and then suspends the endpoint β€” losing the very observability this
134
+ * route exists to provide.
135
+ */
136
+ export function buildDiscordEventsHandler(opts: DiscordEventsOptions = {}): (req: Request, args: RouteHandlerArgs) => Promise<Response> {
137
+ const now = opts.now ?? (() => Date.now());
138
+ const publicKey = () => opts.publicKey ?? process.env.DISCORD_PUBLIC_KEY;
139
+ const audit =
140
+ opts.audit ??
141
+ (async (row) => {
142
+ await appendAudit({ org: deploymentOrg(), platform: "discord", workspace: row.workspace, ...(row.principalId ? { principalId: row.principalId } : {}), action: row.action, detail: row.detail });
143
+ });
144
+
145
+ return async (req: Request): Promise<Response> => {
146
+ const rawBody = await req.text();
147
+ if (!verifyDiscordInteraction(rawBody, req.headers, publicKey(), now())) {
148
+ return new Response("unauthorized", { status: 401 });
149
+ }
150
+ const decision = classifyDiscordEvent(rawBody);
151
+ if (decision.kind === "ack") {
152
+ elog(`acked: ${decision.reason}`);
153
+ return ACK();
154
+ }
155
+ try {
156
+ await audit({ action: decision.action, workspace: decision.workspace, ...(decision.principalId ? { principalId: decision.principalId } : {}), detail: decision.detail });
157
+ elog(`${decision.detail["event"]} on ${decision.workspace} audited as ${decision.action}`);
158
+ } catch (err) {
159
+ // Observability-only: never let a failed row become a delivery failure (Discord would retry,
160
+ // then suspend the endpoint).
161
+ elog(`audit write failed (acked anyway): ${String(err)}`);
162
+ }
163
+ return ACK();
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Mount the Webhook Events route onto the Discord channel. Additive: it only PREPENDS a route to
169
+ * the same channel object (one channel name β†’ one session namespace, the frontDiscordChannel
170
+ * shape), touching neither the interactions front nor eve's own adapter/receive. With no URL
171
+ * configured in the developer portal this route is simply never hit.
172
+ */
173
+ export function withDiscordEventsRoute<C extends Channel<any, any, any>>(channel: C, opts: DiscordEventsOptions = {}): C {
174
+ return { ...channel, routes: [POST(PUBLIC_DISCORD_EVENTS_ROUTE, buildDiscordEventsHandler(opts)), ...channel.routes] };
175
+ }
@@ -0,0 +1,355 @@
1
+ // Approval-click front for the Discord interactions endpoint β€” the Discord sibling of
2
+ // slack-front.ts's option-(c) route-fronting, for the same structural reason: eve's
3
+ // `discordChannel` owns the interactions URL and handles MESSAGE_COMPONENT clicks itself
4
+ // (its own HITL decode β†’ DEFERRED_UPDATE), exposing no onComponent hook β€” so an
5
+ // `oe-approval:` button click would vanish into eve's deferral. We FRONT the public URL
6
+ // with our own route, answer OUR clicks (approvals/discord-flow.ts), and forward everything
7
+ // else β€” PING, slash commands, eve's `eve_input:` HITL clicks, modal submits β€” to
8
+ // discordChannel's own handler unchanged.
9
+ //
10
+ // The design (mirrors frontSlackChannel exactly):
11
+ // β€’ The wrapped `discordChannel` is pointed at an INTERNAL route (`/eve/v1/discord/interaction`).
12
+ // β€’ OUR route sits at the PUBLIC `/eve/v1/discord` (the Interactions Endpoint URL in the
13
+ // Discord developer portal β€” unchanged, so no re-registration).
14
+ // β€’ Both routes live on the SAME channel object (one channel name β†’ one session namespace),
15
+ // and forwarding hands eve a FRESH Request over the same raw body, so it re-verifies and
16
+ // dispatches exactly as if it owned the URL β€” we only ADD a gate, never weaken one.
17
+ //
18
+ // The front route:
19
+ // 1. Buffers the RAW body ONCE (needed for BOTH signature verification AND forwarding).
20
+ // 2. Verifies Discord's Ed25519 signature over `timestamp + rawBody` (fail-closed 401).
21
+ // Unlike slack-front (which had to re-implement Slack's HMAC), eve publicly exports
22
+ // `verifyDiscordSignature`, so the actual crypto is eve's β€” we only add the header
23
+ // plumbing + skew window around it.
24
+ // 3. Classifies: a type-3 MESSAGE_COMPONENT whose custom_id carries our `oe-approval:`
25
+ // prefix (the approvals plane) or our `oe-link:` prefix (the identity-consent button,
26
+ // OPE-622) β†’ OURS, answered synchronously with the interaction response the matching click
27
+ // handler returns; a type-2 APPLICATION_COMMAND named "oe" β†’ OURS too (backlog item
28
+ // 23's zero-inference slash command β€” see commands/core.ts), answered synchronously and
29
+ // NEVER forwarded to discordChannel's `onCommand`, which dispatches a full agent turn for
30
+ // ANY registered command name (docs/deploy/surfaces/discord.md); anything else β†’ forward.
31
+
32
+ import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } from "eve/channels";
33
+ import { verifyDiscordSignature } from "eve/channels/discord";
34
+ import { OE_DISCORD_APPROVAL_PREFIX } from "../approvals/discord-card.js";
35
+ import { handleDiscordApprovalInteraction, type DiscordInteractionResponse } from "../approvals/discord-flow.js";
36
+ import { LINK_CONSENT_CLICK_PREFIX, OE_GRANT_ACTION_PREFIX, OE_INPUT_CLICK_PREFIX } from "@open-everywhere/core/agent/adapter-api.js";
37
+ import { handleDiscordLinkConsentInteraction } from "../identity/discord-link-consent.js";
38
+ import { handleDiscordGrantCardInteraction } from "../access/discord-grant-click.js";
39
+ import { handleDiscordApprovalCardInteraction, type ApprovalRespondFn } from "./discord-approval-card.js";
40
+ import { approvalCardRespondVia, OE_COMMAND_NAME, type AttachSessionForCancel } from "@open-everywhere/core/agent/adapter-api.js";
41
+ import { handleDiscordOeCommandInteraction } from "../commands/discord-commands.js";
42
+
43
+ /** An HTTP route handler β€” `RouteHandler` isn't a public eve export, so we mirror its shape
44
+ * (same workaround as slack-front.ts). */
45
+ type RouteHandler = (req: Request, args: RouteHandlerArgs) => Promise<Response>;
46
+
47
+ /** The public URL Discord POSTs every interaction to (the app's Interactions Endpoint URL). */
48
+ export const PUBLIC_DISCORD_ROUTE = "/eve/v1/discord";
49
+ /** The internal path the wrapped `discordChannel` is mounted at (never hit by Discord directly). */
50
+ export const INTERNAL_DISCORD_ROUTE = "/eve/v1/discord/interaction";
51
+
52
+ // --- signature verification (Discord Ed25519) --------------------------------
53
+ //
54
+ // πŸ”΄ THIS SCHEME STAYS INLINE AND IS DELIBERATELY *NOT* ON THE WEBHOOK VERIFIER REGISTRY (OPE-197).
55
+ // Slack's `v0=` scheme was registered under that ticket; this one was assessed alongside it and
56
+ // refused. The reason is NOT that it fails to fit `WebhookVerifier.verify({rawBody, headers, keys,
57
+ // nowMs})` β€” it fits perfectly, and the registry's `keyKind: "public-key"` was designed against this
58
+ // exact scheme. The reason is that the entry would be UNREACHABLE, and a registry row nothing can
59
+ // reach makes the thing the registry is for WORSE:
60
+ //
61
+ // β€’ `registeredWebhookVerifierSources()` is printed verbatim in the generic ingress's 401
62
+ // (webhooks/gate.ts) as the set of sources a caller may declare. Adding `discord` to it would
63
+ // advertise a hook an operator can create, point Discord at, and never receive one delivery on.
64
+ // β€’ EVERY Ed25519-signed Discord egress is bound to a RESPONSE contract the generic ingress
65
+ // structurally does not satisfy, so Discord refuses the endpoint before any delivery exists:
66
+ // – the Interactions Endpoint URL is saved only if an immediate `PING` gets a signed `PONG`
67
+ // body back (docs/deploy/surfaces/discord.md Β§1.5 and its Gotchas);
68
+ // – the Webhook Events URL requires "a 204 with an EMPTY body within 3s for both the PING and
69
+ // every event", and "repeated failures get the endpoint unregistered" (the header of
70
+ // channels/discord-events.ts, verified against Discord's docs).
71
+ // The generic ingress answers `200 "ok"` (core channels/webhooks.ts). That is neither, on either
72
+ // arm β€” and both arms are all there is: Discord has no third signed outbound.
73
+ //
74
+ // This is the shape OPE-207 already deleted once on the Notion side: an arm that shipped and was
75
+ // removed because "nothing could put a hook into the status the arm required, so it was reachable by
76
+ // no route". Registering `discord` would re-create it, one registry row further out.
77
+ //
78
+ // WHAT WOULD FLIP THIS: the generic ingress growing a per-source response contract (a source able to
79
+ // declare the bytes it must answer a verification handshake with). Then the entry becomes reachable
80
+ // and should be added β€” the verifier body below is already the whole implementation. Until then the
81
+ // honest statement is that this deployment CAN verify Discord's signature, on the one route Discord
82
+ // can actually be pointed at, which is this one.
83
+ //
84
+ // (Note also that the crypto here is EVE'S `verifyDiscordSignature`, not ours β€” this file adds
85
+ // header plumbing and a skew window. On the "is the scheme ours and inline" axis it was always
86
+ // closer to Linear and Teams, which use eve's verifiers, than to Slack, which does not.)
87
+
88
+ const DISCORD_MAX_SKEW_SECONDS = 60 * 5; // same window eve's own verifyDiscordRequest defaults to
89
+
90
+ /**
91
+ * Verify a Discord interaction over the RAW body (the exact bytes Discord signed):
92
+ * Ed25519 of `{timestamp}{body}` against the app public key, with a 5-minute skew window.
93
+ * Fail-closed: no key configured, a missing header, a malformed/stale timestamp, or a
94
+ * signature mismatch all return false β€” never "verified". `nowMs` is injectable so the
95
+ * skew window is tested; the crypto itself is eve's public `verifyDiscordSignature`.
96
+ */
97
+ export function verifyDiscordInteraction(
98
+ rawBody: string,
99
+ headers: Headers,
100
+ publicKey: string | undefined,
101
+ nowMs: number = Date.now(),
102
+ ): boolean {
103
+ const signature = headers.get("x-signature-ed25519");
104
+ const timestamp = headers.get("x-signature-timestamp");
105
+ if (!publicKey || !signature || !timestamp) return false;
106
+ const ts = Number(timestamp);
107
+ if (!Number.isFinite(ts) || Math.abs(nowMs / 1000 - ts) > DISCORD_MAX_SKEW_SECONDS) return false;
108
+ return verifyDiscordSignature({ body: rawBody, publicKey, signature, timestamp });
109
+ }
110
+
111
+ /** The decision the front route makes for one verified inbound interaction. The parsed
112
+ * body rides the approval-click branch so the handler needn't re-parse it. */
113
+ export type DiscordFrontDecision =
114
+ | { readonly kind: "forward"; readonly reason: string }
115
+ | { readonly kind: "approval-click"; readonly reason: string; readonly interaction: unknown }
116
+ | { readonly kind: "link-consent-click"; readonly reason: string; readonly interaction: unknown }
117
+ /** An `oe_grant:` credential-grant card button click (core access/credential-grant-card.ts) β€” ours:
118
+ * the OWNER answering a request to spend their credential (OPE-1260). */
119
+ | { readonly kind: "grant-click"; readonly reason: string; readonly interaction: unknown }
120
+ /** An `oe_input:` BRIDGED-APPROVAL card button click (core channels/approval-card.ts) β€” ours: the
121
+ * room answering an eve `tool-approval` this deployment rendered onto a unified origin (OPE-1720).
122
+ * eve's own route cannot resolve it β€” the parked request lives on the UNIFIED window, not on the
123
+ * Discord channel's session β€” so forwarding it would drop the tap. */
124
+ | { readonly kind: "input-click"; readonly reason: string; readonly interaction: unknown }
125
+ | { readonly kind: "oe-command"; readonly reason: string; readonly interaction: unknown };
126
+
127
+ /**
128
+ * Decide how the front route handles a raw interaction body. FORWARD is the fail-safe
129
+ * default: anything discordChannel owns (PING/PONG, any OTHER slash command, eve's own HITL
130
+ * component clicks and modal submits) or anything unrecognized goes to it unchanged.
131
+ * A MESSAGE_COMPONENT (type 3) whose custom_id starts with our `oe-approval:` prefix, or an
132
+ * APPLICATION_COMMAND (type 2) named "oe", is ours. Pure: no I/O, so every branch is
133
+ * unit-tested.
134
+ */
135
+ export function classifyDiscordInteraction(rawBody: string): DiscordFrontDecision {
136
+ let json: unknown;
137
+ try {
138
+ json = JSON.parse(rawBody);
139
+ } catch {
140
+ return { kind: "forward", reason: "unparseable body β€” let discordChannel answer it" };
141
+ }
142
+ if (typeof json !== "object" || json === null) {
143
+ return { kind: "forward", reason: "non-object body β€” let discordChannel answer it" };
144
+ }
145
+ const p = json as Record<string, unknown>;
146
+ if (p.type === 2) {
147
+ // APPLICATION_COMMAND (a slash command). "/oe" is OUR zero-inference command (backlog
148
+ // item 23) β€” answered here, NEVER forwarded to discordChannel's onCommand, which
149
+ // dispatches a full agent turn for ANY registered command name. Any OTHER command name
150
+ // (e.g. "/ask", docs/deploy/surfaces/discord.md) is a real user command and still
151
+ // forwards untouched β€” this is the ONLY command name the front route special-cases.
152
+ const data = typeof p.data === "object" && p.data !== null ? (p.data as Record<string, unknown>) : undefined;
153
+ const name = typeof data?.name === "string" ? data.name : undefined;
154
+ if (name === OE_COMMAND_NAME) {
155
+ return { kind: "oe-command", reason: `"/${OE_COMMAND_NAME}" command β€” ours, zero-inference, never forwarded`, interaction: json };
156
+ }
157
+ return { kind: "forward", reason: `application command "${name ?? "?"}" β€” not ours, discordChannel dispatches it` };
158
+ }
159
+ if (p.type !== 3) {
160
+ return { kind: "forward", reason: `interaction type ${String(p.type)} β€” not a component click` };
161
+ }
162
+ const data = typeof p.data === "object" && p.data !== null ? (p.data as Record<string, unknown>) : undefined;
163
+ const customId = typeof data?.custom_id === "string" ? data.custom_id : undefined;
164
+ // Four OE prefixes now, and they are deliberately different strings so a log line names one
165
+ // feature: `oe-approval:` is the approvals plane, `oe-link:` is the identity-consent button
166
+ // (OPE-622), `oe_grant:` is the credential-grant card (OPE-1260 β€” before it, that card's buttons
167
+ // rendered here and fell through to eve, so the owner's tap was lost), `oe_input:` is the bridged
168
+ // eve approval card on a unified origin (OPE-1720 β€” the same lost-tap shape, one plane along).
169
+ // Anything else β€” eve's own `eve_input:` HITL buttons included β€” forwards untouched, and the two
170
+ // spellings are one character apart on purpose: `oe_input:` is OURS and `eve_input:` is eve's.
171
+ if (customId?.startsWith(OE_DISCORD_APPROVAL_PREFIX)) {
172
+ return { kind: "approval-click", reason: "oe-approval: component click β€” ours, never forwarded", interaction: json };
173
+ }
174
+ if (customId?.startsWith(LINK_CONSENT_CLICK_PREFIX)) {
175
+ return { kind: "link-consent-click", reason: "oe-link: component click β€” ours, never forwarded", interaction: json };
176
+ }
177
+ if (customId?.startsWith(OE_GRANT_ACTION_PREFIX)) {
178
+ return { kind: "grant-click", reason: "oe_grant: component click β€” ours, never forwarded", interaction: json };
179
+ }
180
+ if (customId?.startsWith(OE_INPUT_CLICK_PREFIX)) {
181
+ return { kind: "input-click", reason: "oe_input: component click β€” ours, never forwarded", interaction: json };
182
+ }
183
+ return { kind: "forward", reason: "component click β€” eve's HITL owns it" };
184
+ }
185
+
186
+ /** Injectable seams so the front route is testable without live crypto env / the real store. */
187
+ export interface DiscordFrontOptions {
188
+ /** The app public key; defaults to DISCORD_PUBLIC_KEY (same env eve's channel reads). */
189
+ readonly publicKey?: string;
190
+ readonly now?: () => number;
191
+ /** Approval-click handler (defaults to the real approvals/discord-flow.ts). Injectable so
192
+ * tests can stub it without the store/executor machinery it drags in. */
193
+ readonly handleApprovalInteraction?: (
194
+ raw: unknown,
195
+ deps?: { waitUntil?: (p: Promise<unknown>) => void },
196
+ ) => Promise<DiscordInteractionResponse | null>;
197
+ /** Identity-consent click handler (defaults to the real identity/discord-link-consent.ts).
198
+ * Injectable for the same reason the approval one is β€” it reaches the link store. */
199
+ readonly handleLinkConsentInteraction?: (raw: unknown) => Promise<DiscordInteractionResponse | null>;
200
+ /** Credential-grant card click handler (defaults to the real access/discord-grant-click.ts).
201
+ * Injectable for the same reason the two above are β€” it reaches the grant store.
202
+ *
203
+ * πŸ”΄ IT TAKES THE ROUTE'S `waitUntil` (OPE-1282). That door answers with a DEFERRED response and
204
+ * resolves the click off the response path, so it needs somewhere for that work to live past the
205
+ * reply. A stub that ignores the second argument gets the old synchronous shape, which is exactly
206
+ * what a test wants. */
207
+ readonly handleGrantCardInteraction?: (
208
+ raw: unknown,
209
+ deps?: { waitUntil?: (p: Promise<unknown>) => void },
210
+ ) => Promise<DiscordInteractionResponse | null>;
211
+ /** Bridged-approval-card `oe_input:` tap handler (OPE-1720, defaults to
212
+ * `./discord-approval-card.ts`). Injectable so the classification and the branch are testable
213
+ * without a store, a bot token or eve's `respond` primitive. It takes the SETTLE delivery as its
214
+ * second argument because `args.from` is the only place eve hands that primitive out, so it is
215
+ * bound in the route and injected rather than reached for inside the handler. */
216
+ readonly handleApprovalCardInteraction?: (
217
+ raw: unknown,
218
+ respond: ApprovalRespondFn,
219
+ deps?: { waitUntil?: (p: Promise<unknown>) => void },
220
+ ) => Promise<DiscordInteractionResponse | null>;
221
+ /** "/oe" zero-inference command handler (defaults to the real commands/discord-commands.ts).
222
+ * Injectable so tests can stub it without the spend/approval/memory machinery it may touch. */
223
+ readonly handleOeCommand?: (raw: unknown, attachSession?: AttachSessionForCancel) => Promise<DiscordInteractionResponse | null>;
224
+ }
225
+
226
+ function interactionJson(body: DiscordInteractionResponse): Response {
227
+ return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
228
+ }
229
+
230
+ /**
231
+ * Build the front route handler. Buffers the body once, verifies the Ed25519 signature,
232
+ * then answers our approval clicks or forwards per {@link classifyDiscordInteraction}.
233
+ * `innerHandler` is discordChannel's own POST handler, invoked with a FRESH Request
234
+ * (re-readable body) + the same {@link RouteHandlerArgs}, so discordChannel re-verifies
235
+ * and dispatches exactly as if it owned the URL.
236
+ */
237
+ export function buildDiscordFrontHandler(innerHandler: RouteHandler, opts: DiscordFrontOptions = {}): RouteHandler {
238
+ const now = opts.now ?? (() => Date.now());
239
+ const publicKey = () => opts.publicKey ?? process.env.DISCORD_PUBLIC_KEY;
240
+ const handleClick = opts.handleApprovalInteraction ?? handleDiscordApprovalInteraction;
241
+ const handleLinkConsent = opts.handleLinkConsentInteraction ?? handleDiscordLinkConsentInteraction;
242
+ const handleGrantCard = opts.handleGrantCardInteraction ?? handleDiscordGrantCardInteraction;
243
+ const handleApprovalCard = opts.handleApprovalCardInteraction ?? handleDiscordApprovalCardInteraction;
244
+ const handleOeCommand = opts.handleOeCommand ?? handleDiscordOeCommandInteraction;
245
+
246
+ return async (req: Request, args: RouteHandlerArgs): Promise<Response> => {
247
+ const rawBody = await req.text();
248
+ if (!verifyDiscordInteraction(rawBody, req.headers, publicKey(), now())) {
249
+ return new Response("unauthorized", { status: 401 });
250
+ }
251
+ const decision = classifyDiscordInteraction(rawBody);
252
+
253
+ if (decision.kind === "approval-click") {
254
+ // Ours: parsed, admin-checked, CAS-decided and executed by our code (the same
255
+ // resolveApprovalClick core Slack clicks use), answered as an interaction response.
256
+ //
257
+ // πŸ”΄ `args.waitUntil` RIDES ALONG (OPE-1407), for the same reason the grant branch below takes
258
+ // it: the decision FEEDBACK β€” a Discord REST notice post plus a wake dispatch β€” runs off the
259
+ // response path, and on a serverless host a promise with nowhere to live is frozen with the
260
+ // invocation. The door awaits the feedback when this is absent, so passing it is what moves
261
+ // that leg out of Discord's three-second budget.
262
+ const response = await handleClick(decision.interaction, { waitUntil: args.waitUntil });
263
+ if (response) return interactionJson(response);
264
+ // Defensive: classification said "ours" but the handler disagreed (an ours-prefixed
265
+ // but unparseable custom_id) β€” fall through to forwarding so the click is never
266
+ // silently swallowed; eve's HITL decode no-ops it with a harmless deferred ack.
267
+ }
268
+
269
+ if (decision.kind === "link-consent-click") {
270
+ // Ours: the person named in a directory-transport link request tapped their own consent ask
271
+ // (OPE-622). Core's `resolveLinkConsentClick` owns the decision and the writes; this route only
272
+ // carries Discord's verified envelope in and Discord's EPHEMERAL answer back out. A null means
273
+ // the handler could not read the id after all β€” forward rather than swallow, exactly as the
274
+ // approval branch does.
275
+ const response = await handleLinkConsent(decision.interaction);
276
+ if (response) return interactionJson(response);
277
+ }
278
+
279
+ if (decision.kind === "grant-click") {
280
+ // Ours: the OWNER of a credential answered a request to spend it (OPE-1260). Core's
281
+ // `resolveGrantCardClick` owns the decision, the owner check and the writes; this route only
282
+ // carries Discord's verified envelope in and Discord's EPHEMERAL answer back out. A null means
283
+ // the handler could not read the id after all β€” forward rather than swallow, exactly as the two
284
+ // branches above do.
285
+ //
286
+ // πŸ”΄ `args.waitUntil` RIDES ALONG (OPE-1282), and this branch is the only one that passes it.
287
+ // The grant door answers with a DEFERRED response and resolves the click afterwards; without
288
+ // somewhere for that work to survive the reply, a serverless host would freeze the invocation
289
+ // and the owner would sit on "thinking…" forever. The door falls back to its old synchronous
290
+ // shape when this is absent, so passing it is what turns the fix on.
291
+ const response = await handleGrantCard(decision.interaction, { waitUntil: args.waitUntil });
292
+ if (response) return interactionJson(response);
293
+ }
294
+
295
+ if (decision.kind === "input-click") {
296
+ // Ours (core channels/approval-card.ts): somebody tapped a bridged eve approval this deployment
297
+ // rendered onto a unified origin (OPE-1720). The handler resolves the ticket, admits the tap
298
+ // through the SAME floor the typed answer takes, and delivers
299
+ // `respond([{requestId, optionId}])` to the parked WINDOW session.
300
+ //
301
+ // πŸ”΄ THE DELIVERY GOES BY SESSION ID (OPE-1863). `args.from` is CHANNEL-LOCAL β€” on this route
302
+ // it addresses `discord:<token>`, while the parked session lives at `unified:<token>` β€” so the
303
+ // settle threw "the target session was not found" and every tap left the approval parked.
304
+ // `args.attachSession` dispatches by durable session id from any channel, and
305
+ // `approvalCardRespondVia` is the one road every surface binds. Bound HERE and injected because
306
+ // the route handler is the only place eve hands out either primitive.
307
+ //
308
+ // πŸ”΄ `args.waitUntil` RIDES ALONG, for the grant branch's reason and one more of this door's
309
+ // own: a settle RESUMES a parked turn, and Discord does not redeliver an interaction it never
310
+ // saw answered β€” so a slow settle without the defer shows the tapper a red failure over an
311
+ // approval that really did settle. A null means the payload carried our prefix but not a shape
312
+ // the handler could read: forward rather than swallow, exactly as the three branches above do.
313
+ const respondToApproval: ApprovalRespondFn = approvalCardRespondVia(args.attachSession);
314
+ const response = await handleApprovalCard(decision.interaction, respondToApproval, { waitUntil: args.waitUntil });
315
+ if (response) return interactionJson(response);
316
+ }
317
+
318
+ if (decision.kind === "oe-command") {
319
+ // Ours: zero-inference β€” parsed, executed as pure logic (+ the approval-card path for
320
+ // budget raise/clear), answered synchronously as an interaction response. NEVER reaches
321
+ // discordChannel's onCommand (which would dispatch a full agent turn).
322
+ // `args.attachSession` rides along purely for "/oe stop" (OPE-418) β€” it is I/O-free, so
323
+ // passing it costs nothing on the branches that never touch it.
324
+ const response = await handleOeCommand(decision.interaction, args.attachSession);
325
+ if (response) return interactionJson(response);
326
+ // Defensive fallback mirrors the approval-click branch above: classification said
327
+ // "ours" but the handler disagreed β€” never silently swallow, forward instead.
328
+ }
329
+
330
+ // Fresh Request so discordChannel can re-read the raw body + re-verify the signature.
331
+ const forwarded = new Request(req.url, { method: "POST", headers: req.headers, body: rawBody });
332
+ return innerHandler(forwarded, args);
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Compose the approval-click front route with a wrapped `discordChannel`. `inner` must have
338
+ * been built with `route: INTERNAL_DISCORD_ROUTE`. Returns the SAME channel value
339
+ * (preserving eve's internal `__kind`/adapter/receive) with the public front route
340
+ * prepended β€” so Discord's Interactions Endpoint URL hits us, and both routes share one
341
+ * channel name β†’ one session namespace. (Byte-for-byte the frontSlackChannel pattern.)
342
+ */
343
+ export function frontDiscordChannel<C extends Channel<any, any, any>>(inner: C, opts: DiscordFrontOptions = {}): C {
344
+ const innerPost = inner.routes.find(
345
+ (r): r is HttpRouteDefinition<any> => r.method === "POST" && r.path === INTERNAL_DISCORD_ROUTE,
346
+ );
347
+ if (!innerPost) {
348
+ throw new Error(
349
+ `frontDiscordChannel: wrapped discordChannel must be built with route: "${INTERNAL_DISCORD_ROUTE}" ` +
350
+ `(found paths: ${inner.routes.map((r) => r.path).join(", ")})`,
351
+ );
352
+ }
353
+ const front = buildDiscordFrontHandler(innerPost.handler, opts);
354
+ return { ...inner, routes: [POST(PUBLIC_DISCORD_ROUTE, front), ...inner.routes] };
355
+ }