feat(desktop): add Passport identity card for people and agents

Every profile with a pubkey gets a Passport action that opens a
passport-style card: photo, HUMAN/AGENT type chip, operator (for
agents), issuing community, a scannable npub QR, and a
machine-readable zone carrying the full npub. Makes the "your keypair
is your portable identity" model tangible, building on the trading
card foundation.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-04 13:59:52 +02:00
parent feccf4eabc
commit 44ed54fe13
6 changed files with 512 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildPassportMrz,
mrzName,
PASSPORT_MRZ_LINE_LENGTH,
passportDocumentCode,
} from "./passportMrz.ts";
const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
test("document code distinguishes people from agents", () => {
assert.equal(passportDocumentCode(false), "P");
assert.equal(passportDocumentCode(true), "A");
});
test("mrzName uses surname<<given convention and MRZ charset", () => {
assert.equal(mrzName("Tyler"), "TYLER");
assert.equal(mrzName("Johnny Apple Seed"), "JOHNNY<<APPLE<SEED");
assert.equal(mrzName("Zoë O'Brien"), "ZOE<<OBRIEN");
assert.equal(mrzName(" "), "");
});
test("buildPassportMrz emits three fixed-width lines carrying the full npub", () => {
const lines = buildPassportMrz({
displayName: "Johnny Appleseed",
isAgent: false,
npub: NPUB,
});
assert.equal(lines.length, 3);
for (const line of lines) {
assert.equal(line.length, PASSPORT_MRZ_LINE_LENGTH);
assert.match(line, /^[A-Z0-9<]+$/);
}
assert.ok(lines[0].startsWith("P<BUZJOHNNY<<APPLESEED"));
// The npub survives round-trip: strip filler, lowercase, rejoin.
const key = (lines[1] + lines[2]).replaceAll("<", "").toLowerCase();
assert.equal(key, NPUB);
});
test("agent passports lead with the A document code", () => {
const [nameLine] = buildPassportMrz({
displayName: "Nest Keeper",
isAgent: true,
npub: NPUB,
});
assert.ok(nameLine.startsWith("A<BUZNEST<<KEEPER"));
});
test("overlong names truncate to the line width instead of overflowing", () => {
const [nameLine] = buildPassportMrz({
displayName: "A".repeat(80),
isAgent: false,
npub: NPUB,
});
assert.equal(nameLine.length, PASSPORT_MRZ_LINE_LENGTH);
});

View File

@@ -0,0 +1,66 @@
/**
* Machine-readable zone (MRZ) lines for the Buzz Passport card.
*
* The MRZ is the passport's signature visual — but here it is also honest:
* lines 2–3 carry the holder's full npub (bech32 is case-insensitive, so the
* uppercase rendering remains decodable). This makes the passport a valid
* "full key" trust surface per the pubkey-truncation rule: nothing on the
* card abbreviates the identity.
*/
export const PASSPORT_MRZ_LINE_LENGTH = 44;
/** Document code by holder kind: P = person, A = agent. */
export function passportDocumentCode(isAgent: boolean): "P" | "A" {
return isAgent ? "A" : "P";
}
/**
* Reduce a display name to the MRZ character set (A–Z, 0–9). Words are
* joined with `<<` after the first word (surname<<given convention) and `<`
* between the remaining words. Diacritics are transliterated by stripping
* combining marks.
*/
export function mrzName(displayName: string): string {
const words = displayName
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase()
.split(/\s+/)
.map((word) => word.replace(/[^A-Z0-9]/g, ""))
.filter((word) => word.length > 0);
if (words.length === 0) {
return "";
}
const [surname, ...given] = words;
return given.length > 0 ? `${surname}<<${given.join("<")}` : surname;
}
function padMrzLine(line: string): string {
return line
.slice(0, PASSPORT_MRZ_LINE_LENGTH)
.padEnd(PASSPORT_MRZ_LINE_LENGTH, "<");
}
/**
* Build the three MRZ lines: a name/document line followed by the holder's
* full npub split across two lines.
*/
export function buildPassportMrz({
displayName,
isAgent,
npub,
}: {
displayName: string;
isAgent: boolean;
npub: string;
}): [string, string, string] {
const nameLine = `${passportDocumentCode(isAgent)}<BUZ${mrzName(displayName)}`;
const key = npub.toUpperCase();
return [
padMrzLine(nameLine),
padMrzLine(key.slice(0, PASSPORT_MRZ_LINE_LENGTH)),
padMrzLine(key.slice(PASSPORT_MRZ_LINE_LENGTH)),
];
}

View File

@@ -0,0 +1,239 @@
import { buildPassportMrz } from "@/features/profile/lib/passportMrz";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { cn } from "@/shared/lib/cn";
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
export type PassportCardProps = {
avatarUrl: string | null;
className?: string;
/** Issuing community name — the server this identity is currently visiting. */
communityName: string | null;
displayName: string;
/** NIP-05 handle, when the profile has one. */
handle: string | null;
isAgent: boolean;
/** Owner handle for agent passports (the human the agent acts for). */
operatorName: string | null;
pubkey: string;
};
/**
* The Buzz Passport — a portable identity card for a person or agent.
*
* One mental model: your keypair IS your passport. It travels with you to
* other communities, and your agents carry passports of their own that name
* you as operator. The card renders that idea literally: photo, holder
* fields, an issuing community, a scannable key, and a machine-readable zone
* carrying the full npub (never truncated — this is a trust surface).
*
* Visual language stays on Buzz basics: Catppuccin semantic tokens, stock
* rem type ramp, rounded-2xl card geometry shared with the identity cards.
*/
export function PassportCard({
avatarUrl,
className,
communityName,
displayName,
handle,
isAgent,
operatorName,
pubkey,
}: PassportCardProps) {
const npub = safeNpub(pubkey) ?? pubkey;
const mrzLines = buildPassportMrz({ displayName, isAgent, npub });
return (
<div
className={cn(
"group relative w-[22rem] max-w-full overflow-hidden rounded-3xl border border-border/70 bg-card text-card-foreground shadow-2xl",
className,
)}
data-testid="passport-card"
>
{/* Tinted wash + top highlight, both from semantic tokens so the card
adapts to Latte/Macchiato without theme-specific branches. */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-primary/10" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-background/50 to-transparent" />
<GuillochePattern />
{/* Holographic glint that sweeps across on hover. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 -left-full w-1/2 -skew-x-12 bg-gradient-to-r from-transparent via-primary/20 to-transparent transition-transform duration-700 ease-out group-hover:translate-x-[300%] motion-reduce:hidden"
/>
<div className="relative flex flex-col gap-5 p-6">
<header className="flex items-baseline justify-between gap-3">
<span className="text-2xs font-semibold tracking-[0.35em] text-muted-foreground">
BUZZ&thinsp;·&thinsp;PASSPORT
</span>
<span
className="rounded-full border border-primary/40 px-2 py-0.5 text-3xs font-semibold tracking-[0.2em] text-primary"
data-testid="passport-holder-kind"
>
{isAgent ? "AGENT" : "HUMAN"}
</span>
</header>
<div className="flex gap-5">
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-28 w-28 rounded-2xl border border-border/60 text-2xl shadow-none"
iconClassName="h-10 w-10"
imageClassName="rounded-2xl"
label={displayName}
testId="passport-photo"
/>
<dl className="flex min-w-0 flex-1 flex-col justify-between gap-2 py-0.5">
<PassportField label="Name" testId="passport-name">
<span className="text-base font-semibold tracking-tight text-foreground">
{displayName}
</span>
</PassportField>
{handle ? (
<PassportField label="Handle" testId="passport-handle">
{handle}
</PassportField>
) : null}
{isAgent && operatorName ? (
<PassportField label="Operator" testId="passport-operator">
{operatorName}
</PassportField>
) : null}
<PassportField label="Community" testId="passport-community">
{communityName ?? "—"}
</PassportField>
</dl>
</div>
<div className="flex items-end justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
Identity key
</span>
<p className="text-xs leading-relaxed text-muted-foreground">
This key is {isAgent ? "this agent's" : "your"} passport — it
travels to every community and conversation.
</p>
</div>
<div className="shrink-0 rounded-xl border border-border/60 bg-background/60 p-1.5">
<StyledQrCode
backgroundColor="transparent"
foregroundColor="hsl(var(--foreground))"
size={84}
title={`${displayName} identity key QR code`}
value={npub}
/>
</div>
</div>
</div>
{/* Machine-readable zone — carries the FULL npub (uppercased bech32
stays decodable), so the passport never truncates the identity. */}
<div
className="relative border-t border-border/60 bg-background/70 px-6 py-4 font-mono text-2xs leading-relaxed tracking-normal text-foreground/80"
data-testid="passport-mrz"
>
{mrzLines.map((line) => (
<div className="truncate whitespace-pre" key={line}>
{line}
</div>
))}
</div>
</div>
);
}
function PassportField({
children,
label,
testId,
}: {
children: React.ReactNode;
label: string;
testId: string;
}) {
return (
<div className="flex min-w-0 flex-col">
<dt className="text-3xs font-medium tracking-[0.25em] text-muted-foreground uppercase">
{label}
</dt>
<dd
className="truncate text-sm font-medium text-foreground/90"
data-testid={testId}
>
{children}
</dd>
</div>
);
}
type GuillocheEllipse = {
cx: number;
cy: number;
rotate: number;
rx: number;
ry: number;
};
function guillocheBand({
count,
start,
step,
}: {
count: number;
start: GuillocheEllipse;
step: GuillocheEllipse;
}): GuillocheEllipse[] {
return Array.from({ length: count }, (_, index) => ({
cx: start.cx + step.cx * index,
cy: start.cy + step.cy * index,
rotate: start.rotate + step.rotate * index,
rx: start.rx + step.rx * index,
ry: start.ry + step.ry * index,
}));
}
const GUILLOCHE_BANDS: GuillocheEllipse[][] = [
guillocheBand({
count: 9,
start: { cx: 300, cy: 60, rotate: -18, rx: 190, ry: 70 },
step: { cx: -6, cy: 10, rotate: 4, rx: 18, ry: 9 },
}),
guillocheBand({
count: 9,
start: { cx: 40, cy: 400, rotate: 16, rx: 170, ry: 60 },
step: { cx: 5, cy: -8, rotate: -3, rx: 16, ry: 8 },
}),
];
/**
* Faint guilloché-style security lines, like the engraved curves on a real
* passport data page. Pure decoration (`aria-hidden`), drawn in the primary
* token at very low opacity.
*/
function GuillochePattern() {
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full text-primary opacity-[0.07]"
fill="none"
preserveAspectRatio="xMidYMid slice"
viewBox="0 0 352 440"
>
{GUILLOCHE_BANDS.flat().map((ellipse) => (
<ellipse
cx={ellipse.cx}
cy={ellipse.cy}
key={`${ellipse.cx}-${ellipse.cy}-${ellipse.rx}`}
rx={ellipse.rx}
ry={ellipse.ry}
stroke="currentColor"
strokeWidth="0.75"
transform={`rotate(${ellipse.rotate} ${ellipse.cx} ${ellipse.cy})`}
/>
))}
</svg>
);
}

View File

@@ -0,0 +1,90 @@
import { Copy } from "lucide-react";
import { toast } from "sonner";
import { useCommunities } from "@/features/communities/useCommunities";
import { useUserProfileQuery } from "@/features/profile/hooks";
import {
PassportCard,
type PassportCardProps,
} from "@/features/profile/ui/PassportCard";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/shared/ui/dialog";
type PassportDialogProps = Omit<
PassportCardProps,
"communityName" | "operatorName"
> & {
onOpenChange: (open: boolean) => void;
open: boolean;
/** Declared owner pubkey (NIP-OA) for agent passports. */
ownerPubkey: string | null;
};
/**
* Passport viewer — the card floats alone over the backdrop (no dialog
* chrome), the same "the card is the hero" treatment as the agent trading
* card viewer. The issuing community and operator identity are resolved
* here so the card itself stays purely presentational.
*/
export function PassportDialog({
onOpenChange,
open,
ownerPubkey,
...cardProps
}: PassportDialogProps) {
const { activeCommunity } = useCommunities();
const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined);
const operatorName = ownerPubkey
? ownerProfileQuery.data?.nip05Handle?.trim() ||
ownerProfileQuery.data?.displayName?.trim() ||
truncatePubkey(ownerPubkey)
: null;
const npub = safeNpub(cardProps.pubkey) ?? cardProps.pubkey;
const copyKey = async () => {
await writeTextToClipboard(npub);
toast.success("Identity key copied.");
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="max-w-fit justify-items-center"
data-testid="passport-dialog"
showCloseButton={false}
surface="none"
>
<DialogTitle className="sr-only">
{`${cardProps.displayName}'s passport`}
</DialogTitle>
<DialogDescription className="sr-only">
Portable identity card carrying the full public key, handle, and
issuing community.
</DialogDescription>
<PassportCard
{...cardProps}
communityName={activeCommunity?.name ?? null}
operatorName={operatorName}
/>
<Button
className="text-muted-foreground hover:text-foreground"
data-testid="passport-copy-key"
onClick={() => void copyKey()}
size="sm"
variant="ghost"
>
<Copy className="mr-2 h-3.5 w-3.5" />
Copy identity key
</Button>
</DialogContent>
</Dialog>
);
}

View File

@@ -36,9 +36,11 @@ import {
STATUS_DOT_MASK_CURVE,
} from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { PassportDialog } from "@/features/profile/ui/PassportDialog";
import {
ProfilePersonaPrimaryActions,
ProfilePrimaryActions,
ProfileSelfPrimaryActions,
} from "@/features/profile/ui/UserProfilePrimaryActions";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
@@ -224,6 +226,12 @@ export function ProfileSummaryView({
}: ProfileSummaryViewProps) {
const activeTurns = useAgentWorking(isBot ? pubkey : null).channels;
// The passport is available for any profile with a pubkey — self, other
// people, and agents alike. Persona drafts have no keypair yet, so no
// passport has been "issued" for them.
const [passportOpen, setPassportOpen] = React.useState(false);
const onOpenPassport = pubkey ? () => setPassportOpen(true) : undefined;
const showMemoriesTab = isOwner === true && Boolean(pubkey);
const showInstructionBlock =
isOwner === true &&
@@ -354,6 +362,7 @@ export function ProfileSummaryView({
canEditAgent={canEditAgent}
followMutation={followMutation}
onCreateCard={onCreateCard}
onOpenPassport={onOpenPassport}
onEditAgent={handleEditAgent}
agentActionDisabled={isAgentActionPending}
agentActionLabel={
@@ -384,6 +393,8 @@ export function ProfileSummaryView({
pubkey={pubkey}
unfollowMutation={unfollowMutation}
/>
) : isSelf && onOpenPassport ? (
<ProfileSelfPrimaryActions onOpenPassport={onOpenPassport} />
) : null}
{showTabSection ? (
@@ -457,6 +468,19 @@ export function ProfileSummaryView({
) : null}
</section>
) : null}
{pubkey ? (
<PassportDialog
avatarUrl={profile?.avatarUrl ?? null}
displayName={displayName}
handle={profile?.nip05Handle ?? null}
isAgent={isBot}
onOpenChange={setPassportOpen}
open={passportOpen}
ownerPubkey={isBot ? (profile?.ownerPubkey ?? null) : null}
pubkey={pubkey}
/>
) : null}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react";
import {
IdCard,
MessageSquare,
Pencil,
Play,
@@ -35,6 +36,7 @@ export function ProfilePrimaryActions({
onCreateCard,
onEditAgent,
onMessage,
onOpenPassport,
pubkey,
unfollowMutation,
}: {
@@ -50,6 +52,7 @@ export function ProfilePrimaryActions({
onCreateCard?: () => void;
onEditAgent: () => void;
onMessage?: () => void;
onOpenPassport?: () => void;
pubkey: string;
unfollowMutation: ReturnType<typeof useUnfollowMutation>;
}) {
@@ -86,6 +89,14 @@ export function ProfilePrimaryActions({
testId="user-profile-message"
/>
) : null}
{onOpenPassport ? (
<ProfileQuickAction
icon={IdCard}
label="Passport"
onClick={onOpenPassport}
testId="user-profile-passport"
/>
) : null}
{canEditAgent ? (
<ProfileQuickAction
icon={Pencil}
@@ -169,6 +180,28 @@ export function ProfilePersonaPrimaryActions({
);
}
/**
* Actions when viewing your own profile — today just the Passport. A
* dedicated row so self profiles gain identity actions without the
* follow/message set meant for other people.
*/
export function ProfileSelfPrimaryActions({
onOpenPassport,
}: {
onOpenPassport: () => void;
}) {
return (
<div className="flex items-center justify-center gap-8">
<ProfileQuickAction
icon={IdCard}
label="Passport"
onClick={onOpenPassport}
testId="user-profile-passport"
/>
</div>
);
}
function ProfileQuickAction({
active,
disabled,