Skip to content
Chapter 58Lesson 6

Quiz - Invitations and the seat-handoff lifecycle

Quiz progress

0 / 0

The invitation table’s uniqueness guard is a partial unique index — (organization_id, lower(email)) with a WHERE status = 'pending' predicate — rather than a plain unique index on (organization_id, email). What does the WHERE status = 'pending' clause buy you that a plain unique index would block?

Bob can keep an old accepted (or canceled) row from a past membership and still receive a fresh pending invite — only pending rows compete for uniqueness, so a legitimate re-invite isn’t blocked by historical rows.

It lets the database skip the case-folding work, since lower(email) only has to run on pending rows.

It marks expired rows so a background job can find and delete them without scanning the whole table.

The accept token is already 32 bytes of CSPRNG randomness, verified by hashing the incoming value and looking it up by tokenHash. So what does adding an HMAC signature (sig) to the URL actually buy you, given the token alone is unguessable?

It lets the server reject a forged or tampered URL with one in-memory comparison before any database round-trip — and, since the attacker doesn’t hold the signing secret, a leaked dump of tokenHash values still can’t be turned into a working link.

It replaces the token as the real credential — the signature is what authenticates the invitee, and the token is just a database lookup key.

It makes the token unguessable; without the signature, 32 random bytes could be brute-forced over the seven-day window.

The acceptInvitation Server Action re-runs the full verify ladder — signature, token hash, expiry, status = 'pending', and the email match — even though the accept page already verified all of that before rendering the Accept button. Why re-verify?

The button press is a separate POST request: a new session that may have changed, with form inputs a script could have altered. The page’s GET-time check is UX, not authorization — it never traveled to this request, so the write must prove the conditions itself.

Re-running the checks warms Postgres’s query cache so the member insert that follows runs faster.

Next.js discards the page’s verification result between the render and the action, so it has to be recomputed to read it back.

Alice clicks Resend on Bob’s still-pending invite. The senior default is to rotate: mint a brand-new 32-byte token, overwrite tokenHash, and push expiresAt a full TTL out from now — rather than re-firing the original email with its original link and unchanged window. What’s the reasoning?

A resend is a security event, not just a UX one — rotating invalidates any forwarded or leaked copy of the old token the instant its hash is overwritten, and gives Bob a clean fresh window instead of whatever sliver remained.

Reusing the same token would violate the partial unique index, since the row’s tokenHash must be distinct on every send.

Rotating lets the action skip the withTenant transaction, because an UPDATE of a single row doesn’t need one.

Two invite collisions are handled in opposite ways. Re-inviting an address that already has a pending invite: just INSERT and catch the 23505 the unique index throws, translating it to err('conflict', …). Inviting an address that’s already a member: read first, in the action body, and refuse before writing. Why pre-check one but not the other?

A pre-check is right only when no constraint can catch the case. The pending collision is guarded by a real unique index (so a SELECT-then-INSERT would just add a race window) — but no single index spans the member and invitation tables, so a guarded read in the action is the only tool available.

The pending case is more common, so it’s worth the cost of catching an exception; the already-member case is rare enough that a cheap read is fine either way.

Catching 23505 only works inside a transaction, and the already-member check runs before withTenant opens one.

Alice invited Bob to Acme, then left the company before Bob accepted. Bob clicks the link the next day. Select every statement that reflects the chapter’s senior posture. Select all that apply.

The accept proceeds normally — the invite is Acme’s decision, frozen on the row at send time, and the inviter’s departure doesn’t retract it.

The accept page may drop the “Invited by Alice” line if Alice is no longer a member, but that’s cosmetic copy — it never changes whether the seat is granted.

acceptInvitation should check that the inviter is still a member and refuse if not, to avoid granting a seat on a departed admin’s authority.

Bob should still get the role the invite carries even if Alice was demoted before he accepted, because the role is snapshotted on the row, not recomputed from the inviter.

Quiz complete

Score by topic