Skip to content
Chapter 58Lesson 2

Minting the signed accept link

Build the Server Action that mints an invitation token, HMAC-signs its accept URL, and emails it after the row commits.

Alice opens the members page, types bob@acme.com, picks Member from the role dropdown, and clicks Send invite. The last lesson built the row this click creates: the invitation table, with its tokenHash column, pending status, and seven-day expiresAt. What’s missing is the code that fills that row in and gets a clickable link into Bob’s mailbox.

That click owes a short chain of work. Mint a random token, hash it, and write a pending row. Build a URL Bob can click from his inbox, sign it so junk requests reject fast, and mail it. Do all of that without leaking the raw token into your server logs, and without leaving a half-built mess behind if Resend hiccups mid-send.

You already have the pieces this assembles from. authedAction lifts the session, the role check, and the schema parse out of the body. withTenant opens a tenant-scoped transaction. logAudit writes the audit row inside it. sendEmail is the Resend wrapper from the email unit. This lesson turns those four into a single send path: one Server Action, sendInvitation, built end to end, plus a small helper underneath it called signedInviteUrl.

A pending invitation is a bearer credential you are mailing to a stranger, so build it like one. That idea frames every decision ahead.

Whoever holds the accept URL can join the org. Not “whoever is logged in as Bob,” but whoever holds the link: the URL itself is the proof of identity. That makes it a bearer token . For the seven days it lives, the raw token inside that URL is as sensitive as a password.

It differs from a password in one way you settled last lesson. A password is long-lived and human-chosen, so it earns a deliberately slow hash. This token is high-entropy and throwaway, 32 random bytes with a seven-day window, so a fast hash is correct, which is why the database stores sha256(token) rather than a bcrypt or argon digest. Generating the token it hashes is this lesson’s job.

If the raw token is that sensitive, the design comes down to one question: where is it allowed to exist? Outside server memory there are exactly three places it could appear, each with a verdict.

Database row invitation table
email bob@acme.com
tokenHash 3af9c2…
token Yk3f…
raw token never stored — only sha256(token)
Server logs / Sentry every breadcrumb
POST /actions/sendInvitation …/accept-invite?id=018f…&token=[redacted]&sig=[redacted]
redacted at the logger before any line is written
Email body Bob’s inbox
You’re invited to Acme Accept invite …?token=Yk3f…
the one place it belongs — its destination
One secret, three trust boundaries. The database keeps only sha256(token); the logger redacts the token and sig params. The raw token leaves server memory exactly once, into the email.

The database never sees the raw token, only its hash, the posture you built last lesson. Server logs and Sentry breadcrumbs never see it either: the token and sig query params get redacted at the logger before any line is written, the same way password and authorization already are. That redaction rule lives in the logger config, not at your call sites, so state it here and leave the pino wiring for later.

The email body is the deliberate exception. The inbox is the credential’s destination: the whole point of the flow is to deliver this secret to the person who can use it. So the raw token belongs there, in the URL, and nowhere else. One secret, three boundaries, one legitimate exit.

Better Auth’s organization plugin already owns the invitation table and ships inviteMember and acceptInvitation APIs. So why hand-roll a send path?

Because the plugin’s default credential is the invitation’s id, a value the rest of your system treats as a non-secret database key, and its mail path isn’t yours to instrument. You can’t slot in your audit row, sign the URL, or control where the secret appears. The experienced move is to keep the plugin’s table shape, which you did last lesson, and write the send by hand, so the random token, the hash at rest, the HMAC signature, and the audit row all live in code you own.

Thirty-two random bytes, encoded for a URL

Section titled “Thirty-two random bytes, encoded for a URL”

The credential starts as randomness. Get the generation right and everything downstream is mechanical; get it subtly wrong and you’ve shipped a guessable token that no amount of hashing or signing can save. It takes two lines: ask the platform’s cryptographic random source for 32 bytes, then encode them into something safe to drop in a URL.

const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const rawToken = Buffer.from(rawBytes).toString('base64url');

crypto.getRandomValues(new Uint8Array(32)) fills a 32-byte array from the platform’s CSPRNG : 256 bits of entropy. Thirty-two is the number to remember: comfortably more than any attacker could search, and a clean match for the 256-bit output of the SHA-256 you’ll hash it with.

Raw bytes aren’t URL-safe, so you encode them. Buffer.from(rawBytes).toString('base64url') gives a 43-character string in the URL-safe Base64 alphabet: - and _ instead of + and /, and no trailing = padding. That’s why you pick base64url over plain base64 — nothing in it needs escaping inside a query string. The result is one high-entropy string: it gets hashed into tokenHash, goes into the URL, and is discarded once the email is sent.

Two choices here are wrong, and an experienced engineer spots both on sight. Math.random() is not cryptographically secure — its output is predictable to anyone who models it, which disqualifies it as the source of a credential. See it feeding a token and that’s a finding. crypto.randomUUID() is subtler: a v4 UUID carries 122 bits of entropy, plenty to be unguessable, so it isn’t a security hole. But a UUID is an identity-shaped value, and using one as a credential blurs two roles you want kept apart. The 32-byte approach gives a uniform, purpose-built bearer string that composes cleanly with the hash and the URL. Use randomUUID for an id, getRandomValues(32) for a secret.

The token is 32 random bytes, verified by hashing the incoming value and looking up the matching tokenHash. Guessing a valid one is infeasible, so the token already authenticates on its own. Why sign anything on top of it?

Because the token and the signature do two different jobs, and conflating them is the usual source of confusion.

The token authenticates. The accept path hashes the incoming token, looks up the row by tokenHash, and either finds a pending invitation or doesn’t. Who gets in depends only on the token; remove the signature and a valid token is still a valid token.

The signature is a cheap doorman. Picture the accept route without it. A script points at /accept-invite?token=garbage and fires a thousand random values a second, and every one forces a database round-trip to hash the junk and run the lookup before failing. The HMAC lets the server reject a tampered or fabricated URL with a single in-memory string comparison, before it touches the database.

It also closes a subtler gap. Suppose an attacker reads your invitation table through a leaked backup or a misconfigured replica, so they hold every tokenHash. Without the signature, that read would be the only barrier left. With it, they’re stuck: lacking the signing secret, they can’t produce a valid sig, so no link they forge will pass. The database becomes a pure key/value store with no forge-from-read power. That is defense in depth: the signature isn’t the lock, it’s a second, independent door.

So the URL has a precise shape:

Anatomy of the accept URL
https://app.acme.com/accept-invite
? id=018f…
& token=Yk3f…
& sig=9b1c…
base public path from NEXT_PUBLIC_APP_URL — never the request host
id public key which invitation row — not a secret
token the credential the actual secret — verified by hash lookup
sig the doorman HMAC of id + token — rejects forgeries before the DB
sig = base64url(HMAC-SHA256(secret, id + '.' + token))
Verify side — next lesson
1 Recompute the HMAC from id + token, same secret
2 Constant-time compare against the sig in the URL
Mismatch → reject immediately
only valid sig gets past
look up the row by tokenHash
The token (green) is the credential, verified by hash lookup; the sig (blue) is the doorman, an HMAC over id + token that the verify side recomputes and constant-time-compares before any database query.

The URL is ${NEXT_PUBLIC_APP_URL}/accept-invite?id=${invitationId}&token=${rawToken}&sig=${hmac}, where hmac = base64url(HMAC-SHA256(secret, invitationId + '.' + rawToken)). The string that gets signed, invitationId + '.' + rawToken, is the canonical signing payload, and it has one strict rule: it must be byte-for-byte identical on the signing and verifying sides. A stray space or the wrong separator means the recomputed signature won’t match and every legitimate link breaks. Enforcing that in one place is the whole job of the helper you’re about to write.

The HMAC gets its own secret: a separate env var, INVITATION_SIGNING_SECRET, holding 32 random bytes base64-encoded, declared in env.ts on the server with a generated value in your .env.local.

Two terms are about to carry real weight. An HMAC is a keyed signature, and a constant-time compare is a string comparison whose timing never reveals where two values first differ.

You write the crypto by hand, once, as a pure function, and you write its mirror image alongside it. Building both halves and watching them line up is what fixes the rule that signing and verifying must agree on the exact payload.

The helper is the single source of truth for what’s in the URL. signedInviteUrl(invitationId, rawToken) lives at src/lib/invitations/url.ts, and it’s async because crypto.subtle is: every method on that surface returns a Promise. One function, one place to read the URL shape and keep the signature in lockstep with the accept path that verifies it next lesson.

import 'server-only';
import { env } from '@/env';
const encoder = new TextEncoder();
async function signingKey() {
const secret = Buffer.from(env.INVITATION_SIGNING_SECRET, 'base64');
return crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
}
export async function signedInviteUrl(
invitationId: string,
rawToken: string,
): Promise<string> {
const key = await signingKey();
const payload = encoder.encode(`${invitationId}.${rawToken}`);
const signature = await crypto.subtle.sign('HMAC', key, payload);
const sig = Buffer.from(signature).toString('base64url');
const url = new URL('/accept-invite', env.NEXT_PUBLIC_APP_URL);
url.searchParams.set('id', invitationId);
url.searchParams.set('token', rawToken);
url.searchParams.set('sig', sig);
return url.toString();
}

import 'server-only' makes it a build error for any client bundle to pull this module in, so the signing secret can never reach the browser. The secret is read from env, the build-time-validated env object, not from process.env.

import 'server-only';
import { env } from '@/env';
const encoder = new TextEncoder();
async function signingKey() {
const secret = Buffer.from(env.INVITATION_SIGNING_SECRET, 'base64');
return crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
}
export async function signedInviteUrl(
invitationId: string,
rawToken: string,
): Promise<string> {
const key = await signingKey();
const payload = encoder.encode(`${invitationId}.${rawToken}`);
const signature = await crypto.subtle.sign('HMAC', key, payload);
const sig = Buffer.from(signature).toString('base64url');
const url = new URL('/accept-invite', env.NEXT_PUBLIC_APP_URL);
url.searchParams.set('id', invitationId);
url.searchParams.set('token', rawToken);
url.searchParams.set('sig', sig);
return url.toString();
}

Import the secret as an HMAC CryptoKey. Buffer.from(..., 'base64') decodes the env string back to the raw 32 bytes, importKey('raw', …) hands those bytes to Web Crypto, false marks the key non-extractable so it can sign but can never be exported back out, and ['sign'] is the one capability it’s allowed.

import 'server-only';
import { env } from '@/env';
const encoder = new TextEncoder();
async function signingKey() {
const secret = Buffer.from(env.INVITATION_SIGNING_SECRET, 'base64');
return crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
}
export async function signedInviteUrl(
invitationId: string,
rawToken: string,
): Promise<string> {
const key = await signingKey();
const payload = encoder.encode(`${invitationId}.${rawToken}`);
const signature = await crypto.subtle.sign('HMAC', key, payload);
const sig = Buffer.from(signature).toString('base64url');
const url = new URL('/accept-invite', env.NEXT_PUBLIC_APP_URL);
url.searchParams.set('id', invitationId);
url.searchParams.set('token', rawToken);
url.searchParams.set('sig', sig);
return url.toString();
}

Sign the canonical payload. `${invitationId}.${rawToken}` is the byte-exact string the verify side must reproduce, encoder.encode turns it into bytes, and crypto.subtle.sign('HMAC', key, …) returns the signature as an ArrayBuffer.

import 'server-only';
import { env } from '@/env';
const encoder = new TextEncoder();
async function signingKey() {
const secret = Buffer.from(env.INVITATION_SIGNING_SECRET, 'base64');
return crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
}
export async function signedInviteUrl(
invitationId: string,
rawToken: string,
): Promise<string> {
const key = await signingKey();
const payload = encoder.encode(`${invitationId}.${rawToken}`);
const signature = await crypto.subtle.sign('HMAC', key, payload);
const sig = Buffer.from(signature).toString('base64url');
const url = new URL('/accept-invite', env.NEXT_PUBLIC_APP_URL);
url.searchParams.set('id', invitationId);
url.searchParams.set('token', rawToken);
url.searchParams.set('sig', sig);
return url.toString();
}

Encode the signature for the URL. Buffer.from(signature).toString('base64url') renders the raw bytes into the same URL-safe alphabet as the token, so nothing needs escaping in the query string.

import 'server-only';
import { env } from '@/env';
const encoder = new TextEncoder();
async function signingKey() {
const secret = Buffer.from(env.INVITATION_SIGNING_SECRET, 'base64');
return crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
}
export async function signedInviteUrl(
invitationId: string,
rawToken: string,
): Promise<string> {
const key = await signingKey();
const payload = encoder.encode(`${invitationId}.${rawToken}`);
const signature = await crypto.subtle.sign('HMAC', key, payload);
const sig = Buffer.from(signature).toString('base64url');
const url = new URL('/accept-invite', env.NEXT_PUBLIC_APP_URL);
url.searchParams.set('id', invitationId);
url.searchParams.set('token', rawToken);
url.searchParams.set('sig', sig);
return url.toString();
}

Assemble the absolute URL from the named env var, never from a route handler’s request.url. env.NEXT_PUBLIC_APP_URL keeps the link stable; the incoming request host breaks the moment the action runs behind a preview deployment or a proxy. The URL and searchParams API encodes each value for you.

1 / 1

Now the twin. To verify this URL, the accept path recomputes the signature and compares, and the correct comparison is not === but crypto.subtle.verify. It checks a MAC in constant time, so it can’t leak the secret through timing the way string equality would. The takeaway you carry forward is the call itself: verify a signature with crypto.subtle.verify, never with ===.

You’ll write a small verifyInviteUrl(invitationId, rawToken, sig) to prove the round-trip closes. It’s a teaching stub, since the production verify gate is next lesson’s job, but writing it now is what makes the signing-and-verifying-must-agree rule land in your hands.

The exercise below is where you build both halves. The sandbox gives you crypto.subtle directly in the browser, with no imports and no setup, and your job is to fill in signInvite and verifyInvite so they agree on the exact payload string. Watch what the tests check: not just that a round-trip works, but that flipping a single character of the token, or swapping the secret, makes verification fail.

The sandbox hands you the browser's crypto.subtle plus three helpers: importKey (the HMAC key), toBase64url, and fromBase64url. Fill in signInvite to return the base64url HMAC-SHA256 of the canonical payload ${id}.${token} under the given secret, and verifyInvite to recompute the signature and constant-time-compare it against sig — use crypto.subtle.verify, never ===. Both are async. The whole point: sign and verify must agree on the exact payload string.

    Reveal solution
    export async function signInvite(id, token, secret) {
    const key = await importKey(secret, 'sign');
    const payload = encoder.encode(`${id}.${token}`);
    const signature = await crypto.subtle.sign('HMAC', key, payload);
    return toBase64url(signature);
    }
    export async function verifyInvite(id, token, secret, sig) {
    const key = await importKey(secret, 'verify');
    const payload = encoder.encode(`${id}.${token}`);
    const sigBytes = fromBase64url(sig);
    return crypto.subtle.verify('HMAC', key, sigBytes, payload);
    }

    Both halves build the same canonical payload, `${id}.${token}`, and that string has to be byte-for-byte identical on each side, or verify returns false even when nothing was tampered with. signInvite HMACs that payload and base64url-encodes the bytes; verifyInvite decodes the incoming sig back to bytes and hands the recomputation to crypto.subtle.verify, which compares the MAC in constant time. It never uses === on the signature string, which would leak the secret one byte at a time through response timing. Flip a character of the token or swap the secret and the recomputed MAC no longer matches, so verification fails: exactly the two failures the tamper and wrong-secret tests prove.

    Assemble the sendInvitation body in execution order

    Section titled “Assemble the sendInvitation body in execution order”

    You now hold every primitive: the token, the hash, and the signed URL. This section assembles them. sendInvitation has eight steps, but you’ve built or seen each one, so the walkthrough is about order.

    Start with the declaration:

    src/app/(app)/settings/members/actions.ts
    const sendInvitationSchema = z.object({
    email: z.email().toLowerCase(),
    role: z.enum(['admin', 'member']),
    });
    export const sendInvitation = authedAction(
    'admin',
    sendInvitationSchema,
    async ({ email, role }, ctx) => {
    // walked step by step below
    },
    );

    authedAction('admin', …) gates everything: only an admin reaches the body, the session and tenant context arrive pre-loaded in ctx, and the FormData is already parsed against the schema. Note the name, as with removeMember last chapter: it’s sendInvitation, not sendInvitationAction. Server Actions here are plain verb-plus-noun, and the Action suffix appears only to disambiguate from a same-named non-action, which this isn’t.

    The schema is small, but every line is a decision. .toLowerCase() is load-bearing: last lesson’s partial unique index keys on lower(email), so without it Bob@Acme.com and bob@acme.com slip past duplicate detection as different people. z.enum(['admin', 'member']) refuses 'owner' at the type level. The form already hides that option, so the schema refusing it too is defense in depth: two layers both have to fail before someone mints an owner invite.

    Now the body, in execution order, since the order matters more than any single line.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Entitlement check, deferred. Before writing, a paid product asks whether the org has a free seat. That’s billing territory (canInviteMember, chapter 064), so it’s a TODO here, not built. It belongs first, because you refuse before you mint anything.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Collision check, deferred. “Does this address already have a pending invite?” isn’t a pre-query, because a SELECT-then-INSERT has a race window where two clicks both pass the check. Instead you let the partial unique index reject the duplicate at the database, and translate that error in the resend flow.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Generate the token. The two lines you know cold, plus expiresAt from INVITATION_TTL_SECONDS (last lesson’s constant, seconds to milliseconds) and getOrgName, a tenant-scoped read for the email’s recognition line. The raw-millisecond new Date() is deliberate: this value binds straight to Better Auth’s plugin-owned invitation column, and the database is exactly the third-party seam where chapter 009 sanctions Date over Temporal. Nothing writes yet.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Write the row, inside the transaction. withTenant(ctx.orgId, …) opens a tenant-scoped transaction. The insert stores tokenHash: await sha256(rawToken) (never the raw token), status: 'pending', the role and email, and inviterId from ctx.user.id. .returning({ id }) hands back the generated id.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Write the audit row, in the same transaction. logAudit(tx, …) rides the same tx as the insert, so the invitation and its audit record share a fate: both commit or neither does. It records intent — Alice invited Bob as member, at this time. Whether the email later lands is a separate dimension, not an audit fact.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Sign the URL, after the transaction closes. It needs the committed invitationId (which didn’t exist until the insert returned) and the rawToken, still in memory. This is the helper you just built.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Send the email, outside the transaction. sendEmail renders the <InviteEmail> template and dispatches through Resend. The acceptUrl is the one place the raw token leaves server memory. The idempotencyKey keyed on invitationId makes a double-submit harmless.

    // 1. Entitlement check — seat count. TODO(chapter 064)
    // if (!(await canInviteMember(ctx.orgId))) return err('forbidden', …);
    // 2. Collision check is handled by the partial unique index,
    // caught and translated to 'already-invited' in the resend flow.
    // 3. Generate the token; read the org name for the email.
    const rawBytes = crypto.getRandomValues(new Uint8Array(32));
    const rawToken = Buffer.from(rawBytes).toString('base64url');
    const expiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
    const orgName = await getOrgName(ctx.orgId);
    // 4 + 5. Row and audit, inside one tenant transaction.
    const invitationId = await withTenant(ctx.orgId, async (tx) => {
    const [row] = await tx
    .insert(invitation)
    .values({
    organizationId: ctx.orgId,
    email,
    role,
    inviterId: ctx.user.id,
    status: 'pending',
    tokenHash: await sha256(rawToken),
    expiresAt,
    })
    .returning({ id: invitation.id });
    await logAudit(tx, {
    action: 'invitation.sent',
    subjectType: 'invitation',
    subjectId: row.id,
    payload: { email, role },
    });
    return row.id;
    });
    // 6. Sign the URL — after COMMIT, the id now exists.
    const acceptUrl = await signedInviteUrl(invitationId, rawToken);
    // 7. Send the email — outside the transaction.
    const sent = await sendEmail({
    to: email,
    subject: `You're invited to ${orgName}`,
    react: (
    <InviteEmail
    orgName={orgName}
    inviterName={ctx.user.name}
    acceptUrl={acceptUrl}
    expiresAt={expiresAt}
    />
    ),
    idempotencyKey: `invite:${invitationId}`,
    });
    // 8. Revalidate and return.
    revalidatePath('/settings/members');
    return ok({ invitationId, emailSent: sent.ok });

    Revalidate and return. revalidatePath('/settings/members') refreshes the admin’s pending list, then you return ok. The return carries invitationId and whether the send succeeded. If Resend failed, the row still committed, so the UI can offer a resend rather than report a dead end.

    1 / 1

    Two orderings here are non-negotiable; pull them out of the code and carry them to every action like this. The audit write rides inside the transaction, so the audit row and the thing it describes can never disagree: they commit together or not at all. The email send rides outside it, the rule you met last chapter, now with real stakes the next section makes visual. Everything else can shuffle; these two cannot.

    One mechanical guardrail reinforces the first rule: logAudit takes the transaction tx as its first argument, so the call won’t compile if you hand it the pooled client instead. The signature makes the wrong shape impossible to write, which is what you want around an append-only audit trail.

    The previous section stated this as a rule; here is why it holds. Trace what happens when Resend fails, and the ordering becomes the difference between a recoverable hiccup and a credential you can’t take back.

    %%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 18px !important; } .actor, .actor tspan { font-size: 15px !important; } .noteText, .noteText tspan { font-size: 14.5px !important; } .labelText, .labelText tspan { font-size: 14px !important; }'} }%%
    sequenceDiagram
      actor Admin as Admin (form)
      participant Action as sendInvitation
      participant DB as Postgres
      participant Resend
    
      Admin->>Action: submit { email, role }
      Note over Action: authedAction parses +<br/>authorizes (admin)
      Note over Action: generate 32-byte token
    
      rect rgba(129, 140, 248, 0.18)
        Note over Action,DB: one transaction — row + audit share a fate
        Action->>DB: BEGIN
        Action->>DB: INSERT invitation (status=pending, tokenHash)
        Action->>DB: INSERT audit_logs ('invitation.sent')
        Action->>DB: COMMIT  ◀ the pivot — row is now durable
      end
    
      Note over Action: transaction closed
      Action->>Action: signedInviteUrl(id, token)
    
      Action->>Resend: send InviteEmail(acceptUrl)
      alt Resend answers 200 OK
        Resend-->>Action: 200 OK
        Action-->>Admin: ok({ invitationId, emailSent: true })
      else Resend answers 5xx
        Resend-->>Action: 5xx error
        Note over Action: row already committed —<br/>nothing to roll back
        Action-->>Admin: ok({ invitationId, emailSent: false })
      end
    COMMIT is the dividing line. Everything database-side (BEGIN, the invitation insert, the audit insert, COMMIT) sits inside the shaded transaction, left of the line; the Resend call is always right of it, so the row is durable before a single packet reaches Resend. A 5xx then returns ok with emailSent: false — the row survives and the admin gets a resend affordance (Unit 9).

    Now imagine moving the Resend call left of the line, inside the transaction, and watch what breaks.

    That send-inside-the-transaction version fails two ways, and the second is the serious one. First, the Resend call is network IO: slow and unpredictable. Holding it open inside a transaction pins a database connection to a third party’s latency, straight back to the pool-starvation rule from last chapter. Second, if the transaction rolls back after the send, on any later error or constraint trip, you’ve already mailed Bob a working link to a row that no longer exists. That orphan credential is live in his inbox, pointing at nothing, and you can’t take it back.

    The send-after-commit version can’t produce that. The row is durable before Resend is ever called, so the worst case is an ok whose emailSent is false: the row exists, the admin sees a resend affordance, and resending is cheap because the source of truth already committed. The email is a delivery; the fact is the row, and the row is safe.

    The action’s seventh step hands sendEmail a React component, InviteEmail. This is where the raw token finally surfaces in rendered output.

    The template reuses the EmailLayout and the pass-a-node-to-sendEmail pattern from the email unit, so only the props are new.

    src/emails/invite.tsx
    export default function InviteEmail({
    orgName,
    inviterName,
    acceptUrl,
    expiresAt,
    }: InviteEmailProps) {
    return (
    <EmailLayout preview={`${inviterName} invited you to ${orgName}`}>
    <Heading>{inviterName} invited you to {orgName}</Heading>
    <Text>You've been given a seat on {orgName}. Accept to join.</Text>
    <Button href={acceptUrl}>Accept invite</Button>
    <Text>This invite expires {formatExpiry(expiresAt)}.</Text>
    </EmailLayout>
    );
    }

    acceptUrl is the only place the raw token appears in the rendered email. The heading and inviter name are there for recognition; the button is the credential.

    One distinction governs this template: inviterName and orgName are a recognition feature, so “Alice invited you to Acme” tells Bob in a fraction of a second that this is real, but the accept URL is a credential, not a marketing link. Treat it as one.

    In development, gate one convenience on NODE_ENV !== 'production': print the accept URL beside the “invite sent” toast so you can click through the flow locally without opening an inbox. In production, never.

    The raw token now lives in exactly one place outside your server, Bob’s inbox, which is where the threat model said it should be.

    The next lesson is the other side of that URL. The accept route verifies the signature first, so junk rejects before any query, then looks up the row by tokenHash, then handles the four ways a human can arrive: signed in with the same email, signed in with a different email, signed out but already holding an account, or signed out with no account at all. Routing those four correctly is where the invitation turns into a member row.