Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion sdk/typescript/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ export async function mintOnboardGrant(
): Promise<OnboardGrantCredential> {
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,
Expand Down Expand Up @@ -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=<token>`. */
readonly url: string;
/** The fragment value carried in the link: a short handoff token when stashed,
* else the whole `<wallet>: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<AgentViewLink> {
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,
Comment on lines +187 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject unsupported TTL values up front.

Lines 187-193 accept any number for ttlMs. That includes <= 0, NaN, and values above the documented 7-day backend cap, which lets the SDK mint links whose advertised expiresAt is already invalid or longer than the backend will honor.

Suggested fix
   const ttlMs = options?.ttlMs ?? DEFAULT_VIEW_TTL_MS;
+  const maxTtlMs = 7 * 24 * 60 * 60 * 1000;
+  if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > maxTtlMs) {
+    throw new Error("createAgentViewLink ttlMs must be between 1 and 604800000");
+  }
   const expiresAt = new Date(Date.now() + ttlMs).toISOString();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
const ttlMs = options?.ttlMs ?? DEFAULT_VIEW_TTL_MS;
const maxTtlMs = 7 * 24 * 60 * 60 * 1000;
if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > maxTtlMs) {
throw new Error("createAgentViewLink ttlMs must be between 1 and 604800000");
}
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
const grant = await mintOnboardGrant(
key,
ownerPublicKey,
[VIEW_SCOPE],
ttlMs,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/typescript/src/auth.ts` around lines 187 - 193, The TTL handling in
auth.ts should reject invalid values before computing expiresAt or calling
mintOnboardGrant. In the grant creation flow that uses ttlMs and
DEFAULT_VIEW_TTL_MS, validate that options.ttlMs is a finite positive number and
does not exceed the backend’s 7-day cap; otherwise throw a clear error instead
of minting the link. Keep the check close to the ttlMs assignment in the same
grant-generation path so the invalid values are blocked before expiresAt is
derived.

);

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 };
Comment on lines +197 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Return the effective handoff expiry.

Lines 197-212 ignore stashed.expiresAt and always return the grant TTL. When the link carries a handoff token, /auth/agent has to redeem that token first, so a shorter handoff expiry makes link.expiresAt overstate how long the link is actually usable.

Suggested fix
-  const expiresAt = new Date(Date.now() + ttlMs).toISOString();
+  let 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;
+      if (stashed?.token) {
+        token = stashed.token;
+        expiresAt = stashed.expiresAt;
+      }
     } catch {
       // Backend predates the handoff endpoint or is unreachable: fall back to
       // embedding the whole grant in the fragment — the web app accepts either.
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 };
let 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;
expiresAt = stashed.expiresAt;
}
} catch {
// Backend predates the handoff endpoint or is unreachable: fall back to
// embedding the whole grant in the fragment — the web app accepts either.
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/typescript/src/auth.ts` around lines 197 - 212, The link builder in the
auth flow is returning the grant’s TTL even when a handoff token is created, so
the effective expiry can be overstated; update the logic in the handoff block
inside the auth URL construction path to track `stashed.expiresAt` from
`options.handoff.createHandoff(...)` and return the earliest effective expiry
when `token` is replaced. Keep the fallback behavior unchanged, but ensure the
final `expiresAt` reflects the handoff token’s lifetime when present, using the
existing `options.handoff`, `stashed.token`, and `expiresAt` handling in
`auth.ts`.

}

export interface AuthHeaders {
Authorization: string;
}
Expand Down
9 changes: 5 additions & 4 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type {
AuthHeaders,
DirectoryWriteHeaders,
OnboardGrantCredential,
OnboardHandoffMinter,
AgentViewLink,
} from "./auth.js";
export {
buildAuthHeader,
Expand All @@ -53,6 +55,8 @@ export {
signFreshCanonicalPayload,
mintOnboardGrant,
parseOnboardGrant,
createAgentViewLink,
VIEW_SCOPE,
} from "./auth.js";

export { Signer, identityPublicKey, signerPaymentMetadata } from "./signer.js";
Expand Down Expand Up @@ -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,
Expand Down
116 changes: 116 additions & 0 deletions sdk/typescript/tests/agent-view-link.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<wallet>:og1.<b64url(claims)>.<sig>` value. */
function scopeOf(fragmentValue: string): Array<string> {
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<string> };
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);
});
});
93 changes: 93 additions & 0 deletions website/app/(main)/auth/agent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { useRouter } from "next/navigation";
import { useEffect, useRef, 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=<token>` 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<string | undefined>(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(() => {
if (handledRef.current) {
return;
}
handledRef.current = true;

const raw = readGrantFragment(window.location.hash);
// 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,
"",
window.location.pathname + window.location.search
);

if (!raw) {
setError(t("authAgent.errorMissing"));
return;
}

resolveAgentViewGrant(raw, createClient().onboard)
.then((grant) => {
setLinkSession(grant, grant.wallet);
router.replace("/explore");
})
.catch(() => {
setError(t("authAgent.errorInvalid"));
});
Comment on lines +54 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Don’t collapse every redeem failure into “invalid token”.

resolveAgentViewGrant() can fail for transient transport/backend reasons too, but this path always shows authAgent.errorInvalid. Because the hash is already stripped by then, a valid link looks permanently broken from this tab. Please keep a separate retryable error state for non-auth failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/app/`(main)/auth/agent/page.tsx around lines 54 - 61, The
resolveAgentViewGrant() flow in the agent auth page is treating every rejection
as an invalid token, which hides retryable transport/backend failures. Update
the .catch handling in the auth page component to distinguish auth/invalid-grant
failures from transient ones, and keep a separate error state/message for
non-auth errors so the user can retry instead of seeing authAgent.errorInvalid
for everything.

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div className="mx-auto flex min-h-[40vh] max-w-md flex-col items-center justify-center gap-3 px-6 text-center">
{error ? (
<>
<h1 className="text-lg font-semibold text-danger">
{t("authAgent.errorTitle")}
</h1>
<p className="text-sm text-muted">{error}</p>
<button
className="mt-2 rounded-md bg-primary px-4 py-2 text-sm text-white hover:bg-primary-hover"
type="button"
onClick={(): void => router.replace("/")}
>
{t("authAgent.goHome")}
</button>
</>
) : (
<>
<h1 className="text-lg font-semibold text-front">
{t("authAgent.checking")}
</h1>
<p className="text-sm text-muted">
{t("authAgent.checkingSubtitle")}
</p>
</>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions website/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,6 +61,7 @@ export function Providers({
<QueryClientProvider client={queryClient}>
<WalletContextProvider>
<ApiProvider>
<AgentViewBanner />
<ThemeController />
<LocaleController />
{/* Reads useSearchParams; a Suspense boundary keeps static
Expand Down
10 changes: 10 additions & 0 deletions website/src/assets/locales/en/translations.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions website/src/assets/locales/es/translations.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading