@hogsend/cli 0.21.1 → 0.23.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/dist/bin.js +416 -47
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/connect-command.test.ts +13 -2
- package/src/__tests__/connect-discord-flow.test.ts +407 -0
- package/src/commands/connect.ts +157 -28
- package/src/commands/webhooks.ts +1 -0
- package/src/lib/connect-discord-flow.ts +427 -0
- package/studio/assets/index-BCzbYwYS.css +1 -0
- package/studio/assets/index-DhnoxvTR.js +280 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-Bf2wN1Hs.css +0 -1
- package/studio/assets/index-JPChDjmd.js +0 -265
package/dist/bin.js
CHANGED
|
@@ -472,7 +472,7 @@ var campaignsCommand = {
|
|
|
472
472
|
|
|
473
473
|
// src/commands/connect.ts
|
|
474
474
|
import { parseArgs as parseArgs2 } from "util";
|
|
475
|
-
import { confirm, select, text } from "@clack/prompts";
|
|
475
|
+
import { confirm, password, select, text } from "@clack/prompts";
|
|
476
476
|
|
|
477
477
|
// src/lib/browser.ts
|
|
478
478
|
import { spawn } from "child_process";
|
|
@@ -491,6 +491,202 @@ function openBrowser(url) {
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
// src/lib/connect-discord-flow.ts
|
|
495
|
+
var ConnectDiscordError = class extends Error {
|
|
496
|
+
verdict;
|
|
497
|
+
hint;
|
|
498
|
+
constructor(verdict, message, hint) {
|
|
499
|
+
super(message);
|
|
500
|
+
this.name = "ConnectDiscordError";
|
|
501
|
+
this.verdict = verdict;
|
|
502
|
+
this.hint = hint;
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
var HINT_NOT_CONFIGURED = "Run `hogsend connect discord` interactively (in a terminal) to paste the four Discord portal values. To only read the current state, re-run with --status (which never prompts).";
|
|
506
|
+
var HINT_LOOPBACK = "Discord validates the interactions endpoint by PINGing it synchronously, so it must be publicly reachable. Run this against your DEPLOYED instance (point --url at it); the secrets are stored either way.";
|
|
507
|
+
function isLoopbackUrl(publicUrl) {
|
|
508
|
+
try {
|
|
509
|
+
const host = new URL(publicUrl).hostname.toLowerCase();
|
|
510
|
+
return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "[::1]" || host === "::1" || host.endsWith(".localhost");
|
|
511
|
+
} catch {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
var errMsg = (err) => err instanceof Error ? err.message : String(err);
|
|
516
|
+
function portalSteps(info) {
|
|
517
|
+
return [
|
|
518
|
+
"One-time Discord developer portal setup:",
|
|
519
|
+
"",
|
|
520
|
+
" 1. https://discord.com/developers/applications -> New Application",
|
|
521
|
+
" 2. Bot tab -> Reset Token -> copy the BOT TOKEN. Enable the privileged",
|
|
522
|
+
" gateway intents: SERVER MEMBERS, MESSAGE CONTENT, PRESENCE.",
|
|
523
|
+
" 3. OAuth2 tab -> copy the CLIENT ID (= application id) and CLIENT",
|
|
524
|
+
" SECRET. Add this exact redirect URL:",
|
|
525
|
+
` ${info.redirectUri}`,
|
|
526
|
+
" 4. General Information tab -> copy the PUBLIC KEY.",
|
|
527
|
+
"",
|
|
528
|
+
"The interactions endpoint is wired for you (you do NOT paste it):",
|
|
529
|
+
` ${info.interactionsUrl}`
|
|
530
|
+
].join("\n");
|
|
531
|
+
}
|
|
532
|
+
async function runConnectDiscord(deps, opts) {
|
|
533
|
+
const base = deps.http.cfg.baseUrl;
|
|
534
|
+
const info = await deps.out.step(
|
|
535
|
+
`GET ${base}/v1/admin/connectors/discord/connect-info`,
|
|
536
|
+
() => deps.http.get(
|
|
537
|
+
"/v1/admin/connectors/discord/connect-info"
|
|
538
|
+
)
|
|
539
|
+
);
|
|
540
|
+
if (opts.statusOnly) {
|
|
541
|
+
deps.out.note(
|
|
542
|
+
[
|
|
543
|
+
"Discord connection status",
|
|
544
|
+
` instance ${base}`,
|
|
545
|
+
` secrets stored ${info.credentialStored ? "yes" : "no"}`,
|
|
546
|
+
` interactions ${info.botInstalled ? "wired" : "not wired"}`,
|
|
547
|
+
` guild id ${info.guildId ?? "(not yet captured)"}`,
|
|
548
|
+
` ingress secret ${info.ingressSecretConfigured ? "set" : "unset"}`,
|
|
549
|
+
` install url ${info.installUrl ?? "(stored secrets first)"}`
|
|
550
|
+
].join("\n")
|
|
551
|
+
);
|
|
552
|
+
return {
|
|
553
|
+
verdict: info.botInstalled ? "connected" : "secrets_stored_not_wired",
|
|
554
|
+
providerId: "discord",
|
|
555
|
+
instance: base,
|
|
556
|
+
secretsStored: info.credentialStored,
|
|
557
|
+
wired: info.botInstalled,
|
|
558
|
+
// The SERVER-MINTED install URL (carries a valid signed state). Null until
|
|
559
|
+
// the secrets are stored — there's nothing for the operator to open yet.
|
|
560
|
+
botInstallUrl: info.installUrl,
|
|
561
|
+
guildId: info.guildId
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
deps.out.note(portalSteps(info), "Discord portal");
|
|
565
|
+
if (!deps.interactive) {
|
|
566
|
+
throw new ConnectDiscordError(
|
|
567
|
+
"not_configured",
|
|
568
|
+
"Discord connect needs the four portal values pasted interactively",
|
|
569
|
+
HINT_NOT_CONFIGURED
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const secrets = await deps.promptSecrets();
|
|
573
|
+
if (!secrets.appId.trim() || !secrets.publicKey.trim() || !secrets.botToken.trim() || !secrets.clientSecret.trim()) {
|
|
574
|
+
throw new ConnectDiscordError(
|
|
575
|
+
"paste_aborted",
|
|
576
|
+
"all four values (application id, public key, bot token, client secret) are required"
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
await deps.out.step(
|
|
581
|
+
`PUT ${base}/v1/admin/connectors/discord/secrets`,
|
|
582
|
+
() => deps.http.put("/v1/admin/connectors/discord/secrets", {
|
|
583
|
+
appId: secrets.appId.trim(),
|
|
584
|
+
publicKey: secrets.publicKey.trim(),
|
|
585
|
+
botToken: secrets.botToken.trim(),
|
|
586
|
+
clientSecret: secrets.clientSecret.trim()
|
|
587
|
+
})
|
|
588
|
+
);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
throw new ConnectDiscordError("store_failed", errMsg(err));
|
|
591
|
+
}
|
|
592
|
+
const stored = await deps.out.step(
|
|
593
|
+
"Reading the server-minted install URL",
|
|
594
|
+
() => deps.http.get(
|
|
595
|
+
"/v1/admin/connectors/discord/connect-info"
|
|
596
|
+
)
|
|
597
|
+
);
|
|
598
|
+
const botInstallUrl = stored.installUrl;
|
|
599
|
+
if (isLoopbackUrl(info.apiPublicUrl)) {
|
|
600
|
+
deps.out.note(
|
|
601
|
+
`Secrets stored, but this instance's API_PUBLIC_URL is ${info.apiPublicUrl} (a loopback address). Discord cannot reach the interactions endpoint, so wiring was skipped.
|
|
602
|
+
|
|
603
|
+
Wire it against your deployed instance:
|
|
604
|
+
|
|
605
|
+
hogsend connect discord --url https://your-instance`,
|
|
606
|
+
"Instance not publicly reachable"
|
|
607
|
+
);
|
|
608
|
+
return {
|
|
609
|
+
verdict: "secrets_stored_not_wired",
|
|
610
|
+
providerId: "discord",
|
|
611
|
+
instance: base,
|
|
612
|
+
secretsStored: true,
|
|
613
|
+
wired: false,
|
|
614
|
+
botInstallUrl,
|
|
615
|
+
guildId: info.guildId
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
await deps.out.step(
|
|
620
|
+
`POST ${base}/v1/admin/connectors/discord/wire`,
|
|
621
|
+
() => deps.http.post("/v1/admin/connectors/discord/wire", {})
|
|
622
|
+
);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
if (isHttpError(err) && err.status === 409 && httpErrorBody(err) === "api_public_url_unreachable") {
|
|
625
|
+
throw new ConnectDiscordError(
|
|
626
|
+
"api_public_url_unreachable",
|
|
627
|
+
`API_PUBLIC_URL is ${info.apiPublicUrl} \u2014 Discord cannot PING the interactions endpoint`,
|
|
628
|
+
HINT_LOOPBACK
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
throw new ConnectDiscordError("wire_failed", errMsg(err));
|
|
632
|
+
}
|
|
633
|
+
let opened = false;
|
|
634
|
+
if (botInstallUrl) {
|
|
635
|
+
deps.out.note(
|
|
636
|
+
["Add the bot to your server", ` ${botInstallUrl}`].join("\n")
|
|
637
|
+
);
|
|
638
|
+
opened = opts.noBrowser ? false : deps.openBrowser(botInstallUrl);
|
|
639
|
+
deps.out.log(
|
|
640
|
+
opened ? "Opening your browser to install the bot. Approve the install in your server, then this command finishes capturing the guild id." : "Open this URL in a browser to install the bot into your server:"
|
|
641
|
+
);
|
|
642
|
+
deps.out.log(` ${botInstallUrl}`);
|
|
643
|
+
} else {
|
|
644
|
+
deps.out.log(
|
|
645
|
+
"Secrets stored, but the server returned no install URL. Re-run `hogsend connect discord --status` to read it."
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
let guildId = stored.guildId;
|
|
649
|
+
if (!guildId && !opts.noBrowser) {
|
|
650
|
+
const proceed = await deps.confirm(
|
|
651
|
+
"Once you've approved the bot install in your browser, press Enter to capture the guild id (or skip and re-run with --status later)"
|
|
652
|
+
);
|
|
653
|
+
if (proceed) {
|
|
654
|
+
try {
|
|
655
|
+
const refreshed = await deps.out.step(
|
|
656
|
+
"Capturing the installed guild id",
|
|
657
|
+
() => deps.http.get(
|
|
658
|
+
"/v1/admin/connectors/discord/connect-info"
|
|
659
|
+
)
|
|
660
|
+
);
|
|
661
|
+
guildId = refreshed.guildId;
|
|
662
|
+
} catch {
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (!guildId) {
|
|
667
|
+
deps.out.log(
|
|
668
|
+
"Bot install not yet detected. Complete it in your browser, then run `hogsend connect discord --status` to capture the guild id."
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
verdict: "connected",
|
|
673
|
+
providerId: "discord",
|
|
674
|
+
instance: base,
|
|
675
|
+
secretsStored: true,
|
|
676
|
+
wired: true,
|
|
677
|
+
botInstallUrl,
|
|
678
|
+
guildId
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
var httpErrorBody = (err) => {
|
|
682
|
+
if (!isHttpError(err)) return void 0;
|
|
683
|
+
const body = err.body;
|
|
684
|
+
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") {
|
|
685
|
+
return body.error;
|
|
686
|
+
}
|
|
687
|
+
return void 0;
|
|
688
|
+
};
|
|
689
|
+
|
|
494
690
|
// src/lib/loopback.ts
|
|
495
691
|
import { createServer } from "http";
|
|
496
692
|
|
|
@@ -776,7 +972,7 @@ var ConnectError = class extends Error {
|
|
|
776
972
|
this.hint = hint;
|
|
777
973
|
}
|
|
778
974
|
};
|
|
779
|
-
var
|
|
975
|
+
var HINT_NOT_CONFIGURED2 = "Pass --posthog-host https://eu.posthog.com (or https://us.posthog.com, or your self-hosted app URL) to pick the region to authorize against. Alternatively set POSTHOG_HOST on the instance, redeploy, then re-run.";
|
|
780
976
|
var POSTHOG_EU_HOST = "https://eu.posthog.com";
|
|
781
977
|
var POSTHOG_US_HOST = "https://us.posthog.com";
|
|
782
978
|
var normalizeHost = (host) => host.replace(/\/+$/, "");
|
|
@@ -792,7 +988,7 @@ var SSH_NOTE = `The consent page must open in a browser on THIS machine \u2014 t
|
|
|
792
988
|
returns to 127.0.0.1 here. On a remote/SSH session this cannot complete: run
|
|
793
989
|
the command from your laptop instead and point --url at the instance (the CLI
|
|
794
990
|
never needs to run on the server).`;
|
|
795
|
-
function
|
|
991
|
+
function isLoopbackUrl2(publicUrl) {
|
|
796
992
|
try {
|
|
797
993
|
const host = new URL(publicUrl).hostname.toLowerCase();
|
|
798
994
|
return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "[::1]" || host === "::1" || host.endsWith(".localhost");
|
|
@@ -807,8 +1003,8 @@ skipped (a destination pointing at localhost would be unreachable).
|
|
|
807
1003
|
Once deployed, wire the loop against the real instance:
|
|
808
1004
|
|
|
809
1005
|
hogsend connect posthog --provision-only --url https://your-instance`;
|
|
810
|
-
var
|
|
811
|
-
var
|
|
1006
|
+
var errMsg2 = (err) => err instanceof Error ? err.message : String(err);
|
|
1007
|
+
var httpErrorBody2 = (err) => {
|
|
812
1008
|
if (!isHttpError(err)) return void 0;
|
|
813
1009
|
const body = err.body;
|
|
814
1010
|
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") {
|
|
@@ -840,7 +1036,7 @@ function fromLoopbackError(err) {
|
|
|
840
1036
|
}
|
|
841
1037
|
}
|
|
842
1038
|
async function runProvisionOnly(deps, info, base) {
|
|
843
|
-
if (
|
|
1039
|
+
if (isLoopbackUrl2(info.apiPublicUrl)) {
|
|
844
1040
|
deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
|
|
845
1041
|
throw new ConnectError(
|
|
846
1042
|
"api_public_url_unreachable",
|
|
@@ -857,14 +1053,14 @@ async function runProvisionOnly(deps, info, base) {
|
|
|
857
1053
|
)
|
|
858
1054
|
);
|
|
859
1055
|
} catch (err) {
|
|
860
|
-
if (isHttpError(err) && err.status === 409 &&
|
|
1056
|
+
if (isHttpError(err) && err.status === 409 && httpErrorBody2(err) === "no_posthog_credential") {
|
|
861
1057
|
throw new ConnectError(
|
|
862
1058
|
"no_credential",
|
|
863
1059
|
"no PostHog credential is stored on this instance",
|
|
864
1060
|
"run `hogsend connect posthog` first"
|
|
865
1061
|
);
|
|
866
1062
|
}
|
|
867
|
-
throw new ConnectError("provision_failed",
|
|
1063
|
+
throw new ConnectError("provision_failed", errMsg2(err));
|
|
868
1064
|
}
|
|
869
1065
|
printProvisioned(deps.out, result);
|
|
870
1066
|
return {
|
|
@@ -911,7 +1107,7 @@ async function resolvePrivateHost(deps, info, opts) {
|
|
|
911
1107
|
throw new ConnectError(
|
|
912
1108
|
"not_configured",
|
|
913
1109
|
"this instance has no PostHog configuration",
|
|
914
|
-
|
|
1110
|
+
HINT_NOT_CONFIGURED2
|
|
915
1111
|
);
|
|
916
1112
|
}
|
|
917
1113
|
async function runConnectPosthog(deps, opts) {
|
|
@@ -1033,7 +1229,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1033
1229
|
})
|
|
1034
1230
|
);
|
|
1035
1231
|
} catch (err) {
|
|
1036
|
-
throw new ConnectError("exchange_failed",
|
|
1232
|
+
throw new ConnectError("exchange_failed", errMsg2(err));
|
|
1037
1233
|
}
|
|
1038
1234
|
const expiresAt = new Date(
|
|
1039
1235
|
deps.now().getTime() + tokens.expires_in * 1e3
|
|
@@ -1059,7 +1255,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1059
1255
|
})
|
|
1060
1256
|
);
|
|
1061
1257
|
} catch (err) {
|
|
1062
|
-
throw new ConnectError("store_failed",
|
|
1258
|
+
throw new ConnectError("store_failed", errMsg2(err));
|
|
1063
1259
|
}
|
|
1064
1260
|
const stored = {
|
|
1065
1261
|
providerId: "posthog",
|
|
@@ -1087,7 +1283,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1087
1283
|
provision: { attempted: false, skipped: "no_provision_flag" }
|
|
1088
1284
|
};
|
|
1089
1285
|
}
|
|
1090
|
-
if (
|
|
1286
|
+
if (isLoopbackUrl2(info.apiPublicUrl)) {
|
|
1091
1287
|
deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
|
|
1092
1288
|
return {
|
|
1093
1289
|
verdict: "connected_no_provision",
|
|
@@ -1116,7 +1312,7 @@ async function runConnectPosthog(deps, opts) {
|
|
|
1116
1312
|
}
|
|
1117
1313
|
};
|
|
1118
1314
|
} catch (err) {
|
|
1119
|
-
const message =
|
|
1315
|
+
const message = errMsg2(err);
|
|
1120
1316
|
deps.out.log(
|
|
1121
1317
|
`The credential is stored, but provisioning the event loop failed: ${message}. Re-run with: hogsend connect posthog --provision-only`
|
|
1122
1318
|
);
|
|
@@ -1139,20 +1335,26 @@ function bail(value) {
|
|
|
1139
1335
|
}
|
|
1140
1336
|
|
|
1141
1337
|
// src/commands/connect.ts
|
|
1142
|
-
var usage2 = `hogsend connect <provider> [
|
|
1338
|
+
var usage2 = `hogsend connect <provider> [options] [--no-browser] [--json]
|
|
1143
1339
|
|
|
1144
|
-
Connect this Hogsend instance to
|
|
1340
|
+
Connect this Hogsend instance to a provider. Providers:
|
|
1145
1341
|
|
|
1146
1342
|
posthog Authorize Hogsend against your PostHog region (PKCE, loopback
|
|
1147
1343
|
callback on 127.0.0.1), store the refresh token on the instance,
|
|
1148
1344
|
then provision the PostHog -> Hogsend event loop (a PostHog
|
|
1149
1345
|
destination posting to /v1/webhooks/posthog).
|
|
1150
1346
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1347
|
+
discord One-time Discord developer-portal setup: paste the four portal
|
|
1348
|
+
values (application id, public key, bot token, client secret),
|
|
1349
|
+
store them on the instance, wire the interactions endpoint
|
|
1350
|
+
(PATCH /applications/@me) server-side, then open the one-click
|
|
1351
|
+
bot-install link and capture the guild id.
|
|
1154
1352
|
|
|
1155
|
-
|
|
1353
|
+
For posthog, the browser consent must happen on THIS machine (the OAuth
|
|
1354
|
+
callback lands on 127.0.0.1). The target instance can be anywhere \u2014 point --url
|
|
1355
|
+
at it and run this command from your laptop, not from an SSH session.
|
|
1356
|
+
|
|
1357
|
+
posthog options:
|
|
1156
1358
|
--posthog-host PostHog app/private host to authorize against, e.g.
|
|
1157
1359
|
https://eu.posthog.com or https://us.posthog.com (NOT the
|
|
1158
1360
|
i. ingestion host). Required when the instance has no
|
|
@@ -1160,11 +1362,17 @@ Options:
|
|
|
1160
1362
|
--provision-only Skip OAuth; (re-)provision the event loop using the
|
|
1161
1363
|
already-stored credential.
|
|
1162
1364
|
--no-provision Stop after storing the credential.
|
|
1163
|
-
|
|
1365
|
+
|
|
1366
|
+
discord options:
|
|
1367
|
+
--status Read-only: report what's stored/wired and the captured
|
|
1368
|
+
guild id. Never prompts or stores anything.
|
|
1369
|
+
|
|
1370
|
+
Shared options:
|
|
1371
|
+
--no-browser Don't spawn a browser; just print the URL(s).
|
|
1164
1372
|
--url, --admin-key, --json, -h, --help Global flags as usual.
|
|
1165
1373
|
|
|
1166
|
-
Exit code: 0 when
|
|
1167
|
-
1 otherwise.`;
|
|
1374
|
+
Exit code: 0 when the connection is stored (even if a follow-up step was
|
|
1375
|
+
skipped), 1 otherwise.`;
|
|
1168
1376
|
async function run2(ctx) {
|
|
1169
1377
|
const { values: values2, positionals } = parseArgs2({
|
|
1170
1378
|
args: ctx.argv,
|
|
@@ -1175,6 +1383,7 @@ async function run2(ctx) {
|
|
|
1175
1383
|
"provision-only": { type: "boolean", default: false },
|
|
1176
1384
|
"no-provision": { type: "boolean", default: false },
|
|
1177
1385
|
"no-browser": { type: "boolean", default: false },
|
|
1386
|
+
status: { type: "boolean", default: false },
|
|
1178
1387
|
help: { type: "boolean", short: "h", default: false }
|
|
1179
1388
|
}
|
|
1180
1389
|
});
|
|
@@ -1186,22 +1395,27 @@ async function run2(ctx) {
|
|
|
1186
1395
|
if (!provider) {
|
|
1187
1396
|
ctx.out.fail("missing provider \u2014 try: hogsend connect posthog");
|
|
1188
1397
|
}
|
|
1398
|
+
if (provider === "discord") {
|
|
1399
|
+
await runDiscord(ctx, {
|
|
1400
|
+
noBrowser: Boolean(values2["no-browser"]),
|
|
1401
|
+
statusOnly: Boolean(values2.status)
|
|
1402
|
+
});
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1189
1405
|
if (provider !== "posthog") {
|
|
1190
|
-
ctx.out.fail(
|
|
1406
|
+
ctx.out.fail(
|
|
1407
|
+
`unknown provider "${provider}" \u2014 supported: posthog, discord`
|
|
1408
|
+
);
|
|
1191
1409
|
}
|
|
1192
1410
|
if (values2["provision-only"] && values2["no-provision"]) {
|
|
1193
1411
|
ctx.out.fail("--provision-only and --no-provision are mutually exclusive");
|
|
1194
1412
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
)
|
|
1202
|
-
);
|
|
1203
|
-
}
|
|
1204
|
-
} catch {
|
|
1413
|
+
if (isPlainHttpRemote(ctx.cfg.baseUrl)) {
|
|
1414
|
+
ctx.out.log(
|
|
1415
|
+
color.yellow(
|
|
1416
|
+
`warning: ${ctx.cfg.baseUrl} is plain http \u2014 OAuth tokens will be sent to it unencrypted; use https for remote instances.`
|
|
1417
|
+
)
|
|
1418
|
+
);
|
|
1205
1419
|
}
|
|
1206
1420
|
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
|
|
1207
1421
|
const deps = {
|
|
@@ -1274,6 +1488,88 @@ async function run2(ctx) {
|
|
|
1274
1488
|
ctx.out.note(
|
|
1275
1489
|
error.hint ? `${error.message}
|
|
1276
1490
|
|
|
1491
|
+
${error.hint}` : error.message
|
|
1492
|
+
);
|
|
1493
|
+
ctx.out.outro(color.red(`connect: ${error.verdict}`));
|
|
1494
|
+
process.exit(1);
|
|
1495
|
+
}
|
|
1496
|
+
throw error;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
function isPlainHttpRemote(baseUrl) {
|
|
1500
|
+
try {
|
|
1501
|
+
const target = new URL(baseUrl);
|
|
1502
|
+
return target.protocol === "http:" && target.hostname !== "localhost" && target.hostname !== "127.0.0.1";
|
|
1503
|
+
} catch {
|
|
1504
|
+
return false;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async function runDiscord(ctx, opts) {
|
|
1508
|
+
if (!opts.statusOnly && isPlainHttpRemote(ctx.cfg.baseUrl)) {
|
|
1509
|
+
ctx.out.fail(
|
|
1510
|
+
`${ctx.cfg.baseUrl} is plain http \u2014 refusing to send the Discord bot token + client secret to a remote instance unencrypted. Use https, or run --status (which sends no secrets).`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
|
|
1514
|
+
const deps = {
|
|
1515
|
+
http: ctx.http,
|
|
1516
|
+
out: ctx.out,
|
|
1517
|
+
interactive: ctx.out.interactive,
|
|
1518
|
+
confirm: async (message) => bail(await confirm({ message })),
|
|
1519
|
+
openBrowser,
|
|
1520
|
+
promptSecrets: async () => {
|
|
1521
|
+
const appId = bail(
|
|
1522
|
+
await text({
|
|
1523
|
+
message: "Discord Application ID (OAuth2 -> Client ID)",
|
|
1524
|
+
placeholder: "1234567890123456789"
|
|
1525
|
+
})
|
|
1526
|
+
);
|
|
1527
|
+
const publicKey = bail(
|
|
1528
|
+
await text({
|
|
1529
|
+
message: "Public Key (General Information -> Public Key)",
|
|
1530
|
+
placeholder: "abc123..."
|
|
1531
|
+
})
|
|
1532
|
+
);
|
|
1533
|
+
const botToken = bail(
|
|
1534
|
+
await password({ message: "Bot Token (Bot -> Reset Token)" })
|
|
1535
|
+
);
|
|
1536
|
+
const clientSecret = bail(
|
|
1537
|
+
await password({
|
|
1538
|
+
message: "Client Secret (OAuth2 -> Client Secret)"
|
|
1539
|
+
})
|
|
1540
|
+
);
|
|
1541
|
+
return { appId, publicKey, botToken, clientSecret };
|
|
1542
|
+
},
|
|
1543
|
+
now: () => /* @__PURE__ */ new Date()
|
|
1544
|
+
};
|
|
1545
|
+
try {
|
|
1546
|
+
const result = await runConnectDiscord(deps, {
|
|
1547
|
+
noBrowser: opts.noBrowser,
|
|
1548
|
+
statusOnly: opts.statusOnly
|
|
1549
|
+
});
|
|
1550
|
+
if (ctx.json) {
|
|
1551
|
+
ctx.out.json({ ok: true, ...result });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
ctx.out.outro(
|
|
1555
|
+
color.green(
|
|
1556
|
+
`connect: discord ${result.verdict === "connected" ? "connected" : "secrets stored (interactions not wired)"}`
|
|
1557
|
+
)
|
|
1558
|
+
);
|
|
1559
|
+
} catch (error) {
|
|
1560
|
+
if (error instanceof ConnectDiscordError) {
|
|
1561
|
+
if (ctx.json) {
|
|
1562
|
+
ctx.out.json({
|
|
1563
|
+
ok: false,
|
|
1564
|
+
verdict: error.verdict,
|
|
1565
|
+
error: error.message,
|
|
1566
|
+
hint: error.hint
|
|
1567
|
+
});
|
|
1568
|
+
process.exit(1);
|
|
1569
|
+
}
|
|
1570
|
+
ctx.out.note(
|
|
1571
|
+
error.hint ? `${error.message}
|
|
1572
|
+
|
|
1277
1573
|
${error.hint}` : error.message
|
|
1278
1574
|
);
|
|
1279
1575
|
ctx.out.outro(color.red(`connect: ${error.verdict}`));
|
|
@@ -1284,7 +1580,7 @@ ${error.hint}` : error.message
|
|
|
1284
1580
|
}
|
|
1285
1581
|
var connectCommand = {
|
|
1286
1582
|
name: "connect",
|
|
1287
|
-
summary: "Connect
|
|
1583
|
+
summary: "Connect a provider (posthog OAuth, discord bot)",
|
|
1288
1584
|
usage: usage2,
|
|
1289
1585
|
run: run2
|
|
1290
1586
|
};
|
|
@@ -4195,12 +4491,12 @@ async function runToken(ctx, argv) {
|
|
|
4195
4491
|
const flags = parseTokenFlags(argv);
|
|
4196
4492
|
const url = flags.url ?? (ctx.cfg.urlExplicit ? ctx.cfg.baseUrl : void 0) ?? process.env.HATCHET_URL;
|
|
4197
4493
|
const email = flags.email ?? process.env.HATCHET_ADMIN_EMAIL;
|
|
4198
|
-
const
|
|
4494
|
+
const password2 = flags.password ?? process.env.HATCHET_ADMIN_PASSWORD;
|
|
4199
4495
|
const missing = [];
|
|
4200
4496
|
if (!url) missing.push("--url (or HATCHET_URL)");
|
|
4201
4497
|
if (!email) missing.push("--email (or HATCHET_ADMIN_EMAIL)");
|
|
4202
|
-
if (!
|
|
4203
|
-
if (missing.length > 0 || !url || !email || !
|
|
4498
|
+
if (!password2) missing.push("--password (or HATCHET_ADMIN_PASSWORD)");
|
|
4499
|
+
if (missing.length > 0 || !url || !email || !password2) {
|
|
4204
4500
|
failToStderr(ctx, `missing ${missing.join(", ")}`);
|
|
4205
4501
|
}
|
|
4206
4502
|
const onProgress = ctx.json ? void 0 : (msg) => process.stderr.write(`${color.dim(msg)}
|
|
@@ -4210,7 +4506,7 @@ async function runToken(ctx, argv) {
|
|
|
4210
4506
|
const result = await mintHatchetToken({
|
|
4211
4507
|
url,
|
|
4212
4508
|
email,
|
|
4213
|
-
password,
|
|
4509
|
+
password: password2,
|
|
4214
4510
|
tenantSlug: flags.tenant,
|
|
4215
4511
|
tokenName: flags.tokenName,
|
|
4216
4512
|
onProgress
|
|
@@ -14124,6 +14420,7 @@ __export(schema_exports, {
|
|
|
14124
14420
|
bucketMemberships: () => bucketMemberships,
|
|
14125
14421
|
bucketMembershipsRelations: () => bucketMembershipsRelations,
|
|
14126
14422
|
campaigns: () => campaigns,
|
|
14423
|
+
connectorLinkCodes: () => connectorLinkCodes,
|
|
14127
14424
|
contactAliases: () => contactAliases,
|
|
14128
14425
|
contactAliasesRelations: () => contactAliasesRelations,
|
|
14129
14426
|
contacts: () => contacts,
|
|
@@ -14536,6 +14833,50 @@ var campaigns = pgTable(
|
|
|
14536
14833
|
]
|
|
14537
14834
|
);
|
|
14538
14835
|
|
|
14836
|
+
// ../db/src/schema/connector-link-codes.ts
|
|
14837
|
+
var connectorLinkCodes = pgTable(
|
|
14838
|
+
"connector_link_codes",
|
|
14839
|
+
{
|
|
14840
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
14841
|
+
// The connector this code belongs to (e.g. "discord"). Scopes throttle
|
|
14842
|
+
// counts + lets one engine serve multiple connectors' link loops.
|
|
14843
|
+
connectorId: text2("connector_id").notNull(),
|
|
14844
|
+
// sha256(code) as lowercase hex — the lookup key. The plaintext code is
|
|
14845
|
+
// NEVER stored; it exists only in the emailed message.
|
|
14846
|
+
codeHash: text2("code_hash").notNull(),
|
|
14847
|
+
// The invoking platform user the code is BOUND to (Discord snowflake). A
|
|
14848
|
+
// redeem must present the SAME platform user id or it is rejected.
|
|
14849
|
+
platformUserId: text2("platform_user_id").notNull(),
|
|
14850
|
+
// The authoritative email the code was issued for (the resolution key the
|
|
14851
|
+
// redeem attaches to the platform identity). Stored normalized
|
|
14852
|
+
// (trim + toLowerCase) by the caller.
|
|
14853
|
+
targetEmail: text2("target_email").notNull(),
|
|
14854
|
+
// Absolute expiry. A redeem after this instant is rejected as expired.
|
|
14855
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
14856
|
+
// Single-use marker: NULL until redeemed, set to the redemption instant by
|
|
14857
|
+
// the atomic `UPDATE ... WHERE used_at IS NULL`. A second redeem misses.
|
|
14858
|
+
usedAt: timestamp("used_at", { withTimezone: true }),
|
|
14859
|
+
...timestamps
|
|
14860
|
+
},
|
|
14861
|
+
(table) => [
|
|
14862
|
+
// Redeem looks the row up by hash. Unique: a hash collision (or a duplicate
|
|
14863
|
+
// mint of the identical code) must never produce two redeemable rows.
|
|
14864
|
+
uniqueIndex("connector_link_codes_code_hash_idx").on(table.codeHash),
|
|
14865
|
+
// Serves the per-invoking-user throttle COUNT (connector + user + recency).
|
|
14866
|
+
index("connector_link_codes_throttle_user_idx").on(
|
|
14867
|
+
table.connectorId,
|
|
14868
|
+
table.platformUserId,
|
|
14869
|
+
table.createdAt
|
|
14870
|
+
),
|
|
14871
|
+
// Serves the per-target-email throttle COUNT (connector + email + recency).
|
|
14872
|
+
index("connector_link_codes_throttle_email_idx").on(
|
|
14873
|
+
table.connectorId,
|
|
14874
|
+
table.targetEmail,
|
|
14875
|
+
table.createdAt
|
|
14876
|
+
)
|
|
14877
|
+
]
|
|
14878
|
+
);
|
|
14879
|
+
|
|
14539
14880
|
// ../db/src/schema/contacts.ts
|
|
14540
14881
|
var contacts = pgTable(
|
|
14541
14882
|
"contacts",
|
|
@@ -14558,6 +14899,16 @@ var contacts = pgTable(
|
|
|
14558
14899
|
* index scoped to live, non-deleted rows.
|
|
14559
14900
|
*/
|
|
14560
14901
|
anonymousId: text2("anonymous_id"),
|
|
14902
|
+
/**
|
|
14903
|
+
* Nullable Discord user id (snowflake) attached to an email-keyed contact
|
|
14904
|
+
* when a member completes the per-member OAuth link. Like external_id it is
|
|
14905
|
+
* a RESOLVABLE identity key (a fourth `Kind`), NOT a property — but it is
|
|
14906
|
+
* NEVER the canonical text key (`external_id ?? anonymous_id ?? id`), so it
|
|
14907
|
+
* does not participate in the history re-point. Uniqueness is the
|
|
14908
|
+
* partial-unique live-row index below, identical to
|
|
14909
|
+
* contacts_external_id_unique_idx.
|
|
14910
|
+
*/
|
|
14911
|
+
discordId: text2("discord_id"),
|
|
14561
14912
|
/**
|
|
14562
14913
|
* Opportunistic IANA-timezone cache (e.g. "America/New_York"). Populated
|
|
14563
14914
|
* best-effort when a tz is resolved from PostHog person props. PostHog and
|
|
@@ -14583,7 +14934,8 @@ var contacts = pgTable(
|
|
|
14583
14934
|
// resolvable identity key. Emails are stored already-normalized (trim +
|
|
14584
14935
|
// toLowerCase), so lower() here is belt-and-suspenders.
|
|
14585
14936
|
uniqueIndex("contacts_email_unique_idx").on(sql`lower(email)`).where(sql`email IS NOT NULL AND deleted_at IS NULL`),
|
|
14586
|
-
uniqueIndex("contacts_anonymous_id_unique_idx").on(table.anonymousId).where(sql`anonymous_id IS NOT NULL AND deleted_at IS NULL`)
|
|
14937
|
+
uniqueIndex("contacts_anonymous_id_unique_idx").on(table.anonymousId).where(sql`anonymous_id IS NOT NULL AND deleted_at IS NULL`),
|
|
14938
|
+
uniqueIndex("contacts_discord_id_unique_idx").on(table.discordId).where(sql`discord_id IS NOT NULL AND deleted_at IS NULL`)
|
|
14587
14939
|
]
|
|
14588
14940
|
);
|
|
14589
14941
|
|
|
@@ -14594,7 +14946,7 @@ var contactAliases = pgTable(
|
|
|
14594
14946
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
14595
14947
|
// The SURVIVOR a stale key resolves TO.
|
|
14596
14948
|
contactId: uuid("contact_id").notNull().references(() => contacts.id, { onDelete: "cascade" }),
|
|
14597
|
-
// 'email' | 'external' | 'anonymous'
|
|
14949
|
+
// 'email' | 'external' | 'anonymous' | 'discord'
|
|
14598
14950
|
aliasKind: text2("alias_kind").notNull(),
|
|
14599
14951
|
// The stale key value (the loser's old external_id / normalized email /
|
|
14600
14952
|
// anonymous_id).
|
|
@@ -14822,7 +15174,23 @@ var trackedLinks = pgTable(
|
|
|
14822
15174
|
"tracked_links",
|
|
14823
15175
|
{
|
|
14824
15176
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
14825
|
-
|
|
15177
|
+
// NULLABLE since the identity-stitching minor: a tracked link no longer has
|
|
15178
|
+
// to belong to an email send. Broadcast/non-email links (Discord, referral,
|
|
15179
|
+
// ad-hoc `createTrackedLink`) carry NULL here. Email-link inserts keep
|
|
15180
|
+
// populating it; the FK + index are unchanged.
|
|
15181
|
+
emailSendId: uuid("email_send_id").references(() => emailSends.id, {
|
|
15182
|
+
onDelete: "cascade"
|
|
15183
|
+
}),
|
|
15184
|
+
// Subject of a stitch-bearing NON-email link: the canonical contact key the
|
|
15185
|
+
// click should fold the visitor's anon session into. NULL for broadcast
|
|
15186
|
+
// links (Discord/referral default) — broadcast links are tracked for click
|
|
15187
|
+
// counts but carry no identity. Email links resolve their subject from the
|
|
15188
|
+
// `email_sends` row instead, so this stays NULL for them too.
|
|
15189
|
+
distinctId: text2("distinct_id"),
|
|
15190
|
+
// Where the link originated: "email" | "discord" | "link". Drives the click
|
|
15191
|
+
// route's per-hit outbound emit (email links emit `email.clicked`; non-email
|
|
15192
|
+
// links emit `link.clicked`). NULL on legacy/email rows.
|
|
15193
|
+
source: text2("source"),
|
|
14826
15194
|
originalUrl: text2("original_url").notNull(),
|
|
14827
15195
|
clickCount: integer("click_count").notNull().default(0),
|
|
14828
15196
|
// Semantic link metadata, lifted from the template's data-hs-* attributes
|
|
@@ -15233,14 +15601,14 @@ var AdminAlreadyExistsError = class extends Error {
|
|
|
15233
15601
|
email;
|
|
15234
15602
|
};
|
|
15235
15603
|
async function createAdminUser(opts) {
|
|
15236
|
-
const { auth, email, password } = opts;
|
|
15604
|
+
const { auth, email, password: password2 } = opts;
|
|
15237
15605
|
const displayName = opts.name ?? email.split("@")[0] ?? email;
|
|
15238
15606
|
const ctx = await auth.$context;
|
|
15239
15607
|
const existing = await ctx.internalAdapter.findUserByEmail(email);
|
|
15240
15608
|
if (existing) {
|
|
15241
15609
|
throw new AdminAlreadyExistsError(email);
|
|
15242
15610
|
}
|
|
15243
|
-
const hashed = await ctx.password.hash(
|
|
15611
|
+
const hashed = await ctx.password.hash(password2);
|
|
15244
15612
|
const created = await ctx.internalAdapter.createUser({
|
|
15245
15613
|
email,
|
|
15246
15614
|
name: displayName,
|
|
@@ -15282,9 +15650,9 @@ function createAdminRecovery(opts) {
|
|
|
15282
15650
|
baseURL: opts.baseURL ?? "http://localhost:3002"
|
|
15283
15651
|
});
|
|
15284
15652
|
return {
|
|
15285
|
-
async create({ email, password, name }) {
|
|
15653
|
+
async create({ email, password: password2, name }) {
|
|
15286
15654
|
try {
|
|
15287
|
-
return await createAdminUser({ auth, email, name, password });
|
|
15655
|
+
return await createAdminUser({ auth, email, name, password: password2 });
|
|
15288
15656
|
} catch (err) {
|
|
15289
15657
|
if (err instanceof AdminAlreadyExistsError) {
|
|
15290
15658
|
throw new Error(err.message);
|
|
@@ -15295,7 +15663,7 @@ function createAdminRecovery(opts) {
|
|
|
15295
15663
|
throw err;
|
|
15296
15664
|
}
|
|
15297
15665
|
},
|
|
15298
|
-
async reset({ email, password, revokeSessions }) {
|
|
15666
|
+
async reset({ email, password: password2, revokeSessions }) {
|
|
15299
15667
|
const ctx = await auth.$context;
|
|
15300
15668
|
const found = await ctx.internalAdapter.findUserByEmail(email, {
|
|
15301
15669
|
includeAccounts: true
|
|
@@ -15305,7 +15673,7 @@ function createAdminRecovery(opts) {
|
|
|
15305
15673
|
`No admin with email "${email}". Use \`hogsend studio admin create\` to create one.`
|
|
15306
15674
|
);
|
|
15307
15675
|
}
|
|
15308
|
-
const hashed = await ctx.password.hash(
|
|
15676
|
+
const hashed = await ctx.password.hash(password2);
|
|
15309
15677
|
const hasCredential = found.accounts?.some(
|
|
15310
15678
|
(a) => a.providerId === "credential"
|
|
15311
15679
|
);
|
|
@@ -15963,6 +16331,7 @@ var WEBHOOK_EVENT_TYPES = [
|
|
|
15963
16331
|
"email.action",
|
|
15964
16332
|
"email.bounced",
|
|
15965
16333
|
"email.complained",
|
|
16334
|
+
"link.clicked",
|
|
15966
16335
|
"journey.completed",
|
|
15967
16336
|
"bucket.entered",
|
|
15968
16337
|
"bucket.left"
|