Skip to main content

cratefield_module_waitlist/
lib.rs

1//! `cratefield-module-waitlist`: per-product waitlist with confirm,
2//! position, referral codes and status (issue #11, architecture section
3//! 6).
4//!
5//! ```no_run
6//! use cratefield_module_waitlist::Waitlist;
7//!
8//! let module = Waitlist::new()
9//!     .products(["kontinuum", "undercover-rockstars"])
10//!     .confirm_ttl_days(7)
11//!     .referrals(true);
12//! ```
13//!
14//! **Positions are atomic.** Confirmation reads the product's position
15//! from a per-product counter on `waitlist_position_lock` that the
16//! lock-taking UPDATE increments in the same statement, and that UPDATE
17//! plus the flip and the referrer credit run in one
18//! [`cratefield_core::Database::batch_atomic`] — atomic on D1, a
19//! transaction on the sqlite and Postgres adapters — so concurrent
20//! confirms of one product serialize on every engine and a UNIQUE
21//! (product, position) index means a duplicate position cannot survive
22//! even if the allocation raced (issues #20, #173, #126). Positions are
23//! dense at assign time per product
24//! and never recomputed on delete.
25//!
26//! **No enumeration** (section 11): `POST /v1/waitlist` answers the same
27//! `202 {"ok":true}` bytes whatever the row state; an unknown `ref` code
28//! is silently ignored.
29
30#![doc = include_str!("../README.md")]
31#![forbid(unsafe_code)]
32
33mod handlers;
34mod mail;
35mod store;
36
37pub use mail::{ConfirmMailData, ConfirmedMailData, default_templates, themed_templates};
38
39use cratefield_core::{
40    AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
41    ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration,
42};
43use serde_json::Value;
44use std::sync::Arc;
45
46use handlers::{Products, Settings};
47
48/// A venture's validation for the free-form `answers` field of a join.
49/// `Err(detail)` becomes a `400 validation-failed` problem.
50pub type AnswersSchema = Arc<dyn Fn(&Value) -> Result<(), String> + Send + Sync>;
51
52fn accept_any_answers() -> AnswersSchema {
53    Arc::new(|_answers| Ok(()))
54}
55
56/// The module's migrations: the `waitlist_entries` table in the portable
57/// SQL subset (issue #11), plus the entry generation that confirm tokens
58/// bind to (issue #127).
59const MIGRATION_INIT: SqlMigration = SqlMigration::new(
60    "0001",
61    "init",
62    include_str!("../migrations/sqlite/0001_init.sql"),
63);
64
65const MIGRATION_ENTRY_GENERATION: SqlMigration = SqlMigration::new(
66    "0002",
67    "entry_generation",
68    include_str!("../migrations/sqlite/0002_entry_generation.sql"),
69);
70
71/// The durable send-cooldown table backing the one-mail-per-window claim
72/// (issue #133). Name must match `handlers::SEND_COOLDOWN_TABLE`.
73const MIGRATION_MAIL_COOLDOWN: SqlMigration = SqlMigration::new(
74    "0003",
75    "mail_cooldown",
76    include_str!("../migrations/sqlite/0003_mail_cooldown.sql"),
77);
78
79/// The single-row per-product position mutex used by `confirm_entry`
80/// (issue #173). Name must match the table in `store::confirm_entry`.
81const MIGRATION_POSITION_LOCK: SqlMigration = SqlMigration::new(
82    "0004",
83    "position_lock",
84    include_str!("../migrations/sqlite/0004_position_lock.sql"),
85);
86
87/// The address columns made nullable so an entry can be anonymised rather
88/// than deleted (issue #265). See the migration for why deleting the row is
89/// the wrong answer here.
90const MIGRATION_ANONYMISABLE_ENTRY: SqlMigration = SqlMigration::new(
91    "0005",
92    "anonymisable_entry",
93    include_str!("../migrations/sqlite/0005_anonymisable_entry.sql"),
94);
95
96/// The per-product position counter and its UNIQUE backstop (issue #126).
97const MIGRATION_POSITION_COUNTER: SqlMigration = SqlMigration::new(
98    "0006",
99    "position_counter",
100    include_str!("../migrations/sqlite/0006_position_counter.sql"),
101);
102
103/// Counter rows for the products 0006's backfill could not see, because
104/// their confirmed positions predate the lock table (issue #707).
105const MIGRATION_LOCK_BACKFILL: SqlMigration = SqlMigration::new(
106    "0007",
107    "lock_backfill",
108    include_str!("../migrations/sqlite/0007_lock_backfill.sql"),
109);
110
111/// A per-product waitlist.
112pub struct Waitlist {
113    settings: Settings,
114}
115
116impl Default for Waitlist {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122impl Waitlist {
123    /// No products allowed until [`Waitlist::products`] or
124    /// [`Waitlist::any_product`] says otherwise; 7-day confirm TTL;
125    /// referrals on.
126    pub fn new() -> Self {
127        Self {
128            settings: Settings {
129                products: Products::List(Vec::new()),
130                confirm_ttl_days: 7,
131                retention_days_pending: 30,
132                status_redirect: None,
133                referrals: true,
134                require_answers: false,
135                answers_schema: accept_any_answers(),
136            },
137        }
138    }
139
140    /// Exactly these product slugs may be joined.
141    #[must_use]
142    pub fn products(mut self, products: impl IntoIterator<Item = impl Into<String>>) -> Self {
143        self.settings.products = Products::List(products.into_iter().map(Into::into).collect());
144        self
145    }
146
147    /// Accept any product slug (single-product ventures that do not want
148    /// an allowlist).
149    #[must_use]
150    pub fn any_product(mut self) -> Self {
151        self.settings.products = Products::Any;
152        self
153    }
154
155    /// How long a confirmation link stays valid (default 7 days).
156    #[must_use]
157    pub fn confirm_ttl_days(mut self, days: u32) -> Self {
158        self.settings.confirm_ttl_days = days;
159        self
160    }
161
162    /// Purge `pending` entries untouched for this many days from the
163    /// scheduled handler (default 30; issue #13 retention rule).
164    #[must_use]
165    pub fn retention_days_pending(mut self, days: u32) -> Self {
166        self.settings.retention_days_pending = days;
167        self
168    }
169
170    /// Where confirmation redirects to, as
171    /// `<url>?token=<status token>` (default
172    /// `<public_url>/waitlist/status`).
173    #[must_use]
174    pub fn status_redirect(mut self, url: impl Into<String>) -> Self {
175        self.settings.status_redirect = Some(url.into());
176        self
177    }
178
179    /// Whether `ref` codes credit referrers (default `true`).
180    #[must_use]
181    pub fn referrals(mut self, enabled: bool) -> Self {
182        self.settings.referrals = enabled;
183        self
184    }
185
186    /// Validates the free-form `answers` JSON of a join (default: accept
187    /// anything). Only runs when the join carries answers; pair with
188    /// [`Self::require_answers`] to also reject joins without them.
189    #[must_use]
190    pub fn answers_schema(
191        mut self,
192        validate: impl Fn(&Value) -> Result<(), String> + Send + Sync + 'static,
193    ) -> Self {
194        self.settings.answers_schema = Arc::new(validate);
195        self
196    }
197
198    /// Whether a join must carry `answers` (default `false`): with
199    /// `true`, a body without the field — absent or `null` — is
200    /// rejected as `validation-failed`.
201    #[must_use]
202    pub fn require_answers(mut self, required: bool) -> Self {
203        self.settings.require_answers = required;
204        self
205    }
206}
207
208impl Module for Waitlist {
209    fn name(&self) -> &'static str {
210        "waitlist"
211    }
212
213    fn version(&self) -> &'static str {
214        env!("CARGO_PKG_VERSION")
215    }
216
217    fn requires(&self) -> &'static [Port] {
218        &[Port::Db, Port::Mailer, Port::Signer]
219    }
220
221    fn optional(&self) -> &'static [Port] {
222        &[Port::Captcha, Port::RateLimiter]
223    }
224
225    fn tables(&self) -> &'static [&'static str] {
226        &[
227            "waitlist_entries",
228            "waitlist_send_cooldown",
229            "waitlist_position_lock",
230        ]
231    }
232
233    /// The entry is anonymised, not deleted; the other two tables have no
234    /// subject column at all (issue #265).
235    ///
236    /// **Why `Anonymise` and not `Erase`.** A confirmed entry carries two
237    /// numbers that belong to other people as much as to its owner:
238    /// `position` is a dense per-product join order that is deliberately
239    /// never recomputed, and `referrals` is a credit already granted. Delete
240    /// the row and the queue acquires a hole nobody can explain and every
241    /// `referred_by` pointing at its code orphans; keep it and null the
242    /// person out of it, and the request is answered without rewriting
243    /// anybody else's place in the line. Migration `0005` exists for exactly
244    /// this: `email` and `email_normalized` were `NOT NULL`, so the `SET … =
245    /// NULL` this declaration promises could not have run.
246    ///
247    /// `referral_code` is deliberately **kept**. It is a random 8-character
248    /// string that names nobody once the address is gone, and it is what the
249    /// referrals other people were credited for still resolve against.
250    ///
251    /// **Why `id` and not the address.** `GET /v1/privacy/export?subject=…`
252    /// puts the subject value in a URL, and URLs outlive their request in
253    /// access logs, proxies and browser history — which is why this module's
254    /// own links carry the opaque row id and never the address (issue #135).
255    /// Declaring `email_normalized` as the subject would have made a subject
256    /// access request the one operation that writes an address into those
257    /// logs.
258    ///
259    /// **Why the other two have no subject column.** `waitlist_position_lock`
260    /// is one row per product, genuinely names nobody, and is `none`.
261    /// `waitlist_send_cooldown` is the harder case, declared `unreachable`,
262    /// and its reason says so plainly: its primary key is the
263    /// address and product the mail throttle counts against, so the address
264    /// is inside a key rather than in a column of its own, and
265    /// `… WHERE <column> = ?` cannot reach it. That is the same shape issue
266    /// #266 describes for `Outbox`, and the table is core's
267    /// [`SendCooldown`](cratefield_core::SendCooldown) shape rather than this
268    /// module's, so giving it a column of its own is a change to every module
269    /// that has one. Saying so is the honest answer available here; inventing
270    /// a subject column that matched nothing would not be.
271    fn personal_data(&self) -> &'static [PersonalDataSet] {
272        const SETS: &[PersonalDataSet] = &[
273            PersonalDataSet {
274                table: "waitlist_entries",
275                subject: "id",
276                kind: DataKind::Contact,
277                disposition: Disposition::Anonymise(&["email", "email_normalized", "answers"]),
278                description: "Your place on a waitlist: the address you joined with, which \
279                              product, when you joined and confirmed, your number in the queue, \
280                              your referral code and how many people joined through it, and any \
281                              answers you gave on the way in.",
282                redacted: &[],
283                subject_via: None,
284            },
285            // Not `PersonalDataSet::none`: the row does hold the address, and
286            // bucketing it under "not personal" would tell the subject a
287            // table with their address in it holds nothing about anybody
288            // (issue #274). Declaring the `subject` column is not an option
289            // either: it holds `<email>:<product>`, so `WHERE subject = ?`
290            // with a bare address never matches — the value's *format* is
291            // the problem, not a missing hop, and widening `subject_via` to
292            // a prefix match would be a different mechanism. Core's
293            // `SendCooldown` shape is the change that closes it properly.
294            PersonalDataSet::unreachable(
295                "waitlist_send_cooldown",
296                DataKind::Contact,
297                "When we last emailed you about a waitlist, so the same address is not \
298                 mailed again within the hour.",
299                "The row is keyed on your address and the product together, so an erasure \
300                 request cannot match it with an address alone. Nothing else about you is \
301                 in the row, and it stops mattering once the queue does.",
302            ),
303            PersonalDataSet::none(
304                "waitlist_position_lock",
305                "One row per product, holding the lock that stops two people confirming in the \
306                 same instant from being given the same number in the queue. A product name and \
307                 a timestamp; nobody is named in it.",
308            ),
309        ];
310        SETS
311    }
312
313    fn emits(&self) -> &'static [&'static str] {
314        &[handlers::EVENT_JOINED, handlers::EVENT_CONFIRMED]
315    }
316
317    fn public_writes(&self) -> bool {
318        true
319    }
320
321    fn migrations(&self) -> Migrations {
322        const MIGRATIONS: [SqlMigration; 7] = [
323            MIGRATION_INIT,
324            MIGRATION_ENTRY_GENERATION,
325            MIGRATION_MAIL_COOLDOWN,
326            MIGRATION_POSITION_LOCK,
327            MIGRATION_ANONYMISABLE_ENTRY,
328            MIGRATION_POSITION_COUNTER,
329            MIGRATION_LOCK_BACKFILL,
330        ];
331        // The array is the apply order; this refuses a gap, a duplicate
332        // or an entry out of order at build time (issue #27).
333        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
334        Migrations {
335            sqlite: &MIGRATIONS,
336            postgres: &[],
337        }
338    }
339
340    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
341        let module = ModuleConfig::new("waitlist", cfg);
342        let mut errors = ConfigError::default();
343
344        if let Some(raw) = cfg
345            .get(&module.key("CONFIRM_TTL_DAYS"))
346            .map(|raw| raw.parse::<u32>())
347        {
348            let invalid = match raw {
349                Ok(days) => days == 0,
350                Err(_) => true,
351            };
352            if invalid {
353                errors.push(format!(
354                    "waitlist: {} must be a positive integer",
355                    module.key("CONFIRM_TTL_DAYS")
356                ));
357            }
358        }
359        for key in ["STATUS_REDIRECT", "EXPIRED_REDIRECT", "API_BASE"] {
360            if let Some(raw) = cfg.get(&module.key(key))
361                && !(raw.starts_with("https://") || raw.starts_with("http://"))
362            {
363                errors.push(format!(
364                    "waitlist: {} must be an absolute http(s) URL, got {raw:?}",
365                    module.key(key)
366                ));
367            }
368        }
369        if let Some(list) = cfg.get(&module.key("PRODUCTS"))
370            && list != "*"
371            && list
372                .split(',')
373                .map(str::trim)
374                .all(|slug| !is_product_slug(slug))
375        {
376            errors.push(format!(
377                "waitlist: {} must be a comma-separated list of kebab-case slugs or `*`",
378                module.key("PRODUCTS")
379            ));
380        }
381
382        errors.into_result()
383    }
384
385    fn router(&self, ctx: ModuleContext) -> axum::Router {
386        handlers::router(Arc::new(ctx), self.settings.clone())
387    }
388
389    fn surface(&self) -> cratefield_core::Surface {
390        handlers::surface(&self.settings)
391    }
392
393    fn scheduled<'a>(
394        &'a self,
395        ctx: &'a ModuleContext,
396        cron: &'a str,
397    ) -> BoxFuture<'a, Result<(), AnyError>> {
398        Box::pin(async move {
399            let Some(db) = ctx.ports.db.clone() else {
400                return Ok(());
401            };
402            let cfg = ModuleConfig::new("waitlist", &*ctx.config);
403            let days = i64::from(cfg.get_u32(
404                "RETENTION_DAYS_PENDING",
405                self.settings.retention_days_pending,
406            ));
407            let cutoff = handlers::iso_ago(days.saturating_mul(86_400));
408            let deleted = store::purge_pending_older_than(&*db, &cutoff)
409                .await
410                .map_err(|err| Box::new(err) as AnyError)?;
411            if deleted > 0 {
412                tracing::info!(deleted, cron, "purged stale pending waitlist entries");
413            }
414            // Claims whose window has closed are dead rows (issue #133).
415            let claims = cratefield_core::SendCooldown::new(handlers::SEND_COOLDOWN_TABLE)
416                .prune(
417                    &*db,
418                    &handlers::iso_ago(handlers::REMAIL_AFTER_SECS.saturating_mul(2)),
419                )
420                .await
421                .map_err(|err| Box::new(err) as AnyError)?;
422            if claims > 0 {
423                tracing::info!(claims, cron, "pruned expired waitlist send claims");
424            }
425            Ok(())
426        })
427    }
428}
429
430fn is_product_slug(slug: &str) -> bool {
431    !slug.is_empty()
432        && slug
433            .chars()
434            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn defaults_match_the_issue() {
443        let module = Waitlist::new();
444        assert_eq!(module.settings.confirm_ttl_days, 7);
445        assert_eq!(module.settings.retention_days_pending, 30);
446        assert!(module.settings.referrals);
447        assert_eq!(module.name(), "waitlist");
448        assert_eq!(
449            module.tables(),
450            [
451                "waitlist_entries",
452                "waitlist_send_cooldown",
453                "waitlist_position_lock"
454            ]
455        );
456        assert!(module.public_writes());
457        assert_eq!(module.requires(), [Port::Db, Port::Mailer, Port::Signer]);
458        assert_eq!(module.optional(), [Port::Captcha, Port::RateLimiter]);
459        assert_eq!(module.emits(), ["waitlist.joined", "waitlist.confirmed"]);
460    }
461
462    #[test]
463    fn referral_codes_are_8_char_crockford() {
464        for _ in 0..64 {
465            let code = handlers::referral_code();
466            assert_eq!(code.len(), 8);
467            assert!(
468                code.chars()
469                    .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase()),
470                "{code}"
471            );
472        }
473    }
474}