@hogsend/engine 0.21.0 → 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 +30 -0
- package/src/routes/admin/analytics.ts +6 -6
- package/src/routes/admin/connectors.ts +466 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/provider-credentials.ts +15 -6
- 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 +30 -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. */
|
|
@@ -297,6 +308,25 @@ export async function deleteProviderCredential(
|
|
|
297
308
|
return deleted.length > 0;
|
|
298
309
|
}
|
|
299
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Purge EVERY stored credential for a provider — the oauth grant AND the
|
|
313
|
+
* server-derived config (minted webhook secret + grabbed phc_). Disconnect
|
|
314
|
+
* must leave no orphaned rows; the single-kind `deleteProviderCredential`
|
|
315
|
+
* stays unchanged so token-lifecycle callers can still target one kind.
|
|
316
|
+
* Returns which kinds were removed. Never decrypts.
|
|
317
|
+
*/
|
|
318
|
+
export async function deleteAllProviderCredentials(
|
|
319
|
+
db: Database,
|
|
320
|
+
providerId: string,
|
|
321
|
+
): Promise<{ oauth: boolean; derived: boolean }> {
|
|
322
|
+
const [oauth, derived] = await Promise.all([
|
|
323
|
+
deleteProviderCredential(db, providerId, "oauth"),
|
|
324
|
+
deleteProviderCredential(db, providerId, "derived"),
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
return { oauth, derived };
|
|
328
|
+
}
|
|
329
|
+
|
|
300
330
|
/**
|
|
301
331
|
* Read + decrypt the kind="derived" config for a provider. `null` when none
|
|
302
332
|
* stored; throws `ProviderCredentialDecryptError` when a row exists but cannot
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
provisionPostHogLoop,
|
|
15
15
|
} from "../../lib/provision-posthog-loop.js";
|
|
16
16
|
import { errorSchema } from "../../lib/schemas.js";
|
|
17
|
+
import { invalidateStoredPosthogSecret } from "../webhooks/sources.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Admin analytics-connection routes — the server half of
|
|
@@ -170,9 +171,8 @@ const provisionLoopRoute = createRoute({
|
|
|
170
171
|
description:
|
|
171
172
|
"Refused: `no_posthog_credential` (no OAuth credential and no " +
|
|
172
173
|
"personal API key), `posthog_not_configured` (no PostHog env signal " +
|
|
173
|
-
"at all), `
|
|
174
|
-
"
|
|
175
|
-
"PostHog cannot deliver to it)",
|
|
174
|
+
"at all), or `api_public_url_unreachable` (API_PUBLIC_URL is loopback " +
|
|
175
|
+
"— PostHog cannot deliver to it)",
|
|
176
176
|
},
|
|
177
177
|
502: {
|
|
178
178
|
content: { "application/json": { schema: provisionFailureSchema } },
|
|
@@ -273,6 +273,9 @@ export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
|
273
273
|
...(storedDerived ?? {}),
|
|
274
274
|
webhookSecret,
|
|
275
275
|
});
|
|
276
|
+
// Bust the inbound posthog webhook source's cached secret so it enforces
|
|
277
|
+
// the freshly-minted value immediately instead of after the ~30s TTL.
|
|
278
|
+
invalidateStoredPosthogSecret();
|
|
276
279
|
}
|
|
277
280
|
|
|
278
281
|
try {
|
|
@@ -318,9 +321,6 @@ export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
|
318
321
|
);
|
|
319
322
|
} catch (error) {
|
|
320
323
|
if (error instanceof ProvisionPostHogLoopError) {
|
|
321
|
-
if (error.code === "missing-webhook-secret") {
|
|
322
|
-
return c.json({ error: "webhook_secret_missing" }, 409);
|
|
323
|
-
}
|
|
324
324
|
return c.json(
|
|
325
325
|
{
|
|
326
326
|
error: error.code,
|