@hogsend/engine 0.21.1 → 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/package.json +7 -7
- package/src/app.ts +37 -30
- package/src/connectors/define-connector.ts +205 -0
- package/src/connectors/presets/index.ts +31 -0
- package/src/connectors/registry-singleton.ts +79 -0
- package/src/container.ts +73 -0
- package/src/env.ts +5 -0
- package/src/index.ts +56 -0
- package/src/lib/connector-link-codes.ts +218 -0
- package/src/lib/connector-state.ts +114 -0
- package/src/lib/contacts.ts +121 -8
- package/src/lib/discord-gateway-heartbeat.ts +164 -0
- package/src/lib/ingestion.ts +6 -0
- package/src/lib/provider-credentials.ts +11 -0
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/connectors/index.ts +279 -0
- package/src/routes/index.ts +17 -4
- package/src/routes/webhooks/index.ts +3 -3
- package/src/routes/webhooks/sources.ts +20 -10
- package/src/webhook-sources/define-webhook-source.ts +44 -39
- package/src/webhook-sources/verify.ts +4 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Logger } from "./logger.js";
|
|
2
|
+
import { getRedis } from "./redis.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Discord Gateway worker liveness heartbeat. The gateway worker is its OWN
|
|
6
|
+
* long-lived process (a `discord.js` socket forwarding raw dispatches), separate
|
|
7
|
+
* from BOTH the API and the Hatchet worker — so the API (and Studio's
|
|
8
|
+
* `/integrations` card) cannot otherwise tell whether the gateway socket is
|
|
9
|
+
* actually up. The worker writes a TTL'd key to Redis on an interval; readers
|
|
10
|
+
* treat a fresh key as "the gateway worker is alive".
|
|
11
|
+
*
|
|
12
|
+
* This mirrors {@link ./worker-heartbeat.ts} but on a DISTINCT key and with a
|
|
13
|
+
* richer JSON payload: it also carries the guild id the live worker observed at
|
|
14
|
+
* `GUILD_CREATE`, which lets the card confirm "Bot installed" for env-only
|
|
15
|
+
* deploys (no derived credential) — a fresh heartbeat with a guild id IS the
|
|
16
|
+
* proof-of-configuration the status fix needs.
|
|
17
|
+
*
|
|
18
|
+
* Redis is the channel because both processes can already reach it and the
|
|
19
|
+
* health route already probes it — no direct process-to-process coupling, no
|
|
20
|
+
* migration. Everything here is best-effort: a missing/unreachable Redis never
|
|
21
|
+
* crashes the worker and simply reads back as "down".
|
|
22
|
+
*/
|
|
23
|
+
const HEARTBEAT_KEY = "hogsend:discord-gateway:heartbeat";
|
|
24
|
+
const TTL_SECONDS = 30;
|
|
25
|
+
const REFRESH_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
export interface DiscordGatewayHeartbeat {
|
|
28
|
+
/** True when a fresh gateway-worker heartbeat is present in Redis. */
|
|
29
|
+
alive: boolean;
|
|
30
|
+
/** ISO timestamp the gateway worker last wrote, when alive. */
|
|
31
|
+
lastSeenAt?: string;
|
|
32
|
+
/** The guild id the live worker observed (confirms the bot is in a server). */
|
|
33
|
+
guildId?: string;
|
|
34
|
+
/** The intents bitfield the live worker requested at login. */
|
|
35
|
+
intents?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The JSON shape persisted under {@link HEARTBEAT_KEY}. */
|
|
39
|
+
interface HeartbeatPayload {
|
|
40
|
+
lastSeenAt: string;
|
|
41
|
+
guildId?: string;
|
|
42
|
+
intents?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The mutable state a running heartbeat exposes for late-bound folding. */
|
|
46
|
+
export interface DiscordGatewayHeartbeatState {
|
|
47
|
+
/**
|
|
48
|
+
* Fold the worker-observed guild id into the heartbeat and write immediately,
|
|
49
|
+
* so Studio can confirm "Bot installed" as soon as the socket sees a guild.
|
|
50
|
+
*/
|
|
51
|
+
setGuildId(guildId: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Fold the worker's resolved intents bitfield into the heartbeat and write
|
|
54
|
+
* immediately, so Studio's intents chip reflects what the LIVE worker requested
|
|
55
|
+
* (preferred over the derived credential, which is never written by install).
|
|
56
|
+
*/
|
|
57
|
+
setIntents(intents: number): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DiscordGatewayHeartbeatHandle {
|
|
61
|
+
/** Mutable state — call `setGuildId` from the worker's `onGuildObserved` tap. */
|
|
62
|
+
state: DiscordGatewayHeartbeatState;
|
|
63
|
+
/** Clear the timer and delete the key for an immediate "down" on shutdown. */
|
|
64
|
+
stop(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Begin writing the Discord gateway-worker heartbeat. Writes once immediately,
|
|
69
|
+
* then refreshes every {@link REFRESH_MS} with a {@link TTL_SECONDS} expiry — so
|
|
70
|
+
* an ungraceful worker death is reflected as "down" within the TTL. The returned
|
|
71
|
+
* handle exposes `state.setGuildId(id)` (fold in the observed guild + write now)
|
|
72
|
+
* and `stop()` (clear the timer + delete the key for an immediate "down").
|
|
73
|
+
*/
|
|
74
|
+
export function startDiscordGatewayHeartbeat(
|
|
75
|
+
logger: Logger,
|
|
76
|
+
): DiscordGatewayHeartbeatHandle {
|
|
77
|
+
let warned = false;
|
|
78
|
+
let guildId: string | undefined;
|
|
79
|
+
let intents: number | undefined;
|
|
80
|
+
|
|
81
|
+
const write = async () => {
|
|
82
|
+
const payload: HeartbeatPayload = {
|
|
83
|
+
lastSeenAt: new Date().toISOString(),
|
|
84
|
+
...(guildId ? { guildId } : {}),
|
|
85
|
+
...(typeof intents === "number" ? { intents } : {}),
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
await getRedis().set(
|
|
89
|
+
HEARTBEAT_KEY,
|
|
90
|
+
JSON.stringify(payload),
|
|
91
|
+
"EX",
|
|
92
|
+
TTL_SECONDS,
|
|
93
|
+
);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// Log the first failure only — a Redis-less deploy would otherwise spam.
|
|
96
|
+
if (!warned) {
|
|
97
|
+
warned = true;
|
|
98
|
+
logger.debug(
|
|
99
|
+
"Discord gateway heartbeat write failed (Redis unreachable?)",
|
|
100
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
void write();
|
|
107
|
+
const timer = setInterval(() => void write(), REFRESH_MS);
|
|
108
|
+
// Never hold the process open for the heartbeat alone.
|
|
109
|
+
timer.unref?.();
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
state: {
|
|
113
|
+
setGuildId(id: string) {
|
|
114
|
+
guildId = id;
|
|
115
|
+
// Write immediately so the card flips to "Bot installed" without waiting
|
|
116
|
+
// for the next refresh tick.
|
|
117
|
+
void write();
|
|
118
|
+
},
|
|
119
|
+
setIntents(value: number) {
|
|
120
|
+
intents = value;
|
|
121
|
+
// Write immediately so the intents chip reflects the live worker without
|
|
122
|
+
// waiting for the next refresh tick.
|
|
123
|
+
void write();
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
async stop() {
|
|
127
|
+
clearInterval(timer);
|
|
128
|
+
try {
|
|
129
|
+
await getRedis().del(HEARTBEAT_KEY);
|
|
130
|
+
} catch {
|
|
131
|
+
// Best-effort — the TTL expires it anyway.
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read the current Discord gateway-worker heartbeat. Resolves to
|
|
139
|
+
* `{ alive: false }` if the key is missing or Redis is unreachable. Tolerates a
|
|
140
|
+
* legacy plain-string value (treated as alive with no guild) so a reader can
|
|
141
|
+
* outlive a payload-shape change.
|
|
142
|
+
*/
|
|
143
|
+
export async function getDiscordGatewayHeartbeat(): Promise<DiscordGatewayHeartbeat> {
|
|
144
|
+
try {
|
|
145
|
+
const raw = await getRedis().get(HEARTBEAT_KEY);
|
|
146
|
+
if (!raw) return { alive: false };
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(raw) as HeartbeatPayload;
|
|
149
|
+
return {
|
|
150
|
+
alive: true,
|
|
151
|
+
lastSeenAt: parsed.lastSeenAt,
|
|
152
|
+
...(parsed.guildId ? { guildId: parsed.guildId } : {}),
|
|
153
|
+
...(typeof parsed.intents === "number"
|
|
154
|
+
? { intents: parsed.intents }
|
|
155
|
+
: {}),
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
// Legacy plain-string value (a bare ISO timestamp) — alive, no guild.
|
|
159
|
+
return { alive: true, lastSeenAt: raw };
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
return { alive: false };
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/lib/ingestion.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface IngestEvent {
|
|
|
14
14
|
userEmail?: string;
|
|
15
15
|
/** D1: future anonymous→identified path. Threaded into the resolver. */
|
|
16
16
|
anonymousId?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Discord user id (snowflake). Resolves a `discord`-keyed contact (a later
|
|
19
|
+
* per-member link merges it into the email contact).
|
|
20
|
+
*/
|
|
21
|
+
discordId?: string;
|
|
17
22
|
/** D2: → `user_events` + Hatchet `trigger.where`/`exitOn` ONLY. */
|
|
18
23
|
eventProperties: Record<string, unknown>;
|
|
19
24
|
/** D2: → `contacts.properties` merge ONLY. */
|
|
@@ -68,6 +73,7 @@ export async function ingestEvent(opts: {
|
|
|
68
73
|
userId: event.userId,
|
|
69
74
|
email: event.userEmail || undefined,
|
|
70
75
|
anonymousId: event.anonymousId,
|
|
76
|
+
discordId: event.discordId,
|
|
71
77
|
contactProperties: event.contactProperties,
|
|
72
78
|
});
|
|
73
79
|
|
|
@@ -54,6 +54,17 @@ export interface DerivedCredentialPayload {
|
|
|
54
54
|
projectApiKey?: string;
|
|
55
55
|
projectId?: string;
|
|
56
56
|
privateHost?: string;
|
|
57
|
+
// --- Discord (kind="derived", providerId="discord") ---
|
|
58
|
+
// Server-derived during `hogsend connect discord`: the app id + public key
|
|
59
|
+
// are read by the admin connect-info / interactions routes; the guild id is
|
|
60
|
+
// captured from the bot-install OAuth grant; the bot token + client secret
|
|
61
|
+
// (when stored here rather than env) feed the gateway worker / code exchange.
|
|
62
|
+
// All optional — the store is provider-neutral and additive.
|
|
63
|
+
discordAppId?: string;
|
|
64
|
+
discordPublicKey?: string;
|
|
65
|
+
discordClientSecret?: string;
|
|
66
|
+
discordBotToken?: string;
|
|
67
|
+
discordGuildId?: string;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
/** Row metadata — everything EXCEPT token material. Safe to surface. */
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { contacts, type Database } from "@hogsend/db";
|
|
3
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
4
|
+
import { and, count, isNotNull, isNull } from "drizzle-orm";
|
|
5
|
+
import type { AppEnv } from "../../app.js";
|
|
6
|
+
import { getDestinationRegistry } from "../../destinations/registry-singleton.js";
|
|
7
|
+
import { signConnectorState } from "../../lib/connector-state.js";
|
|
8
|
+
import { getDiscordGatewayHeartbeat } from "../../lib/discord-gateway-heartbeat.js";
|
|
9
|
+
import {
|
|
10
|
+
getDerivedCredential,
|
|
11
|
+
getProviderCredential,
|
|
12
|
+
ProviderCredentialDecryptError,
|
|
13
|
+
} from "../../lib/provider-credentials.js";
|
|
14
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
15
|
+
import { isLoopbackPublicUrl } from "./analytics.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Admin connector/destination catalog — the GENERIC, provider-neutral half of
|
|
19
|
+
* the connect flow that Studio's `/integrations` page reads. Mounted at
|
|
20
|
+
* `/v1/admin/connectors`, inheriting `requireAdmin` + `rateLimit` +
|
|
21
|
+
* `auditMiddleware` from the admin router root.
|
|
22
|
+
*
|
|
23
|
+
* - `GET /` enumerates every code-registered inbound connector + outbound
|
|
24
|
+
* destination, joined to stored-credential META (kind + updatedAt). Token
|
|
25
|
+
* material NEVER surfaces — this is observe-and-connect, the same INVARIANT
|
|
26
|
+
* the provider-credentials router holds.
|
|
27
|
+
* - `GET /discord/connect-info` projects the Discord-specific env/credential
|
|
28
|
+
* signal the CLI + Studio need to drive the connect flow. Pure projection —
|
|
29
|
+
* it reads the registry + derived-credential meta + env, nothing more.
|
|
30
|
+
*
|
|
31
|
+
* The Discord-SPECIFIC mutating routes (`secrets`/`wire`) are CONSUMER-mounted
|
|
32
|
+
* (the engine ships no Discord code) — see the consolidated spec §4.2.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const transportSchema = z.enum(["webhook", "gateway", "poll"]);
|
|
36
|
+
|
|
37
|
+
const credentialMetaSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
connected: z.boolean(),
|
|
40
|
+
kind: z.enum(["oauth", "derived"]).optional(),
|
|
41
|
+
updatedAt: z.string().optional(),
|
|
42
|
+
})
|
|
43
|
+
.nullable();
|
|
44
|
+
|
|
45
|
+
const integrationSchema = z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
name: z.string(),
|
|
48
|
+
transport: transportSchema,
|
|
49
|
+
hasConnector: z.boolean(),
|
|
50
|
+
hasDestination: z.boolean(),
|
|
51
|
+
description: z.string().optional(),
|
|
52
|
+
credential: credentialMetaSchema,
|
|
53
|
+
webhook: z
|
|
54
|
+
.object({ url: z.string(), secretConfigured: z.boolean() })
|
|
55
|
+
.optional(),
|
|
56
|
+
gateway: z
|
|
57
|
+
.object({
|
|
58
|
+
// Tri-state: true = a guild id is known (bot is in a server); null =
|
|
59
|
+
// unknown (worker reports no guild AND no derived guild). Never a false
|
|
60
|
+
// "not installed" for a working env-only deploy.
|
|
61
|
+
botInstalled: z.boolean().nullable(),
|
|
62
|
+
guildId: z.string().nullable(),
|
|
63
|
+
intents: z.number().nullable(),
|
|
64
|
+
workerHealthy: z.boolean(),
|
|
65
|
+
workerLastSeenAt: z.string().nullable(),
|
|
66
|
+
linkedMembers: z.number(),
|
|
67
|
+
unlinkedMembers: z.number(),
|
|
68
|
+
})
|
|
69
|
+
.optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const listRoute = createRoute({
|
|
73
|
+
method: "get",
|
|
74
|
+
path: "/",
|
|
75
|
+
tags: ["Admin — Connectors"],
|
|
76
|
+
summary: "List code-registered connectors + destinations with connect state",
|
|
77
|
+
responses: {
|
|
78
|
+
200: {
|
|
79
|
+
content: {
|
|
80
|
+
"application/json": {
|
|
81
|
+
schema: z.object({ integrations: z.array(integrationSchema) }),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
description:
|
|
85
|
+
"Every code-registered inbound connector + outbound destination, " +
|
|
86
|
+
"joined to stored-credential meta. Tokens never surface.",
|
|
87
|
+
},
|
|
88
|
+
401: {
|
|
89
|
+
content: { "application/json": { schema: errorSchema } },
|
|
90
|
+
description: "Missing or invalid admin credentials",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const connectInfoSchema = z.object({
|
|
96
|
+
providerId: z.literal("discord"),
|
|
97
|
+
apiPublicUrl: z.string(),
|
|
98
|
+
redirectUri: z.string(),
|
|
99
|
+
interactionsUrl: z.string(),
|
|
100
|
+
ingressSecretConfigured: z.boolean(),
|
|
101
|
+
credentialStored: z.boolean(),
|
|
102
|
+
guildId: z.string().nullable(),
|
|
103
|
+
// Tri-state — null = unknown (no guild from worker or derived credential).
|
|
104
|
+
botInstalled: z.boolean().nullable(),
|
|
105
|
+
/** True when a fresh gateway-worker heartbeat is present (Worker Online). */
|
|
106
|
+
workerOnline: z.boolean(),
|
|
107
|
+
workerLastSeenAt: z.string().nullable(),
|
|
108
|
+
apiPublicUrlReachable: z.boolean(),
|
|
109
|
+
/**
|
|
110
|
+
* The one-click bot-install URL, built SERVER-SIDE from the stored Discord
|
|
111
|
+
* application id (the `client_id` — not a secret). `null` until the secrets
|
|
112
|
+
* are pasted via `hogsend connect discord`, so Studio shows the
|
|
113
|
+
* "Connect via CLI" callout instead of an invite button.
|
|
114
|
+
*/
|
|
115
|
+
installUrl: z.string().nullable(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const connectInfoRoute = createRoute({
|
|
119
|
+
method: "get",
|
|
120
|
+
path: "/discord/connect-info",
|
|
121
|
+
tags: ["Admin — Connectors"],
|
|
122
|
+
summary: "Discord connection info for `hogsend connect discord` + Studio",
|
|
123
|
+
responses: {
|
|
124
|
+
200: {
|
|
125
|
+
content: {
|
|
126
|
+
"application/json": { schema: connectInfoSchema },
|
|
127
|
+
},
|
|
128
|
+
description:
|
|
129
|
+
"Discord connect signal (redirect URI, interactions URL, readiness " +
|
|
130
|
+
"flags, guild id) — secrets never appear, only their configured-ness",
|
|
131
|
+
},
|
|
132
|
+
401: {
|
|
133
|
+
content: { "application/json": { schema: errorSchema } },
|
|
134
|
+
description: "Missing or invalid admin credentials",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const memberLinkUrlRequestSchema = z.object({
|
|
140
|
+
/** The contact the per-member link is issued FOR (authoritative binding). */
|
|
141
|
+
contactId: z.string().min(1),
|
|
142
|
+
/** The contact's email — the authoritative resolution key (anti-graft). */
|
|
143
|
+
email: z.string().email(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const memberLinkUrlResponseSchema = z.object({
|
|
147
|
+
/** The full per-member Discord authorize URL, carrying the signed state. */
|
|
148
|
+
url: z.string(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const memberLinkUrlRoute = createRoute({
|
|
152
|
+
method: "post",
|
|
153
|
+
path: "/discord/member-link-url",
|
|
154
|
+
tags: ["Admin — Connectors"],
|
|
155
|
+
summary: "Mint a per-member Discord link URL bound to a contact (anti-graft)",
|
|
156
|
+
request: {
|
|
157
|
+
body: {
|
|
158
|
+
content: {
|
|
159
|
+
"application/json": { schema: memberLinkUrlRequestSchema },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
responses: {
|
|
164
|
+
200: {
|
|
165
|
+
content: {
|
|
166
|
+
"application/json": { schema: memberLinkUrlResponseSchema },
|
|
167
|
+
},
|
|
168
|
+
description:
|
|
169
|
+
"Per-member Discord authorize URL whose signed state binds the " +
|
|
170
|
+
"contact id + email the link is issued for",
|
|
171
|
+
},
|
|
172
|
+
409: {
|
|
173
|
+
content: { "application/json": { schema: errorSchema } },
|
|
174
|
+
description: "Discord application id not yet stored (connect first)",
|
|
175
|
+
},
|
|
176
|
+
401: {
|
|
177
|
+
content: { "application/json": { schema: errorSchema } },
|
|
178
|
+
description: "Missing or invalid admin credentials",
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve a provider's stored-credential meta WITHOUT ever surfacing token
|
|
185
|
+
* material. Prefers the oauth grant; falls back to the derived config. A
|
|
186
|
+
* decrypt failure (secret rotated) is treated as "connected" — the operator
|
|
187
|
+
* still needs to disconnect/reconnect, so the card must show it.
|
|
188
|
+
*/
|
|
189
|
+
async function resolveCredentialMeta(
|
|
190
|
+
db: Database,
|
|
191
|
+
providerId: string,
|
|
192
|
+
): Promise<z.infer<typeof credentialMetaSchema>> {
|
|
193
|
+
try {
|
|
194
|
+
const oauth = await getProviderCredential(db, providerId, "oauth");
|
|
195
|
+
if (oauth) {
|
|
196
|
+
return {
|
|
197
|
+
connected: true,
|
|
198
|
+
kind: "oauth",
|
|
199
|
+
updatedAt: oauth.updatedAt.toISOString(),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof ProviderCredentialDecryptError) {
|
|
204
|
+
return { connected: true, kind: "oauth" };
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const derived = await getDerivedCredential(db, providerId);
|
|
211
|
+
if (derived) return { connected: true, kind: "derived" };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error instanceof ProviderCredentialDecryptError) {
|
|
214
|
+
return { connected: true, kind: "derived" };
|
|
215
|
+
}
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const adminConnectorsRouter = new OpenAPIHono<AppEnv>()
|
|
223
|
+
.openapi(listRoute, async (c) => {
|
|
224
|
+
const { db, env, connectorRegistry } = c.get("container");
|
|
225
|
+
const apiPublicUrl = env.API_PUBLIC_URL.replace(/\/+$/, "");
|
|
226
|
+
|
|
227
|
+
const connectors = connectorRegistry.getAll();
|
|
228
|
+
const destinations = getDestinationRegistry().getAll();
|
|
229
|
+
|
|
230
|
+
// Union the two faces by id — a platform (Discord) can be BOTH an inbound
|
|
231
|
+
// connector and an outbound destination, and reads as one integration card.
|
|
232
|
+
const ids = new Set<string>();
|
|
233
|
+
for (const connector of connectors) ids.add(connector.meta.id);
|
|
234
|
+
for (const destination of destinations) ids.add(destination.meta.id);
|
|
235
|
+
|
|
236
|
+
const integrations = await Promise.all(
|
|
237
|
+
[...ids].map(async (id) => {
|
|
238
|
+
const connector = connectors.find((x) => x.meta.id === id);
|
|
239
|
+
const destination = destinations.find((x) => x.meta.id === id);
|
|
240
|
+
const transport = connector?.meta.transport ?? "webhook";
|
|
241
|
+
const credential = await resolveCredentialMeta(db, id);
|
|
242
|
+
|
|
243
|
+
const base = {
|
|
244
|
+
id,
|
|
245
|
+
name: connector?.meta.name ?? destination?.meta.name ?? id,
|
|
246
|
+
transport,
|
|
247
|
+
hasConnector: Boolean(connector),
|
|
248
|
+
hasDestination: Boolean(destination),
|
|
249
|
+
description:
|
|
250
|
+
connector?.meta.description ?? destination?.meta.description,
|
|
251
|
+
credential,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Webhook-transport connectors expose their inbound URL + whether the
|
|
255
|
+
// verify secret is configured (a `match` source with no env secret is
|
|
256
|
+
// OPEN; surface that as not-configured so the operator notices).
|
|
257
|
+
if (connector && transport === "webhook") {
|
|
258
|
+
const auth = connector.inboundVerify;
|
|
259
|
+
const secretConfigured = auth
|
|
260
|
+
? Boolean(
|
|
261
|
+
env[auth.envKey as keyof typeof env] as string | undefined,
|
|
262
|
+
)
|
|
263
|
+
: false;
|
|
264
|
+
return {
|
|
265
|
+
...base,
|
|
266
|
+
webhook: {
|
|
267
|
+
url: `${apiPublicUrl}/v1/webhooks/${id}`,
|
|
268
|
+
secretConfigured,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Gateway-transport connectors (Discord) expose bot-install + member
|
|
274
|
+
// link counts + a REAL worker-liveness signal (the gateway worker
|
|
275
|
+
// publishes a TTL'd Redis heartbeat — §4). guildId/intents live in the
|
|
276
|
+
// derived credential OR the live heartbeat.
|
|
277
|
+
if (connector && transport === "gateway") {
|
|
278
|
+
// The Discord derived fields are an ADDITIVE widening of
|
|
279
|
+
// DerivedCredentialPayload (spec §4.1) — read through a loose record
|
|
280
|
+
// so this compiles before/after that widening lands.
|
|
281
|
+
const derived = (await getDerivedCredential(db, id).catch(
|
|
282
|
+
() => null,
|
|
283
|
+
)) as Record<string, unknown> | null;
|
|
284
|
+
const heartbeat = await getDiscordGatewayHeartbeat();
|
|
285
|
+
|
|
286
|
+
const derivedGuildId =
|
|
287
|
+
typeof derived?.discordGuildId === "string"
|
|
288
|
+
? derived.discordGuildId
|
|
289
|
+
: null;
|
|
290
|
+
// Prefer the live worker-observed guild; fall back to the stored one.
|
|
291
|
+
const guildId = heartbeat.guildId ?? derivedGuildId;
|
|
292
|
+
// Prefer the LIVE worker-reported intents (the derived credential never
|
|
293
|
+
// carries discordIntents — install writes only the guild id); fall back
|
|
294
|
+
// to the derived value for forward-compat if it ever IS written.
|
|
295
|
+
const derivedIntents =
|
|
296
|
+
typeof derived?.discordIntents === "number"
|
|
297
|
+
? derived.discordIntents
|
|
298
|
+
: null;
|
|
299
|
+
const intents = heartbeat.intents ?? derivedIntents;
|
|
300
|
+
// Tri-state: a guild id (either source) confirms the bot is in a
|
|
301
|
+
// server; otherwise unknown (null), NOT a false "not installed".
|
|
302
|
+
const botInstalled: boolean | null = guildId ? true : null;
|
|
303
|
+
|
|
304
|
+
// linkedMembers = contacts that have BOTH a discord_id and an email
|
|
305
|
+
// (a member completed the per-member link); unlinkedMembers =
|
|
306
|
+
// discord-keyed contacts with no email yet. Both scope to live rows.
|
|
307
|
+
const [linkedRows, totalRows] = await Promise.all([
|
|
308
|
+
db
|
|
309
|
+
.select({ value: count() })
|
|
310
|
+
.from(contacts)
|
|
311
|
+
.where(
|
|
312
|
+
and(
|
|
313
|
+
isNotNull(contacts.discordId),
|
|
314
|
+
isNotNull(contacts.email),
|
|
315
|
+
isNull(contacts.deletedAt),
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
db
|
|
319
|
+
.select({ value: count() })
|
|
320
|
+
.from(contacts)
|
|
321
|
+
.where(
|
|
322
|
+
and(isNotNull(contacts.discordId), isNull(contacts.deletedAt)),
|
|
323
|
+
),
|
|
324
|
+
]);
|
|
325
|
+
const linkedMembers = linkedRows[0]?.value ?? 0;
|
|
326
|
+
const totalMembers = totalRows[0]?.value ?? 0;
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
...base,
|
|
330
|
+
gateway: {
|
|
331
|
+
botInstalled,
|
|
332
|
+
guildId,
|
|
333
|
+
intents,
|
|
334
|
+
// REAL worker liveness — a fresh gateway-worker heartbeat in Redis.
|
|
335
|
+
workerHealthy: heartbeat.alive,
|
|
336
|
+
workerLastSeenAt: heartbeat.lastSeenAt ?? null,
|
|
337
|
+
linkedMembers,
|
|
338
|
+
unlinkedMembers: Math.max(0, totalMembers - linkedMembers),
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return base;
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
integrations.sort((a, b) => a.name.localeCompare(b.name));
|
|
348
|
+
return c.json({ integrations }, 200);
|
|
349
|
+
})
|
|
350
|
+
.openapi(connectInfoRoute, async (c) => {
|
|
351
|
+
const { db, env } = c.get("container");
|
|
352
|
+
const apiPublicUrl = env.API_PUBLIC_URL.replace(/\/+$/, "");
|
|
353
|
+
|
|
354
|
+
const derived = (await getDerivedCredential(db, "discord").catch(
|
|
355
|
+
() => null,
|
|
356
|
+
)) as Record<string, unknown> | null;
|
|
357
|
+
const heartbeat = await getDiscordGatewayHeartbeat();
|
|
358
|
+
// Prefer the live worker-observed guild; fall back to the stored one.
|
|
359
|
+
const guildId =
|
|
360
|
+
heartbeat.guildId ??
|
|
361
|
+
(typeof derived?.discordGuildId === "string"
|
|
362
|
+
? derived.discordGuildId
|
|
363
|
+
: null);
|
|
364
|
+
const appId =
|
|
365
|
+
typeof derived?.discordAppId === "string" ? derived.discordAppId : null;
|
|
366
|
+
|
|
367
|
+
const redirectUri = `${apiPublicUrl}/v1/connectors/discord/oauth/callback`;
|
|
368
|
+
|
|
369
|
+
// The one-click install link is built server-side from the stored Discord
|
|
370
|
+
// application id (`client_id`, NOT a secret). Scopes install the bot +
|
|
371
|
+
// register slash commands. The `redirect_uri` is the bare callback (NO
|
|
372
|
+
// `flow` query — the signed-state `purpose` disambiguates install vs.
|
|
373
|
+
// member, and the exchange `redirect_uri` byte-matches this value).
|
|
374
|
+
//
|
|
375
|
+
// The install URL is SERVER-MINTED with a signed CSRF `state` — this is the
|
|
376
|
+
// SINGLE canonical install URL (Studio button + CLI both consume it). The
|
|
377
|
+
// unauthenticated oauth callback refuses to exchange a code without a valid
|
|
378
|
+
// state, so the URL would be useless (and the install un-completable)
|
|
379
|
+
// without it. `install` state is CSRF-only — it binds no contact.
|
|
380
|
+
let installUrl: string | null = null;
|
|
381
|
+
if (appId) {
|
|
382
|
+
const state = signConnectorState(
|
|
383
|
+
{
|
|
384
|
+
purpose: "install",
|
|
385
|
+
connectorId: "discord",
|
|
386
|
+
nonce: randomBytes(16).toString("base64url"),
|
|
387
|
+
},
|
|
388
|
+
env.BETTER_AUTH_SECRET,
|
|
389
|
+
600,
|
|
390
|
+
);
|
|
391
|
+
const params = new URLSearchParams({
|
|
392
|
+
client_id: appId,
|
|
393
|
+
response_type: "code",
|
|
394
|
+
scope: "bot applications.commands",
|
|
395
|
+
redirect_uri: redirectUri,
|
|
396
|
+
});
|
|
397
|
+
installUrl =
|
|
398
|
+
`https://discord.com/oauth2/authorize?${params.toString()}` +
|
|
399
|
+
`&state=${encodeURIComponent(state)}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return c.json(
|
|
403
|
+
{
|
|
404
|
+
providerId: "discord" as const,
|
|
405
|
+
apiPublicUrl,
|
|
406
|
+
redirectUri,
|
|
407
|
+
interactionsUrl: `${apiPublicUrl}/v1/connectors/discord/interactions`,
|
|
408
|
+
ingressSecretConfigured: Boolean(env.CONNECTOR_INGRESS_SECRET),
|
|
409
|
+
credentialStored: derived !== null,
|
|
410
|
+
guildId,
|
|
411
|
+
// Tri-state — a guild id (live or derived) confirms install; else null.
|
|
412
|
+
botInstalled: guildId ? true : null,
|
|
413
|
+
workerOnline: heartbeat.alive,
|
|
414
|
+
workerLastSeenAt: heartbeat.lastSeenAt ?? null,
|
|
415
|
+
apiPublicUrlReachable: !isLoopbackPublicUrl(apiPublicUrl),
|
|
416
|
+
installUrl,
|
|
417
|
+
},
|
|
418
|
+
200,
|
|
419
|
+
);
|
|
420
|
+
})
|
|
421
|
+
.openapi(memberLinkUrlRoute, async (c) => {
|
|
422
|
+
const { db, env } = c.get("container");
|
|
423
|
+
const { contactId, email } = c.req.valid("json");
|
|
424
|
+
const apiPublicUrl = env.API_PUBLIC_URL.replace(/\/+$/, "");
|
|
425
|
+
|
|
426
|
+
const derived = (await getDerivedCredential(db, "discord").catch(
|
|
427
|
+
() => null,
|
|
428
|
+
)) as Record<string, unknown> | null;
|
|
429
|
+
const appId =
|
|
430
|
+
typeof derived?.discordAppId === "string" ? derived.discordAppId : null;
|
|
431
|
+
if (!appId) {
|
|
432
|
+
return c.json({ error: "discord_not_connected" }, 409);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Bind the EXACT contact + email the link is issued for. The oauth callback
|
|
436
|
+
// attaches the Discord identity to THIS contact (never to whatever email
|
|
437
|
+
// Discord reports — the graft vector). 15-minute TTL.
|
|
438
|
+
const state = signConnectorState(
|
|
439
|
+
{
|
|
440
|
+
purpose: "member_link",
|
|
441
|
+
connectorId: "discord",
|
|
442
|
+
contactId,
|
|
443
|
+
email,
|
|
444
|
+
nonce: randomBytes(16).toString("base64url"),
|
|
445
|
+
},
|
|
446
|
+
env.BETTER_AUTH_SECRET,
|
|
447
|
+
900,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Mirror the plugin's `buildMemberLinkUrl` contract inline — the engine
|
|
451
|
+
// ships no Discord code, so it cannot import the plugin, but the authorize
|
|
452
|
+
// URL shape is stable: member-link scopes + the signed state. The
|
|
453
|
+
// `redirect_uri` is the bare callback (NO `flow` query — the signed-state
|
|
454
|
+
// `purpose` disambiguates, and the exchange `redirect_uri` byte-matches).
|
|
455
|
+
const redirectUri = `${apiPublicUrl}/v1/connectors/discord/oauth/callback`;
|
|
456
|
+
|
|
457
|
+
const url = new URL("https://discord.com/api/oauth2/authorize");
|
|
458
|
+
url.searchParams.set("client_id", appId);
|
|
459
|
+
url.searchParams.set("scope", "identify email guilds.members.read");
|
|
460
|
+
url.searchParams.set("response_type", "code");
|
|
461
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
462
|
+
url.searchParams.set("state", state);
|
|
463
|
+
url.searchParams.set("prompt", "consent");
|
|
464
|
+
|
|
465
|
+
return c.json({ url: url.toString() }, 200);
|
|
466
|
+
});
|
|
@@ -9,6 +9,7 @@ import { apiKeysRouter } from "./api-keys.js";
|
|
|
9
9
|
import { auditLogsRouter } from "./audit-logs.js";
|
|
10
10
|
import { bucketsRouter } from "./buckets.js";
|
|
11
11
|
import { bulkRouter } from "./bulk.js";
|
|
12
|
+
import { adminConnectorsRouter } from "./connectors.js";
|
|
12
13
|
import { contactsRouter } from "./contacts.js";
|
|
13
14
|
import { dlqRouter } from "./dlq.js";
|
|
14
15
|
import { domainRouter } from "./domain.js";
|
|
@@ -45,6 +46,7 @@ adminRouter.route("/suppressions", suppressionsRouter);
|
|
|
45
46
|
adminRouter.route("/api-keys", apiKeysRouter);
|
|
46
47
|
adminRouter.route("/webhooks", webhooksRouter);
|
|
47
48
|
adminRouter.route("/provider-credentials", providerCredentialsRouter);
|
|
49
|
+
adminRouter.route("/connectors", adminConnectorsRouter);
|
|
48
50
|
adminRouter.route("/analytics", analyticsAdminRouter);
|
|
49
51
|
adminRouter.route("/audit-logs", auditLogsRouter);
|
|
50
52
|
adminRouter.route("/alerts", alertsRouter);
|