Token model
Every booking gets one opaque token. Each token maps to exactly one booking and is the credential carried in URLs and the session cookie.
Properties
- 256 bits of entropy — 32 random bytes from
crypto.getRandomValues, base64url-encoded (~43 chars, URL-safe, no padding) - One per booking (
UNIQUE(booking_id)index in D1) - Long-lived — issued at workflow spawn, valid until 30 days post-departure, then garbage-collected
- Rotatable — re-issuing replaces the row, old token stops resolving
D1 schema
CREATE TABLE guest_tokens ( token TEXT PRIMARY KEY, booking_id INTEGER NOT NULL, issued_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL, last_seen_at TEXT);CREATE UNIQUE INDEX idx_guest_tokens_booking ON guest_tokens(booking_id);CREATE INDEX idx_guest_tokens_expires ON guest_tokens(expires_at);Migration: 0035_guest_tokens.sql.
Why raw, not hashed
teamsummit-cal hashes its magic-link tokens at rest. The portal does not, deliberately:
- The token already appears in URLs / email bodies / Mailgun logs / browser history — it’s a session credential, not a password
- We need reverse-lookup (
booking_id→ token) for email rendering (“inject the portal URL into the welcome message”). Hashing would require keeping a separate raw copy somewhere, defeating the point - D1 is not externally accessible — only worker code reads it. If the D1 row leaks, the threat model has already failed
The migration’s header comment captures this rationale.
Helpers
In src/lib/guest-token.ts:
| Function | Purpose |
|---|---|
generateGuestToken() | 256-bit base64url string |
computeTokenExpiry(departure) | EOD UTC + 30 days |
issueOrGetGuestToken(env, bookingId, departure) | Get-or-create; rotates if existing is expired. Safe to call on every workflow respawn. |
resolveGuestToken(env, token) | URL → bookingId; touches last_seen_at; null on missing or expired |
getGuestTokenForBooking(env, bookingId) | For email rendering; null if none active |
sweepExpiredGuestTokens(env) | Daily-sweep target for P12.M7 |
Lifecycle
- Issue —
BookingFlagWorkflow(or whichever phase first needs the URL) callsissueOrGetGuestTokenat workflow spawn time. Idempotent — re-spawns return the same token unless it’s expired. - Serve — every guest-facing email rendering helper fetches the
token and builds
https://my.stayonthesnow.com/p/{token}URLs. - Resolve — portal request hits
/p/{token}, lookup in D1,last_seen_atupdated, booking fetched, page rendered. - Rotate — re-issuing for the same booking_id replaces the row
via
ON CONFLICT(booking_id) DO UPDATE. Old token returns 404. - Expire —
expires_at < nowcausesresolveGuestTokento return null. Daily sweep (P12.M7) drops the row.
Threat model
| Risk | Mitigation |
|---|---|
| Token in URL leaks to analytics / shared screenshot | 256-bit entropy makes brute-force infeasible; cookie scope is per-booking only |
| D1 row leak (catastrophic) | Same as session-token compromise: rotate every booking’s token; emails go out with fresh URLs |
| Stolen device with cookie | Token rotation invalidates the cookie. No multi-factor; portal doesn’t expose payment auth (Stripe handles that directly) |
| Email forwarding | Whoever has the URL has access — by design (multi-guest parties share). Sensitive operations behind explicit confirmation (e.g., add-on capture). |