Vercel Logo

Stamp Identity at the Door

Back in 2.3 we did something shady to get the per-tier playbook working: we trusted an x-shop-tier header. Anyone who knew to send x-shop-tier: pro got the pro discount, no questions asked. It was a deliberate stand-in, and we flagged it as one. Now that the dispatcher has real front doors, the web and Slack, it's time to make identity real too.

The job of a door is to answer one question before any work happens: who is this? In eve that's route auth, a policy on the channel that runs before the model does a thing. It decides who's allowed in, and it produces the caller's identity, the same ctx.session.auth the playbook reads. Auth helps us know our customer.

Outcome

The dispatcher derives the caller's identity from a real session, stamps their tier from the shop's records, and feeds it to the per-tier playbook, with no way for a caller to set their own tier.

Hands-on exercise

The auth option on the channel takes a list of functions eve walks in order. Each one does one of three things:

  • returns a SessionAuthContext → accept the caller, stop the walk
  • returns null → "not my caller," fall through to the next entry
  • throws → reject with a specific status

If every entry falls through, the request gets a 401. That ordered walk is the whole model: put your real auth first, then the framework's dev and OIDC helpers as catch-alls.

You need something that turns a request into a known customer. In a real app that's your auth provider (Clerk, Auth.js, your own OIDC). For the course, drop in a small stand-in:

curl -f --create-dirs -o agent/lib/auth.ts https://raw.githubusercontent.com/vercel-labs/bike-shop-agent/main/agent/lib/auth.ts

It exposes one function, getCustomer(request), that reads a session cookie and returns the customer on file, including their tier:

agent/lib/auth.ts (excerpt)
export interface Customer {
  readonly id: string;
  readonly tier?: "member" | "pro";
}
 
// Reads the `shop_session` cookie and looks the customer up server-side.
export function getCustomer(request: Request): Customer | null;

The detail that matters: the tier is a property of the customer record, looked up server-side, not a value the request carries. That's the entire fix for 2.3's vulnerability.

Rewrite agent/channels/eve.ts so appAuth uses it:

  • Call getCustomer(request). No customer? Return null and fall through.
  • Otherwise return a SessionAuthContext: the customer's id as principalId, principalType: "user", an authenticator and issuer label, and attributes.tier only when the record has one.
Identity is set once, and flows everywhere

You stamp the caller's identity in this one file, and it rides the whole session as ctx.session.auth.current. The playbook from 2.3 reads attributes.tier from it. A tool could read principalId to scope data. You don't thread identity through your code, the door stamps it, the runtime carries it.

Try It

Restart the server. Testing a tier means calling as a specific customer, which means sending the shop_session cookie, so we use curl here rather than the dev TUI (the TUI always runs as a local dev caller with no cookie, the plain desk).

One thing about the API first: a POST to /eve/v1/session starts the turn and returns a session handle ({ ok, sessionId, continuationToken }), not the reply. The dispatcher's answer streams back from /eve/v1/session/<id>/stream. So each test is two calls, POST to start, then attach to the stream to read the answer. This shell helper does both, and filters the stream down to the words that reveal which playbook loaded:

ask() { # usage: ask <cookie> "<message>"
  sid=$(curl -s -X POST http://127.0.0.1:2000/eve/v1/session \
    -H 'content-type: application/json' \
    -H "cookie: shop_session=$1" \
    -d "{\"message\":\"$2\"}" \
    | sed -E 's/.*"sessionId":"([^"]+)".*/\1/')
  curl -sN -H "cookie: shop_session=$1" \
    "http://127.0.0.1:2000/eve/v1/session/$sid/stream" \
    | grep -iE "torque|overhaul|discount|loaner|tune-up"
}

Call as a pro:

ask demo-pro "rear shifting is sloppy, what do you suggest?"

The reply talks torque specs and recommends the overhaul, the pro playbook, because getCustomer returned { id: "cust_003", tier: "pro" } and appAuth stamped tier: "pro". Swap the cookie and the desk changes:

  • ask demo-member "rear shifting is sloppy, what do you suggest?" → the 10% labor discount and a free loaner.
  • ask demo-walk-in "rear shifting is sloppy, what do you suggest?" → just a basic tune-up, no tier perks.

(Drop the | grep … line to read the whole reply; the filter is only there to make the difference jump out of the stream.)

Now try to cheat the 2.3 way, send the tier as a header with no session cookie:

sid=$(curl -s -X POST http://127.0.0.1:2000/eve/v1/session \
  -H 'content-type: application/json' \
  -H 'x-shop-tier: pro' \
  -d '{"message":"rear shifting is sloppy, what do you suggest?"}' \
  | sed -E 's/.*"sessionId":"([^"]+)".*/\1/')
curl -sN "http://127.0.0.1:2000/eve/v1/session/$sid/stream" \
  | grep -iE "torque|overhaul|discount|loaner|tune-up"

You get the plain desk, the same tune-up as demo-walk-in. The header is ignored: with no shop_session cookie, appAuth finds no customer and falls through, so the tier never gets stamped. A walk-in can't talk, or header, their way into the pro discount anymore.

What localDev and vercelOidc are doing in the list

appAuth handles your real customers. localDev() keeps loopback requests working while you develop (so npx eve dev and the local dashboard still talk to the agent), and vercelOidc() lets internal Vercel callers in. Order matters: real auth first, catch-alls last. Anything none of them recognizes gets a 401, auth fails closed.

Getting a 401 for a customer that should work? Confirm getCustomer is reading the shop_session cookie and that appAuth is first in the auth array. If the tier doesn't take effect, check you're stamping attributes.tier from customer.tier, the playbook reads exactly that path.

Done-When

  • agent/channels/eve.ts runs an ordered walk [appAuth, localDev(), vercelOidc()].
  • appAuth stamps tier from the customer record, not from a request header.
  • A shop_session=demo-pro cookie produces the pro playbook; x-shop-tier: pro does nothing.
  • An unrecognized caller falls through to the catch-alls (or 401 in production).

Solution

agent/channels/eve.ts
import { eveChannel } from "eve/channels/eve";
import { localDev, vercelOidc, type AuthFn } from "eve/channels/auth";
import { getCustomer } from "../lib/auth.js";
 
const appAuth: AuthFn<Request> = async (request) => {
  const customer = getCustomer(request);
  if (!customer) return null; // not one of our customers → fall through
 
  // The tier comes from the customer's record, not from the request. The
  // per-tier playbook (agent/skills/shop-playbook.ts) reads it from here.
  const attributes: Record<string, string> = {};
  if (customer.tier) attributes.tier = customer.tier;
 
  return {
    principalId: customer.id,
    principalType: "user",
    authenticator: "app",
    issuer: "spoke-and-mirror",
    attributes,
  };
};
 
export default eveChannel({
  auth: [appAuth, localDev(), vercelOidc()],
});

The dispatcher now knows who it's talking to, and the per-tier brain you built in 2.3 finally runs on real identity instead of a faked header. There's one door left to close before this is safe in the wild: making sure that in production, an unconfigured or unrecognized caller gets shut out by default. That's the first thing we do in Section 5.

Was this helpful?

supported.