@hogsend/db 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.
@@ -176,6 +176,20 @@
176
176
  "when": 1781282015159,
177
177
  "tag": "0024_provider_credentials",
178
178
  "breakpoints": true
179
+ },
180
+ {
181
+ "idx": 25,
182
+ "version": "7",
183
+ "when": 1781371526854,
184
+ "tag": "0025_contact_discord_id",
185
+ "breakpoints": true
186
+ },
187
+ {
188
+ "idx": 26,
189
+ "version": "7",
190
+ "when": 1781382218591,
191
+ "tag": "0026_warm_freak",
192
+ "breakpoints": true
179
193
  }
180
194
  ]
181
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/db",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,68 @@
1
+ import {
2
+ index,
3
+ pgTable,
4
+ text,
5
+ timestamp,
6
+ uniqueIndex,
7
+ uuid,
8
+ } from "drizzle-orm/pg-core";
9
+ import { timestamps } from "./_shared.js";
10
+
11
+ /**
12
+ * Single-use verification codes for the native in-connector identify loop
13
+ * (Discord `/link <email>` → emailed code → `/verify <code>`). A member runs
14
+ * `/link`, we mint a code, email it via Hogsend, and store ONLY a sha256 hash
15
+ * of the code here (never the plaintext — the code lives only in the member's
16
+ * inbox). `/verify` re-hashes the typed code, looks the row up by hash, and
17
+ * redeems it: single-use (`used_at IS NULL` guard), TTL'd (`expires_at`), and
18
+ * BOUND to the invoking platform user (`platform_user_id`, constant-time
19
+ * compared) so a code is only valid for the same account that requested it.
20
+ *
21
+ * The `connector_id` + `target_email` columns also back the anti-email-bomb
22
+ * throttle: the create path counts recent rows per invoking user AND per target
23
+ * email within a rolling window and refuses to mint (and send) once either cap
24
+ * is hit — counting on MINT (rows are never deleted on redeem/expiry, only
25
+ * marked used / left to age out) is what makes it an email-bomb control.
26
+ */
27
+ export const connectorLinkCodes = pgTable(
28
+ "connector_link_codes",
29
+ {
30
+ id: uuid("id").defaultRandom().primaryKey(),
31
+ // The connector this code belongs to (e.g. "discord"). Scopes throttle
32
+ // counts + lets one engine serve multiple connectors' link loops.
33
+ connectorId: text("connector_id").notNull(),
34
+ // sha256(code) as lowercase hex — the lookup key. The plaintext code is
35
+ // NEVER stored; it exists only in the emailed message.
36
+ codeHash: text("code_hash").notNull(),
37
+ // The invoking platform user the code is BOUND to (Discord snowflake). A
38
+ // redeem must present the SAME platform user id or it is rejected.
39
+ platformUserId: text("platform_user_id").notNull(),
40
+ // The authoritative email the code was issued for (the resolution key the
41
+ // redeem attaches to the platform identity). Stored normalized
42
+ // (trim + toLowerCase) by the caller.
43
+ targetEmail: text("target_email").notNull(),
44
+ // Absolute expiry. A redeem after this instant is rejected as expired.
45
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
46
+ // Single-use marker: NULL until redeemed, set to the redemption instant by
47
+ // the atomic `UPDATE ... WHERE used_at IS NULL`. A second redeem misses.
48
+ usedAt: timestamp("used_at", { withTimezone: true }),
49
+ ...timestamps,
50
+ },
51
+ (table) => [
52
+ // Redeem looks the row up by hash. Unique: a hash collision (or a duplicate
53
+ // mint of the identical code) must never produce two redeemable rows.
54
+ uniqueIndex("connector_link_codes_code_hash_idx").on(table.codeHash),
55
+ // Serves the per-invoking-user throttle COUNT (connector + user + recency).
56
+ index("connector_link_codes_throttle_user_idx").on(
57
+ table.connectorId,
58
+ table.platformUserId,
59
+ table.createdAt,
60
+ ),
61
+ // Serves the per-target-email throttle COUNT (connector + email + recency).
62
+ index("connector_link_codes_throttle_email_idx").on(
63
+ table.connectorId,
64
+ table.targetEmail,
65
+ table.createdAt,
66
+ ),
67
+ ],
68
+ );
@@ -18,7 +18,7 @@ export const contactAliases = pgTable(
18
18
  contactId: uuid("contact_id")
19
19
  .notNull()
20
20
  .references(() => contacts.id, { onDelete: "cascade" }),
21
- // 'email' | 'external' | 'anonymous'
21
+ // 'email' | 'external' | 'anonymous' | 'discord'
22
22
  aliasKind: text("alias_kind").notNull(),
23
23
  // The stale key value (the loser's old external_id / normalized email /
24
24
  // anonymous_id).
@@ -31,6 +31,16 @@ export const contacts = pgTable(
31
31
  * index scoped to live, non-deleted rows.
32
32
  */
33
33
  anonymousId: text("anonymous_id"),
34
+ /**
35
+ * Nullable Discord user id (snowflake) attached to an email-keyed contact
36
+ * when a member completes the per-member OAuth link. Like external_id it is
37
+ * a RESOLVABLE identity key (a fourth `Kind`), NOT a property — but it is
38
+ * NEVER the canonical text key (`external_id ?? anonymous_id ?? id`), so it
39
+ * does not participate in the history re-point. Uniqueness is the
40
+ * partial-unique live-row index below, identical to
41
+ * contacts_external_id_unique_idx.
42
+ */
43
+ discordId: text("discord_id"),
34
44
  /**
35
45
  * Opportunistic IANA-timezone cache (e.g. "America/New_York"). Populated
36
46
  * best-effort when a tz is resolved from PostHog person props. PostHog and
@@ -69,5 +79,8 @@ export const contacts = pgTable(
69
79
  uniqueIndex("contacts_anonymous_id_unique_idx")
70
80
  .on(table.anonymousId)
71
81
  .where(sql`anonymous_id IS NOT NULL AND deleted_at IS NULL`),
82
+ uniqueIndex("contacts_discord_id_unique_idx")
83
+ .on(table.discordId)
84
+ .where(sql`discord_id IS NOT NULL AND deleted_at IS NULL`),
72
85
  ],
73
86
  );
@@ -6,6 +6,7 @@ export * from "./auth.js";
6
6
  export * from "./bucket-configs.js";
7
7
  export * from "./bucket-memberships.js";
8
8
  export * from "./campaigns.js";
9
+ export * from "./connector-link-codes.js";
9
10
  export * from "./contact-aliases.js";
10
11
  export * from "./contacts.js";
11
12
  export * from "./dead-letter-queue.js";