@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.
@@ -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);
@@ -1,7 +1,7 @@
1
1
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
3
  import {
4
- deleteProviderCredential,
4
+ deleteAllProviderCredentials,
5
5
  getProviderCredential,
6
6
  ProviderCredentialDecryptError,
7
7
  type ProviderCredentialMeta,
@@ -108,7 +108,11 @@ const deleteRoute = createRoute({
108
108
  method: "delete",
109
109
  path: "/{providerId}",
110
110
  tags: ["Admin — Provider Credentials"],
111
- summary: "Delete a provider credential",
111
+ summary: "Purge a provider's stored credentials (oauth + derived)",
112
+ description:
113
+ "Disconnect: hard-deletes EVERY stored credential for the provider — " +
114
+ "the oauth grant AND the server-derived config (minted webhook secret + " +
115
+ "grabbed phc_) — so no orphaned rows remain.",
112
116
  request: {
113
117
  params: providerIdParam,
114
118
  },
@@ -117,11 +121,11 @@ const deleteRoute = createRoute({
117
121
  content: {
118
122
  "application/json": { schema: z.object({ deleted: z.boolean() }) },
119
123
  },
120
- description: "Credential hard-deleted",
124
+ description: "Credentials hard-deleted (at least one row removed)",
121
125
  },
122
126
  404: {
123
127
  content: { "application/json": { schema: errorSchema } },
124
- description: "No credential stored for this provider",
128
+ description: "No credentials stored for this provider",
125
129
  },
126
130
  },
127
131
  });
@@ -180,8 +184,13 @@ export const providerCredentialsRouter = new OpenAPIHono<AppEnv>()
180
184
 
181
185
  // Never decrypts — DELETE must succeed even when the payload is
182
186
  // undecryptable (the operator's escape hatch after a secret rotation).
183
- const deleted = await deleteProviderCredential(db, providerId);
184
- if (!deleted) {
187
+ // Disconnect purges BOTH kinds (oauth grant + derived config) so the
188
+ // minted webhook secret + grabbed phc_ never linger orphaned.
189
+ const { oauth, derived } = await deleteAllProviderCredentials(
190
+ db,
191
+ providerId,
192
+ );
193
+ if (!oauth && !derived) {
185
194
  return c.json({ error: "Provider credential not found" }, 404);
186
195
  }
187
196
  return c.json({ deleted: true }, 200);