@hogsend/plugin-discord 0.22.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.
- package/LICENSE +93 -0
- package/README.md +235 -0
- package/package.json +50 -0
- package/src/__tests__/connector-transform.test.ts +178 -0
- package/src/__tests__/gateway-forward.test.ts +94 -0
- package/src/__tests__/interactions-followup.test.ts +94 -0
- package/src/__tests__/interactions.test.ts +585 -0
- package/src/__tests__/member-link.test.ts +58 -0
- package/src/__tests__/oauth.test.ts +214 -0
- package/src/connect/interactions-followup.ts +59 -0
- package/src/connect/interactions.ts +864 -0
- package/src/connect/member-link.ts +79 -0
- package/src/connect/oauth.ts +159 -0
- package/src/connect/patch-application.ts +67 -0
- package/src/connector.ts +541 -0
- package/src/constants.ts +47 -0
- package/src/destination.ts +95 -0
- package/src/env.ts +25 -0
- package/src/events.ts +16 -0
- package/src/gateway/index.ts +7 -0
- package/src/gateway/ingress.ts +50 -0
- package/src/gateway/worker.ts +180 -0
- package/src/index.ts +71 -0
- package/src/types.ts +61 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DiscordCurrentUser } from "./oauth.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shape a per-member link reduces to: the args for a single
|
|
5
|
+
* `resolveOrCreateContact({ email?, discordId })` call (the consumer's
|
|
6
|
+
* `resolveContact` callback forwards it). No bespoke merge path — the engine's
|
|
7
|
+
* existing identity machinery folds the Discord-only contact onto the
|
|
8
|
+
* email/identified one (or fills `discord_id` in when only the email resolves).
|
|
9
|
+
*/
|
|
10
|
+
export interface DiscordMemberLink {
|
|
11
|
+
/** The Discord user the member linked. */
|
|
12
|
+
user: DiscordCurrentUser;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MemberLinkContactPatch {
|
|
16
|
+
/**
|
|
17
|
+
* The RAW Discord snowflake — routed through the engine's `discord` identity
|
|
18
|
+
* Kind via `resolveOrCreateContact({ discordId })`, which makes the
|
|
19
|
+
* `discord_id` column load-bearing. NOT a `discord:`-prefixed `userId`: that
|
|
20
|
+
* stuffed the snowflake into `external_id`, leaving `discord_id` always NULL.
|
|
21
|
+
*/
|
|
22
|
+
discordId: string;
|
|
23
|
+
contactProperties: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Map a member-link OAuth result → a contact patch.
|
|
28
|
+
*
|
|
29
|
+
* SECURITY — the Discord-reported email is NEVER a resolution/merge KEY here:
|
|
30
|
+
* the authoritative email is the one the link was ISSUED for (carried in the
|
|
31
|
+
* engine-verified `state.email`), so the connector passes THAT to
|
|
32
|
+
* `resolveContact`, not whatever Discord returns. The Discord-reported email is
|
|
33
|
+
* stored ONLY as a non-key `contactProperty` (`discordEmail`), and ONLY when
|
|
34
|
+
* Discord reports it present AND `verified === true` — an unverified email is
|
|
35
|
+
* dropped entirely. This closes the grafting/account-takeover vector where an
|
|
36
|
+
* attacker sets a Discord email matching a victim's contact to merge into it.
|
|
37
|
+
*
|
|
38
|
+
* `isDiscordLinked` is stamped `true` here — a successful per-member link is the
|
|
39
|
+
* ONLY thing that sets it (an unlinked Discord-only contact never gets it).
|
|
40
|
+
*
|
|
41
|
+
* The NON-KEY `properties.discord` metadata object (id/username/global_name/
|
|
42
|
+
* avatar) is populated from the `/users/@me` pull and deep-merges into whatever
|
|
43
|
+
* inbound events already accumulated (see `DEEP_MERGE_KEYS` in the engine's
|
|
44
|
+
* `lib/contacts.ts`); `last_seen` is NOT stamped here (a link is an identity
|
|
45
|
+
* attach, not activity). `isDiscordLinked` + `discordEmail` stay TOP-LEVEL flags
|
|
46
|
+
* (they are not part of the Discord-platform metadata object).
|
|
47
|
+
*/
|
|
48
|
+
export function memberLinkToContactPatch(
|
|
49
|
+
link: DiscordMemberLink,
|
|
50
|
+
): MemberLinkContactPatch {
|
|
51
|
+
const { user } = link;
|
|
52
|
+
const verifiedEmail =
|
|
53
|
+
user.verified === true &&
|
|
54
|
+
typeof user.email === "string" &&
|
|
55
|
+
user.email.length > 0
|
|
56
|
+
? user.email
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
59
|
+
const discord: Record<string, unknown> = { id: user.id };
|
|
60
|
+
if (typeof user.username === "string") discord.username = user.username;
|
|
61
|
+
if (typeof user.global_name === "string") {
|
|
62
|
+
discord.global_name = user.global_name;
|
|
63
|
+
}
|
|
64
|
+
if (typeof user.avatar === "string") discord.avatar = user.avatar;
|
|
65
|
+
|
|
66
|
+
const contactProperties: Record<string, unknown> = {
|
|
67
|
+
discord,
|
|
68
|
+
isDiscordLinked: true,
|
|
69
|
+
};
|
|
70
|
+
// Non-key property only — NEVER a resolution key (anti-graft, see above).
|
|
71
|
+
if (verifiedEmail) {
|
|
72
|
+
contactProperties.discordEmail = verifiedEmail;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
discordId: user.id,
|
|
77
|
+
contactProperties,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DISCORD_API_BASE,
|
|
3
|
+
DISCORD_BOT_INSTALL_SCOPES,
|
|
4
|
+
DISCORD_MEMBER_LINK_SCOPES,
|
|
5
|
+
DISCORD_OAUTH_AUTHORIZE_URL,
|
|
6
|
+
DISCORD_OAUTH_TOKEN_URL,
|
|
7
|
+
} from "../constants.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discord OAuth2 URL builders + the authorization-code exchange. `fetch`-only;
|
|
11
|
+
* zero `discord.js`. These run inside the engine API process (the connect flow
|
|
12
|
+
* + the connector's `oauthCallback` handler).
|
|
13
|
+
*
|
|
14
|
+
* SECURITY — both authorize-URL builders REQUIRE a `state`: the redirect lands
|
|
15
|
+
* UNAUTHENTICATED on `/v1/connectors/discord/oauth/callback`, so without a
|
|
16
|
+
* caller-minted, server-verified `state` an attacker could forge the callback
|
|
17
|
+
* (login-CSRF / grafting a Discord id onto an arbitrary contact). For the
|
|
18
|
+
* member-link flow the `state` MUST also bind the intended contact/email; the
|
|
19
|
+
* `oauthCallback` handler verifies `state` BEFORE exchanging the code.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface BuildBotInstallUrlArgs {
|
|
23
|
+
applicationId: string;
|
|
24
|
+
/** Redirect URI registered on the app (…/v1/connectors/discord/oauth/callback). */
|
|
25
|
+
redirectUri: string;
|
|
26
|
+
/** Permissions bitfield the bot is requested with (stringified integer). */
|
|
27
|
+
permissions: string;
|
|
28
|
+
/** Opaque CSRF state the callback verifies. */
|
|
29
|
+
state: string;
|
|
30
|
+
/** Optional guild to pre-select in the install dialog. */
|
|
31
|
+
guildId?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The one-click bot-install authorize URL. The single `redirectUri` is used
|
|
36
|
+
* verbatim (NO `flow` query) — the signed-state `purpose` (`install` |
|
|
37
|
+
* `member_link`) is what the `oauthCallback` branches on, and the exchange
|
|
38
|
+
* `redirect_uri` must byte-match this authorize value.
|
|
39
|
+
*/
|
|
40
|
+
export function buildBotInstallUrl(args: BuildBotInstallUrlArgs): string {
|
|
41
|
+
const url = new URL(DISCORD_OAUTH_AUTHORIZE_URL);
|
|
42
|
+
url.searchParams.set("client_id", args.applicationId);
|
|
43
|
+
url.searchParams.set("scope", DISCORD_BOT_INSTALL_SCOPES.join(" "));
|
|
44
|
+
url.searchParams.set("permissions", args.permissions);
|
|
45
|
+
url.searchParams.set("response_type", "code");
|
|
46
|
+
url.searchParams.set("redirect_uri", args.redirectUri);
|
|
47
|
+
url.searchParams.set("state", args.state);
|
|
48
|
+
if (args.guildId) {
|
|
49
|
+
url.searchParams.set("guild_id", args.guildId);
|
|
50
|
+
url.searchParams.set("disable_guild_select", "true");
|
|
51
|
+
}
|
|
52
|
+
return url.toString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BuildMemberLinkUrlArgs {
|
|
56
|
+
applicationId: string;
|
|
57
|
+
redirectUri: string;
|
|
58
|
+
/** Opaque CSRF state — MUST bind the intended contact/email (see header). */
|
|
59
|
+
state: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The per-member link authorize URL. The single `redirectUri` is used verbatim
|
|
64
|
+
* (NO `flow` query) — the signed-state `purpose` disambiguates install vs.
|
|
65
|
+
* member at the callback, and the exchange `redirect_uri` must byte-match this
|
|
66
|
+
* authorize value.
|
|
67
|
+
*/
|
|
68
|
+
export function buildMemberLinkUrl(args: BuildMemberLinkUrlArgs): string {
|
|
69
|
+
const url = new URL(DISCORD_OAUTH_AUTHORIZE_URL);
|
|
70
|
+
url.searchParams.set("client_id", args.applicationId);
|
|
71
|
+
url.searchParams.set("scope", DISCORD_MEMBER_LINK_SCOPES.join(" "));
|
|
72
|
+
url.searchParams.set("response_type", "code");
|
|
73
|
+
url.searchParams.set("redirect_uri", args.redirectUri);
|
|
74
|
+
url.searchParams.set("state", args.state);
|
|
75
|
+
url.searchParams.set("prompt", "consent");
|
|
76
|
+
return url.toString();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Discord's token-endpoint response (subset; bot-install adds `guild`). */
|
|
80
|
+
export interface DiscordTokenResponse {
|
|
81
|
+
access_token: string;
|
|
82
|
+
token_type: string;
|
|
83
|
+
expires_in: number;
|
|
84
|
+
refresh_token?: string;
|
|
85
|
+
scope: string;
|
|
86
|
+
/** Present on a bot-install grant — the guild the bot was added to. */
|
|
87
|
+
guild?: { id: string; name?: string };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ExchangeDiscordCodeArgs {
|
|
91
|
+
applicationId: string;
|
|
92
|
+
clientSecret: string;
|
|
93
|
+
code: string;
|
|
94
|
+
/** MUST byte-match the `redirect_uri` sent on the authorize URL (no flow). */
|
|
95
|
+
redirectUri: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Exchange an authorization `code` for tokens at Discord's token endpoint.
|
|
100
|
+
* `application/x-www-form-urlencoded`, HTTP-Basic-free (client id + secret in
|
|
101
|
+
* the body, per Discord's docs). Throws on a non-2xx.
|
|
102
|
+
*
|
|
103
|
+
* SECRET HYGIENE — the thrown message carries ONLY the HTTP status + a short
|
|
104
|
+
* static reason. Discord's error body can echo the request (which contains the
|
|
105
|
+
* `client_secret`) or partial token material, so it is NEVER interpolated into
|
|
106
|
+
* the thrown message or logged.
|
|
107
|
+
*/
|
|
108
|
+
export async function exchangeDiscordCode(
|
|
109
|
+
args: ExchangeDiscordCodeArgs,
|
|
110
|
+
): Promise<DiscordTokenResponse> {
|
|
111
|
+
const body = new URLSearchParams({
|
|
112
|
+
client_id: args.applicationId,
|
|
113
|
+
client_secret: args.clientSecret,
|
|
114
|
+
grant_type: "authorization_code",
|
|
115
|
+
code: args.code,
|
|
116
|
+
redirect_uri: args.redirectUri,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const res = await fetch(DISCORD_OAUTH_TOKEN_URL, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
122
|
+
body: body.toString(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
throw new Error(`Discord token exchange failed (${res.status})`);
|
|
127
|
+
}
|
|
128
|
+
return (await res.json()) as DiscordTokenResponse;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Discord `/users/@me` response subset — the member-link identity pull. */
|
|
132
|
+
export interface DiscordCurrentUser {
|
|
133
|
+
id: string;
|
|
134
|
+
username?: string;
|
|
135
|
+
/** Discord's display name (the post-2023 unique-name system). */
|
|
136
|
+
global_name?: string | null;
|
|
137
|
+
/** Avatar hash (NOT a URL). */
|
|
138
|
+
avatar?: string | null;
|
|
139
|
+
email?: string | null;
|
|
140
|
+
/** TRUE iff Discord has verified the email — gate the link on this. */
|
|
141
|
+
verified?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fetch the authenticated user (member-link flow). Throws on a non-2xx with
|
|
146
|
+
* ONLY the HTTP status — the response/error body (and the Bearer token it
|
|
147
|
+
* pertains to) is never echoed into the thrown message or logged.
|
|
148
|
+
*/
|
|
149
|
+
export async function getCurrentUser(
|
|
150
|
+
accessToken: string,
|
|
151
|
+
): Promise<DiscordCurrentUser> {
|
|
152
|
+
const res = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
|
153
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
throw new Error(`Discord /users/@me failed (${res.status})`);
|
|
157
|
+
}
|
|
158
|
+
return (await res.json()) as DiscordCurrentUser;
|
|
159
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { DISCORD_API_BASE } from "../constants.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wire the Discord application server-side via `PATCH /applications/@me` (Bot
|
|
5
|
+
* auth). Sets the `interactions_endpoint_url` (and optional install params) so
|
|
6
|
+
* a fresh app is fully provisioned without portal clicking.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: Discord validates `interactions_endpoint_url` by synchronously
|
|
9
|
+
* PINGing it during the PATCH — the interactions route MUST be live and
|
|
10
|
+
* publicly reachable first, or the PATCH 400s. The caller (the `wire` admin
|
|
11
|
+
* route) refuses when `API_PUBLIC_URL` is loopback for exactly this reason.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: PATCHing the same values again is a no-op on Discord's side.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface PatchApplicationArgs {
|
|
17
|
+
/** Bot token (used as `Bot <token>` — application-level config edits). */
|
|
18
|
+
botToken: string;
|
|
19
|
+
/** Public interactions URL (…/v1/connectors/discord/interactions). */
|
|
20
|
+
interactionsEndpointUrl: string;
|
|
21
|
+
/** Optional in-app install params (scopes + permissions bitfield). */
|
|
22
|
+
installParams?: { scopes: string[]; permissions: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PatchApplicationResult {
|
|
26
|
+
applicationId: string;
|
|
27
|
+
interactionsEndpointUrl: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function patchApplication(
|
|
31
|
+
args: PatchApplicationArgs,
|
|
32
|
+
): Promise<PatchApplicationResult> {
|
|
33
|
+
const payload: Record<string, unknown> = {
|
|
34
|
+
interactions_endpoint_url: args.interactionsEndpointUrl,
|
|
35
|
+
};
|
|
36
|
+
if (args.installParams) {
|
|
37
|
+
payload.install_params = {
|
|
38
|
+
scopes: args.installParams.scopes,
|
|
39
|
+
permissions: args.installParams.permissions,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const res = await fetch(`${DISCORD_API_BASE}/applications/@me`, {
|
|
44
|
+
method: "PATCH",
|
|
45
|
+
headers: {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
Authorization: `Bot ${args.botToken}`,
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify(payload),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
// SECRET HYGIENE — status + a short static reason ONLY. The error body can
|
|
54
|
+
// echo back the request (carrying the `Bot` token) or app config; it is
|
|
55
|
+
// NEVER interpolated into the thrown message or logged.
|
|
56
|
+
throw new Error(`Discord PATCH /applications/@me failed (${res.status})`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const app = (await res.json()) as {
|
|
60
|
+
id: string;
|
|
61
|
+
interactions_endpoint_url?: string | null;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
applicationId: app.id,
|
|
65
|
+
interactionsEndpointUrl: app.interactions_endpoint_url ?? null,
|
|
66
|
+
};
|
|
67
|
+
}
|