@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.
@@ -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
+ }