Skip to content
File

Blob: src/worker/db/schema/invites.ts

typescript40 lines
1import { sql } from "drizzle-orm";
2import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
3 
4// `email` is required: tessera asserts `email_verified=true` on every new
5// user (see auth/index.ts databaseHooks.user.create.before), and that claim
6// is only honest when the invite address itself proves email ownership.
7//
8// Single-use consume is enforced by a CAS in api/invites.ts:
9// `UPDATE invites SET consumed_at = ?
10// WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?`.
11// Both placeholders are JS ISO timestamps so lexicographic comparison
12// matches chronological order.
13// SQLite serializes writers; one request sets the column and concurrent
14// callers see zero affected rows.
15export const invites = sqliteTable(
16 "invites",
17 {
18 id: text("id").primaryKey(),
19 tokenHash: text("token_hash").notNull(),
20 email: text("email").notNull(),
21 createdBy: text("created_by").notNull(),
22 createdAt: text("created_at")
23 .notNull()
24 .default(sql`(current_timestamp)`),
25 expiresAt: text("expires_at").notNull(),
26 consumedAt: text("consumed_at"),
27 },
28 (table) => [
29 uniqueIndex("idx_invites_token_hash").on(table.tokenHash),
30 // One invite row per email, consumed or not. Admins must DELETE
31 // before re-issuing for the same address; the application also
32 // refuses minting when a `users` row with that email already exists
33 // (see `handleCreateInvite`). Defense-in-depth against TOCTOU
34 // between two concurrent admin mints.
35 uniqueIndex("idx_invites_email").on(table.email),
36 index("idx_invites_created_by_created_at").on(table.createdBy, table.createdAt),
37 index("idx_invites_expires_at").on(table.expiresAt),
38 ],
39);