@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,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
|
-
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
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);
|