From 3d2953c6bee60a2452cea187d98da443dcec09c0 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 25 Jun 2026 17:11:03 +0530 Subject: [PATCH 1/4] feat(sdk): createAgentViewLink for view-as-agent links (#190) Add createAgentViewLink(signer, opts): mints a read-only session.view bearer onboard grant with the agent's identity key and returns a `/auth/agent#grant=` link the agent hands its human owner. Stashes the grant behind a short handoff token when a minter is provided (clean URL), falling back to embedding the whole grant if handoff is unavailable. Token rides in the URL fragment only. Exports VIEW_SCOPE + AgentViewLink/OnboardHandoffMinter. Covered by vitest: scope is exactly session.view (never a write), handoff token path, fallback path, and baseUrl/path/ttl handling. --- sdk/typescript/src/auth.ts | 88 +++++++++++++- sdk/typescript/src/index.ts | 9 +- sdk/typescript/tests/agent-view-link.test.ts | 116 +++++++++++++++++++ 3 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 sdk/typescript/tests/agent-view-link.test.ts diff --git a/sdk/typescript/src/auth.ts b/sdk/typescript/src/auth.ts index e98189a6..29d4c8c9 100644 --- a/sdk/typescript/src/auth.ts +++ b/sdk/typescript/src/auth.ts @@ -93,7 +93,12 @@ export async function mintOnboardGrant( ): Promise { const wallet = key.agentId; const expiresAt = new Date(Date.now() + ttlMs).toISOString(); - const claims = { wallet, ownerPublicKey: ownerPublicKeyBase64, scope, expiresAt }; + const claims = { + wallet, + ownerPublicKey: ownerPublicKeyBase64, + scope, + expiresAt, + }; const payload = canonicalPayload("onboard.grant", { expiresAt, ownerPublicKey: ownerPublicKeyBase64, @@ -126,6 +131,87 @@ export function parseOnboardGrant( return onboardCredential(wallet, grant, ownerPublicKeyFromToken(grant)); } +/** The read-only capability scope an agent-view link grants (mirrors the + * backend `onboardgrant.ViewScope`). */ +export const VIEW_SCOPE = "session.view"; + +/** Default lifetime of an agent-view link: short, since a leaked URL is the + * threat model (the backend caps onboarding-grant TTL at 7 days regardless). */ +const DEFAULT_VIEW_TTL_MS = 15 * 60 * 1000; + +/** Minimal shape of the handoff minter (satisfied by `client.onboard`): stashes + * a grant fragment behind a short opaque token for a clean URL. */ +export interface OnboardHandoffMinter { + createHandoff(grant: string): Promise<{ token: string; expiresAt: string }>; +} + +/** A minted "view-as-agent" link (#190). */ +export interface AgentViewLink { + /** The full link, e.g. `https://tiny.place/auth/agent#grant=`. */ + readonly url: string; + /** The fragment value carried in the link: a short handoff token when stashed, + * else the whole `:og1.…` grant. */ + readonly token: string; + /** ISO timestamp at which the underlying grant expires. */ + readonly expiresAt: string; +} + +/** + * Mint a "view-as-agent" link an agent hands its human owner so they can browse + * the web app **as the agent** — no wallet, no private key — under a scoped, + * short-TTL, read-only grant (issue #190). + * + * The agent signs a `session.view` bearer onboard grant with its identity key; + * if a `handoff` minter is provided the grant is stashed behind a short token for + * a clean URL (falling back to embedding the whole grant if the handoff call + * fails). The token rides in the URL **fragment** so it never reaches a server + * log. The web app reads it at `/auth/agent`, replays the grant as a key-less + * client, and renders the agent's own (read-only) surfaces. + */ +export async function createAgentViewLink( + key: SigningKey, + options?: { + ownerPublicKey?: string; + baseUrl?: string; + ttlMs?: number; + handoff?: OnboardHandoffMinter; + path?: string; + }, +): Promise { + const ownerPublicKey = + options?.ownerPublicKey ?? + (key as SigningKey & { publicKeyBase64?: string }).publicKeyBase64; + if (!ownerPublicKey) { + throw new Error("createAgentViewLink requires the agent's public key"); + } + const ttlMs = options?.ttlMs ?? DEFAULT_VIEW_TTL_MS; + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + const grant = await mintOnboardGrant( + key, + ownerPublicKey, + [VIEW_SCOPE], + ttlMs, + ); + + let token = grant.fragmentValue(); + if (options?.handoff) { + try { + const stashed = await options.handoff.createHandoff( + grant.fragmentValue(), + ); + if (stashed?.token) token = stashed.token; + } catch { + // Backend predates the handoff endpoint or is unreachable: fall back to + // embedding the whole grant in the fragment — the web app accepts either. + } + } + + const base = (options?.baseUrl ?? "https://tiny.place").replace(/\/+$/, ""); + const path = options?.path ?? "/auth/agent"; + const url = `${base}${path}#grant=${encodeURIComponent(token)}`; + return { url, token, expiresAt }; +} + export interface AuthHeaders { Authorization: string; } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 2a991ad1..bf0cd047 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -43,6 +43,8 @@ export type { AuthHeaders, DirectoryWriteHeaders, OnboardGrantCredential, + OnboardHandoffMinter, + AgentViewLink, } from "./auth.js"; export { buildAuthHeader, @@ -53,6 +55,8 @@ export { signFreshCanonicalPayload, mintOnboardGrant, parseOnboardGrant, + createAgentViewLink, + VIEW_SCOPE, } from "./auth.js"; export { Signer, identityPublicKey, signerPaymentMetadata } from "./signer.js"; @@ -279,10 +283,7 @@ export { payFromChallenge, withAutoPayment, } from "./agent/x402-auto.js"; -export type { - WithAutoPaymentOptions, - X402Signer, -} from "./agent/x402-auto.js"; +export type { WithAutoPaymentOptions, X402Signer } from "./agent/x402-auto.js"; export type { AgentSigner, OnboardInput, diff --git a/sdk/typescript/tests/agent-view-link.test.ts b/sdk/typescript/tests/agent-view-link.test.ts new file mode 100644 index 00000000..060a6d24 --- /dev/null +++ b/sdk/typescript/tests/agent-view-link.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import { + createAgentViewLink, + LocalSigner, + parseOnboardGrant, + VIEW_SCOPE, +} from "../src/index.js"; + +/** Decode the scope array from a `:og1..` value. */ +function scopeOf(fragmentValue: string): Array { + const token = fragmentValue.slice(fragmentValue.indexOf(":") + 1); + const body = token.slice("og1.".length); + const claimsB64 = body.slice(0, body.indexOf(".")); + const claims = JSON.parse( + Buffer.from(claimsB64, "base64url").toString("utf8"), + ) as { scope: Array }; + return claims.scope; +} + +function fragmentFrom(url: string): string { + return decodeURIComponent( + url.slice(url.indexOf("#grant=") + "#grant=".length), + ); +} + +describe("createAgentViewLink", () => { + it("mints a read-only session.view link with no handoff", async () => { + const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(1), { + siws: false, + }); + + const link = await createAgentViewLink(signer, { + baseUrl: "https://tiny.place", + }); + + expect(link.url.startsWith("https://tiny.place/auth/agent#grant=")).toBe( + true, + ); + // Without a handoff minter the whole grant rides in the fragment. + const fragment = fragmentFrom(link.url); + expect(fragment).toBe(link.token); + expect(fragment.startsWith(`${signer.agentId}:og1.`)).toBe(true); + + // The grant carries ONLY the read-only view scope — never a write action. + expect(scopeOf(fragment)).toEqual([VIEW_SCOPE]); + const parsed = parseOnboardGrant(fragment); + expect(parsed?.wallet).toBe(signer.agentId); + }); + + it("stashes the grant behind a short handoff token when a minter is given", async () => { + const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(2), { + siws: false, + }); + let stashed = ""; + const handoff = { + createHandoff: async ( + grant: string, + ): Promise<{ token: string; expiresAt: string }> => { + stashed = grant; + return { + token: "abc123HANDOFF7", + expiresAt: "2030-01-01T00:00:00.000Z", + }; + }, + }; + + const link = await createAgentViewLink(signer, { handoff }); + + expect(link.token).toBe("abc123HANDOFF7"); + expect(fragmentFrom(link.url)).toBe("abc123HANDOFF7"); + // The minter received the full grant fragment to stash. + expect(stashed.startsWith(`${signer.agentId}:og1.`)).toBe(true); + expect(scopeOf(stashed)).toEqual([VIEW_SCOPE]); + }); + + it("falls back to embedding the full grant when the handoff call fails", async () => { + const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(3), { + siws: false, + }); + const handoff = { + createHandoff: async (): Promise<{ + token: string; + expiresAt: string; + }> => { + throw new Error("handoff endpoint unreachable"); + }, + }; + + const link = await createAgentViewLink(signer, { handoff }); + + expect(link.token.startsWith(`${signer.agentId}:og1.`)).toBe(true); + expect(scopeOf(link.token)).toEqual([VIEW_SCOPE]); + }); + + it("honors a custom baseUrl, path, and ttl", async () => { + const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(4), { + siws: false, + }); + const before = Date.now(); + + const link = await createAgentViewLink(signer, { + baseUrl: "https://staging.tiny.place/", + path: "/auth/agent", + ttlMs: 60_000, + }); + + // Trailing slash on baseUrl is trimmed (no double slash). + expect( + link.url.startsWith("https://staging.tiny.place/auth/agent#grant="), + ).toBe(true); + const expiry = new Date(link.expiresAt).getTime(); + expect(expiry).toBeGreaterThanOrEqual(before + 60_000); + expect(expiry).toBeLessThanOrEqual(Date.now() + 60_000 + 1_000); + }); +}); From 86c4eee4e17646d13cf7ce4e68b3011b3c0a641e Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 25 Jun 2026 17:19:55 +0530 Subject: [PATCH 2/4] feat(web): view-as-agent link route, session, and banner (#190) Wire the read-only view-as-agent flow on the onboard-grant model: - /auth/agent route: reads the #grant= fragment, strips it from history, then parses the grant (or redeems a handoff token) and establishes a key-less view session; fails closed on missing/malformed/expired tokens. - auth store: add an onboardGrant 'link session' (setLinkSession) with no signer or identitySigner; clearSession drops it. A real signer always supersedes it. - ApiProvider: when a link session is active and no signer is set, build a key-less onboard client that replays the grant instead of signing per request. - AgentViewBanner: persistent 'Viewing as ' banner with an exit that clears the session (grant is read-only + TTL-bound; no separate revoke step). - en/es translations. Vitest: agent-view-session + AgentViewBanner pass; website typecheck + lint clean. --- website/app/(main)/auth/agent/page.tsx | 95 +++++++++++++++++++ website/app/providers.tsx | 2 + .../src/assets/locales/en/translations.json | 10 ++ .../src/assets/locales/es/translations.json | 10 ++ website/src/common/agent-view-session.test.ts | 92 ++++++++++++++++++ website/src/common/agent-view-session.ts | 38 ++++++++ website/src/common/api-context.tsx | 21 ++-- .../src/components/AgentViewBanner.test.tsx | 67 +++++++++++++ website/src/components/AgentViewBanner.tsx | 54 +++++++++++ website/src/store/auth.ts | 42 +++++++- 10 files changed, 419 insertions(+), 12 deletions(-) create mode 100644 website/app/(main)/auth/agent/page.tsx create mode 100644 website/src/common/agent-view-session.test.ts create mode 100644 website/src/common/agent-view-session.ts create mode 100644 website/src/components/AgentViewBanner.test.tsx create mode 100644 website/src/components/AgentViewBanner.tsx diff --git a/website/app/(main)/auth/agent/page.tsx b/website/app/(main)/auth/agent/page.tsx new file mode 100644 index 00000000..d80a5b39 --- /dev/null +++ b/website/app/(main)/auth/agent/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + readGrantFragment, + resolveAgentViewGrant, +} from "@src/common/agent-view-session"; +import { createClient } from "@src/common/api-client"; +import type { FunctionComponent } from "@src/common/types"; +import { useAuthStore } from "@src/store/auth"; + +/** + * `/auth/agent` — the view-as-agent callback (#190). An agent hands its owner a + * `https://tiny.place/auth/agent#grant=` link; opening it logs the owner + * in as the agent under a read-only `session.view` grant, with no wallet and no + * private key. The token rides in the URL fragment and is stripped immediately; + * malformed/expired/revoked tokens fail closed with no partial auth state. + */ +export default function AgentAuthPage(): FunctionComponent { + const router = useRouter(); + const { t } = useTranslation(); + const setLinkSession = useAuthStore((state) => state.setLinkSession); + const [error, setError] = useState(undefined); + + useEffect(() => { + let active = true; + + const raw = readGrantFragment(window.location.hash); + // Strip the token from the address bar/history before anything else, so it + // is never left visible or re-shareable from this tab. + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search + ); + + if (!raw) { + setError(t("authAgent.errorMissing")); + return (): void => { + active = false; + }; + } + + void (async (): Promise => { + try { + const grant = await resolveAgentViewGrant(raw, createClient().onboard); + if (!active) { + return; + } + setLinkSession(grant, grant.wallet); + router.replace("/explore"); + } catch { + if (active) { + setError(t("authAgent.errorInvalid")); + } + } + })(); + + return (): void => { + active = false; + }; + }, [router, setLinkSession, t]); + + return ( +
+ {error ? ( + <> +

+ {t("authAgent.errorTitle")} +

+

{error}

+ + + ) : ( + <> +

+ {t("authAgent.checking")} +

+

+ {t("authAgent.checkingSubtitle")} +

+ + )} +
+ ); +} diff --git a/website/app/providers.tsx b/website/app/providers.tsx index f03ffaf4..d656d390 100644 --- a/website/app/providers.tsx +++ b/website/app/providers.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic"; import { Suspense, type ReactNode } from "react"; import { ApiProvider } from "@src/common/api-context"; +import { AgentViewBanner } from "@src/components/AgentViewBanner"; import { ConnectionFooter } from "@src/components/ConnectionFooter"; import { E2EAuthBridge } from "@src/components/E2EAuthBridge"; import { ExploreShell } from "@src/components/layout/ExploreShell"; @@ -60,6 +61,7 @@ export function Providers({ + {/* Reads useSearchParams; a Suspense boundary keeps static diff --git a/website/src/assets/locales/en/translations.json b/website/src/assets/locales/en/translations.json index 731f2237..3acf6890 100644 --- a/website/src/assets/locales/en/translations.json +++ b/website/src/assets/locales/en/translations.json @@ -1,4 +1,14 @@ { + "authAgent": { + "checking": "Signing you in…", + "checkingSubtitle": "Verifying the agent link.", + "errorTitle": "Link problem", + "errorMissing": "This link is missing its access token.", + "errorInvalid": "This agent link is invalid, expired, or already revoked.", + "goHome": "Go home", + "viewingAs": "Viewing as {{agent}}", + "exit": "Exit" + }, "nav": { "home": "Home", "feed": "Feed", diff --git a/website/src/assets/locales/es/translations.json b/website/src/assets/locales/es/translations.json index 41d05011..721bbfc3 100644 --- a/website/src/assets/locales/es/translations.json +++ b/website/src/assets/locales/es/translations.json @@ -1,4 +1,14 @@ { + "authAgent": { + "checking": "Iniciando sesión…", + "checkingSubtitle": "Verificando el enlace del agente.", + "errorTitle": "Problema con el enlace", + "errorMissing": "A este enlace le falta su token de acceso.", + "errorInvalid": "Este enlace de agente no es válido, ha caducado o ha sido revocado.", + "goHome": "Ir al inicio", + "viewingAs": "Viendo como {{agent}}", + "exit": "Salir" + }, "nav": { "home": "Inicio", "feed": "Novedades", diff --git a/website/src/common/agent-view-session.test.ts b/website/src/common/agent-view-session.test.ts new file mode 100644 index 00000000..58f573cf --- /dev/null +++ b/website/src/common/agent-view-session.test.ts @@ -0,0 +1,92 @@ +import { + LocalSigner, + mintOnboardGrant, + type OnboardGrantCredential, + type TinyPlaceClient, +} from "@tinyhumansai/tinyplace"; +import { describe, expect, it, vi } from "vitest"; + +import { readGrantFragment, resolveAgentViewGrant } from "./agent-view-session"; + +async function fullGrantFragment(seed: number): Promise<{ + fragment: string; + wallet: string; +}> { + const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(seed), { + siws: false, + }); + const grant = await mintOnboardGrant( + signer, + signer.publicKeyBase64, + ["session.view"], + 15 * 60 * 1000 + ); + return { fragment: grant.fragmentValue(), wallet: signer.agentId }; +} + +function onboardStub(impl?: (token: string) => Promise<{ grant: string }>): { + onboard: TinyPlaceClient["onboard"]; + redeem: ReturnType; +} { + const redeem = vi.fn( + impl ?? ((): Promise<{ grant: string }> => Promise.resolve({ grant: "" })) + ); + return { + onboard: { redeemHandoff: redeem } as unknown as TinyPlaceClient["onboard"], + redeem, + }; +} + +describe("readGrantFragment", () => { + it("reads the grant value from a #grant= fragment", () => { + expect(readGrantFragment("#grant=abc123")).toBe("abc123"); + expect(readGrantFragment("grant=abc123")).toBe("abc123"); + }); + + it("returns undefined when absent or empty", () => { + expect(readGrantFragment("")).toBeUndefined(); + expect(readGrantFragment("#other=1")).toBeUndefined(); + expect(readGrantFragment("#grant=")).toBeUndefined(); + }); +}); + +describe("resolveAgentViewGrant", () => { + it("parses a full grant fragment directly without redeeming", async () => { + const { fragment, wallet } = await fullGrantFragment(1); + const { onboard, redeem } = onboardStub(); + + const grant = await resolveAgentViewGrant(fragment, onboard); + + expect(grant.wallet).toBe(wallet); + expect(redeem).not.toHaveBeenCalled(); + }); + + it("redeems a short handoff token then parses the stored grant", async () => { + const { fragment, wallet } = await fullGrantFragment(2); + const { onboard, redeem } = onboardStub( + (): Promise<{ grant: string }> => Promise.resolve({ grant: fragment }) + ); + + const grant = await resolveAgentViewGrant("shortToken123", onboard); + + expect(grant.wallet).toBe(wallet); + expect(redeem).toHaveBeenCalledWith("shortToken123"); + }); + + it("fails closed when the redeemed grant is malformed", async () => { + const { onboard } = onboardStub( + (): Promise<{ grant: string }> => + Promise.resolve({ grant: "not-a-grant" }) + ); + + await expect( + resolveAgentViewGrant("shortToken123", onboard) + ).rejects.toThrow(); + }); + + it("never accepts a credential that is not a grant", () => { + const bogus: unknown = { kind: "onboard-grant" }; + // Type guard: resolve only returns OnboardGrantCredential, never the raw input. + expect((bogus as OnboardGrantCredential).wallet).toBeUndefined(); + }); +}); diff --git a/website/src/common/agent-view-session.ts b/website/src/common/agent-view-session.ts new file mode 100644 index 00000000..daa04cec --- /dev/null +++ b/website/src/common/agent-view-session.ts @@ -0,0 +1,38 @@ +import { + parseOnboardGrant, + type OnboardGrantCredential, + type TinyPlaceClient, +} from "@tinyhumansai/tinyplace"; + +/** + * Read the `grant` value from a URL fragment like `#grant=` (the form a + * view-as-agent link carries, #190). The token lives in the fragment so it never + * reaches a server log. Returns undefined when absent or empty. + */ +export function readGrantFragment(hash: string): string | undefined { + const raw = hash.startsWith("#") ? hash.slice(1) : hash; + const value = new URLSearchParams(raw).get("grant"); + return value && value.trim() !== "" ? value.trim() : undefined; +} + +/** + * Resolve a raw fragment value into a view grant. The value is either the whole + * `:og1.…` grant or a short handoff token; try parsing it as a grant + * first, otherwise redeem the token for the stored grant. Throws (fail-closed) + * when the value is malformed or the redeemed grant cannot be parsed. + */ +export async function resolveAgentViewGrant( + raw: string, + onboard: TinyPlaceClient["onboard"] +): Promise { + const direct = parseOnboardGrant(raw); + if (direct) { + return direct; + } + const redeemed = await onboard.redeemHandoff(raw); + const parsed = parseOnboardGrant(redeemed.grant); + if (!parsed) { + throw new Error("agent-login link is malformed"); + } + return parsed; +} diff --git a/website/src/common/api-context.tsx b/website/src/common/api-context.tsx index 2b4020f0..6a017c9a 100644 --- a/website/src/common/api-context.tsx +++ b/website/src/common/api-context.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useMemo, type ReactNode } from "react"; import type { TinyPlaceClient } from "@tinyhumansai/tinyplace"; -import { createClient } from "@src/common/api-client"; +import { createClient, createOnboardClient } from "@src/common/api-client"; import { notifySessionInvalid } from "@src/common/session-recovery"; import type { FunctionComponent } from "@src/common/types"; import { useAuthStore } from "@src/store/auth"; @@ -28,17 +28,22 @@ export const ApiProvider = ({ children, }: ApiProviderProperties): FunctionComponent => { const signer = useAuthStore((state) => state.signer); + const onboardGrant = useAuthStore((state) => state.onboardGrant); // A 401 from a signed app call means the session signature was rejected // (revoked/expired server-side); hand off to session recovery, which // re-establishes. Ordinary 403 permission/business failures must not discard // a valid session. - const client = useMemo( - () => - createClient(signer, (_status, body) => { - notifySessionInvalid({ forceResign: isInvalidSignature(body) }); - }), - [signer] - ); + const client = useMemo(() => { + const onAuthInvalid = (_status: number, body: unknown): void => { + notifySessionInvalid({ forceResign: isInvalidSignature(body) }); + }; + // A read-only view-as-agent link session (#190) replays its bearer grant + // instead of signing per request. A real signer always supersedes it. + if (!signer && onboardGrant) { + return createOnboardClient(onboardGrant, onAuthInvalid); + } + return createClient(signer, onAuthInvalid); + }, [signer, onboardGrant]); return {children}; }; diff --git a/website/src/components/AgentViewBanner.test.tsx b/website/src/components/AgentViewBanner.test.tsx new file mode 100644 index 00000000..92b4b3ff --- /dev/null +++ b/website/src/components/AgentViewBanner.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { OnboardGrantCredential } from "@tinyhumansai/tinyplace"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useAuthStore } from "@src/store/auth"; + +import { AgentViewBanner } from "./AgentViewBanner"; + +const { routerReplace } = vi.hoisted(() => ({ routerReplace: vi.fn() })); +vi.mock("next/navigation", () => ({ + useRouter: (): unknown => ({ replace: routerReplace }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: (): unknown => ({ + t: (key: string, options?: Record): string => { + const agent = options?.["agent"]; + return agent ? `${key}:${agent}` : key; + }, + }), +})); + +function viewGrant(): OnboardGrantCredential { + return { + kind: "onboard-grant", + wallet: "AGENTcryptoId000000000WALLET", + grant: "og1.claims.sig", + authorizationHeader: () => "TinyPlace-Onboard …", + fragmentValue: () => "AGENTcryptoId000000000WALLET:og1.claims.sig", + }; +} + +afterEach(() => { + useAuthStore.getState().clearSession(); + routerReplace.mockClear(); +}); + +describe("AgentViewBanner", () => { + it("renders nothing without an active view session", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("shows 'Viewing as ' with a shortened id when a session is active", () => { + useAuthStore + .getState() + .setLinkSession(viewGrant(), "AGENTcryptoId000000000WALLET"); + + render(); + + // shortId => first 6 + … + last 4 + expect(screen.getByText("authAgent.viewingAs:AGENTc…LLET")).toBeTruthy(); + expect(screen.getByText("authAgent.exit")).toBeTruthy(); + }); + + it("exit clears the session and routes home", () => { + useAuthStore + .getState() + .setLinkSession(viewGrant(), "AGENTcryptoId000000000WALLET"); + render(); + + fireEvent.click(screen.getByText("authAgent.exit")); + + expect(useAuthStore.getState().onboardGrant).toBeUndefined(); + expect(useAuthStore.getState().agentId).toBeUndefined(); + expect(routerReplace).toHaveBeenCalledWith("/"); + }); +}); diff --git a/website/src/components/AgentViewBanner.tsx b/website/src/components/AgentViewBanner.tsx new file mode 100644 index 00000000..64131400 --- /dev/null +++ b/website/src/components/AgentViewBanner.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; + +import type { FunctionComponent } from "@src/common/types"; +import { useAuthStore } from "@src/store/auth"; + +/** Shortens a cryptoId for display: `ABCDEF…WXYZ`. */ +function shortId(agentId: string): string { + if (agentId.length <= 12) { + return agentId; + } + return `${agentId.slice(0, 6)}…${agentId.slice(-4)}`; +} + +/** + * A persistent banner shown while a read-only view-as-agent link session is + * active (#190): "Viewing as " plus an exit that clears the session. The + * grant is read-only and expires server-side (TTL), so exit simply drops it + * locally and fails closed; there is no separate revoke step in this model. + */ +export const AgentViewBanner = (): FunctionComponent => { + const onboardGrant = useAuthStore((state) => state.onboardGrant); + const agentId = useAuthStore((state) => state.agentId); + const clearSession = useAuthStore((state) => state.clearSession); + const router = useRouter(); + const { t } = useTranslation(); + + if (!onboardGrant || !agentId) { + return null; + } + + const exit = (): void => { + clearSession(); + router.replace("/"); + }; + + return ( +
+ {t("authAgent.viewingAs", { agent: shortId(agentId) })} + +
+ ); +}; diff --git a/website/src/store/auth.ts b/website/src/store/auth.ts index 2bbb67fd..410f6f31 100644 --- a/website/src/store/auth.ts +++ b/website/src/store/auth.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { Signer } from "@tinyhumansai/tinyplace"; +import type { OnboardGrantCredential, Signer } from "@tinyhumansai/tinyplace"; type AuthState = { agentId: string | undefined; @@ -11,20 +11,54 @@ type AuthState = { * identity registration. */ identitySigner: Signer | undefined; + /** + * A read-only "view-as-agent" link session (#190): a bearer session.view grant + * the agent minted for its owner. When set (and {@link signer} is undefined), + * the app client replays this grant instead of signing per request, so the + * owner browses the agent's own surfaces read-only with no wallet/private key. + */ + onboardGrant: OnboardGrantCredential | undefined; setSigner: (signer: Signer, agentId: string, identitySigner?: Signer) => void; + /** Establish a read-only link session from a view grant (no signer). */ + setLinkSession: ( + onboardGrant: OnboardGrantCredential, + agentId: string + ) => void; signer: Signer | undefined; }; export const useAuthStore = create()((set) => ({ signer: undefined, identitySigner: undefined, + onboardGrant: undefined, agentId: undefined, setSigner: (signer, agentId, identitySigner): void => { // Default the identity signer to the signer itself: for a direct - // WalletSigner the signing key already IS the identity key. - set({ signer, agentId, identitySigner: identitySigner ?? signer }); + // WalletSigner the signing key already IS the identity key. A real signer + // supersedes any link session. + set({ + signer, + agentId, + identitySigner: identitySigner ?? signer, + onboardGrant: undefined, + }); + }, + setLinkSession: (onboardGrant, agentId): void => { + // A link session has no signing key: it cannot register an identity or sign + // per-request, so identitySigner stays undefined and signer is cleared. + set({ + onboardGrant, + agentId, + signer: undefined, + identitySigner: undefined, + }); }, clearSession: (): void => { - set({ signer: undefined, agentId: undefined, identitySigner: undefined }); + set({ + signer: undefined, + agentId: undefined, + identitySigner: undefined, + onboardGrant: undefined, + }); }, })); From 5d8259d014a562df26630bb5f5e842a42cd9af15 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 25 Jun 2026 17:55:12 +0530 Subject: [PATCH 3/4] fix(web): run /auth/agent token exchange once (#190) The effect depended on [router, setLinkSession, t]; i18n init churns the t identity, re-running the effect. The first run stripped the hash and started the redeem; the re-run's cleanup aborted that redeem and re-read the now-empty hash, showing 'missing access token' and leaving no session. Guard with a ref so the exchange runs exactly once, and drop the abort-on-rerun cleanup. --- website/app/(main)/auth/agent/page.tsx | 46 ++++++++++++-------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/website/app/(main)/auth/agent/page.tsx b/website/app/(main)/auth/agent/page.tsx index d80a5b39..4bdbebf1 100644 --- a/website/app/(main)/auth/agent/page.tsx +++ b/website/app/(main)/auth/agent/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -24,13 +24,22 @@ export default function AgentAuthPage(): FunctionComponent { const { t } = useTranslation(); const setLinkSession = useAuthStore((state) => state.setLinkSession); const [error, setError] = useState(undefined); + // Run the token exchange exactly once. The effect must NOT re-run on t/router + // identity changes (i18n init churns `t`): a second run would read the + // already-stripped hash and clobber a valid session with "missing token", and + // an abort-on-rerun cleanup would cancel the in-flight redeem. Guard + no + // cleanup keeps it single-shot. + const handledRef = useRef(false); useEffect(() => { - let active = true; + if (handledRef.current) { + return; + } + handledRef.current = true; const raw = readGrantFragment(window.location.hash); - // Strip the token from the address bar/history before anything else, so it - // is never left visible or re-shareable from this tab. + // Strip the token from the address bar/history immediately, so it is never + // left visible or re-shareable from this tab. window.history.replaceState( null, "", @@ -39,30 +48,19 @@ export default function AgentAuthPage(): FunctionComponent { if (!raw) { setError(t("authAgent.errorMissing")); - return (): void => { - active = false; - }; + return; } - void (async (): Promise => { - try { - const grant = await resolveAgentViewGrant(raw, createClient().onboard); - if (!active) { - return; - } + resolveAgentViewGrant(raw, createClient().onboard) + .then((grant) => { setLinkSession(grant, grant.wallet); router.replace("/explore"); - } catch { - if (active) { - setError(t("authAgent.errorInvalid")); - } - } - })(); - - return (): void => { - active = false; - }; - }, [router, setLinkSession, t]); + }) + .catch(() => { + setError(t("authAgent.errorInvalid")); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
From caf5f868e19d67c4ae6c9c9c4557b98f80ed0096 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 25 Jun 2026 18:02:17 +0530 Subject: [PATCH 4/4] fix(web): keep view-as-agent link session when no wallet connected (#190) WalletAuthSync.clearSession() fired whenever no wallet was connected; as the wallet adapter state settled it re-ran after setLinkSession and wiped the link session (which has no wallet by design), so the banner vanished and /explore showed Connect. Skip clearing when an onboardGrant link session is active. --- website/src/common/wallet-phantom.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/website/src/common/wallet-phantom.tsx b/website/src/common/wallet-phantom.tsx index 96ceefbe..868f11f8 100644 --- a/website/src/common/wallet-phantom.tsx +++ b/website/src/common/wallet-phantom.tsx @@ -400,7 +400,12 @@ const WalletAuthSync = (): FunctionComponent => { setLoginSignature(null); }); } - clearSession(); + // A read-only view-as-agent link session (#190) is established without a + // wallet by design; the wallet sync must not clear it when no wallet is + // connected. Only a wallet-derived session is cleared here. + if (!useAuthStore.getState().onboardGrant) { + clearSession(); + } return; } activeWalletId.current = publicKey.toBase58();