File
Blob: src/worker/db/schema/invites.ts
| 1 | import { sql } from "drizzle-orm"; |
| 2 | import { 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. |
| 15 | export 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 | ); |