@hogsend/cli 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 HINT_NOT_CONFIGURED = "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.";
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 isLoopbackUrl(publicUrl) {
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 errMsg = (err) => err instanceof Error ? err.message : String(err);
811
- var httpErrorBody = (err) => {
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 (isLoopbackUrl(info.apiPublicUrl)) {
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 && httpErrorBody(err) === "no_posthog_credential") {
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", errMsg(err));
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
- HINT_NOT_CONFIGURED
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", errMsg(err));
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", errMsg(err));
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 (isLoopbackUrl(info.apiPublicUrl)) {
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 = errMsg(err);
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> [--posthog-host <url>] [--provision-only] [--no-provision] [--no-browser] [--json]
1338
+ var usage2 = `hogsend connect <provider> [options] [--no-browser] [--json]
1143
1339
 
1144
- Connect this Hogsend instance to an analytics provider via OAuth. Providers:
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
- The browser consent must happen on THIS machine (the OAuth callback lands on
1152
- 127.0.0.1). The target instance can be anywhere \u2014 point --url at it and run
1153
- this command from your laptop, not from an SSH session on the server.
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
- Options:
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
- --no-browser Don't spawn a browser; just print the authorize URL.
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 a credential is stored (even if provisioning was skipped),
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(`unknown provider "${provider}" \u2014 supported: posthog`);
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
- try {
1196
- const target = new URL(ctx.cfg.baseUrl);
1197
- if (target.protocol === "http:" && target.hostname !== "localhost" && target.hostname !== "127.0.0.1") {
1198
- ctx.out.log(
1199
- color.yellow(
1200
- `warning: ${ctx.cfg.baseUrl} is plain http \u2014 OAuth tokens will be sent to it unencrypted; use https for remote instances.`
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 an analytics provider via OAuth (posthog)",
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 password = flags.password ?? process.env.HATCHET_ADMIN_PASSWORD;
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 (!password) missing.push("--password (or HATCHET_ADMIN_PASSWORD)");
4203
- if (missing.length > 0 || !url || !email || !password) {
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).
@@ -15233,14 +15585,14 @@ var AdminAlreadyExistsError = class extends Error {
15233
15585
  email;
15234
15586
  };
15235
15587
  async function createAdminUser(opts) {
15236
- const { auth, email, password } = opts;
15588
+ const { auth, email, password: password2 } = opts;
15237
15589
  const displayName = opts.name ?? email.split("@")[0] ?? email;
15238
15590
  const ctx = await auth.$context;
15239
15591
  const existing = await ctx.internalAdapter.findUserByEmail(email);
15240
15592
  if (existing) {
15241
15593
  throw new AdminAlreadyExistsError(email);
15242
15594
  }
15243
- const hashed = await ctx.password.hash(password);
15595
+ const hashed = await ctx.password.hash(password2);
15244
15596
  const created = await ctx.internalAdapter.createUser({
15245
15597
  email,
15246
15598
  name: displayName,
@@ -15282,9 +15634,9 @@ function createAdminRecovery(opts) {
15282
15634
  baseURL: opts.baseURL ?? "http://localhost:3002"
15283
15635
  });
15284
15636
  return {
15285
- async create({ email, password, name }) {
15637
+ async create({ email, password: password2, name }) {
15286
15638
  try {
15287
- return await createAdminUser({ auth, email, name, password });
15639
+ return await createAdminUser({ auth, email, name, password: password2 });
15288
15640
  } catch (err) {
15289
15641
  if (err instanceof AdminAlreadyExistsError) {
15290
15642
  throw new Error(err.message);
@@ -15295,7 +15647,7 @@ function createAdminRecovery(opts) {
15295
15647
  throw err;
15296
15648
  }
15297
15649
  },
15298
- async reset({ email, password, revokeSessions }) {
15650
+ async reset({ email, password: password2, revokeSessions }) {
15299
15651
  const ctx = await auth.$context;
15300
15652
  const found = await ctx.internalAdapter.findUserByEmail(email, {
15301
15653
  includeAccounts: true
@@ -15305,7 +15657,7 @@ function createAdminRecovery(opts) {
15305
15657
  `No admin with email "${email}". Use \`hogsend studio admin create\` to create one.`
15306
15658
  );
15307
15659
  }
15308
- const hashed = await ctx.password.hash(password);
15660
+ const hashed = await ctx.password.hash(password2);
15309
15661
  const hasCredential = found.accounts?.some(
15310
15662
  (a) => a.providerId === "credential"
15311
15663
  );