-
Notifications
You must be signed in to change notification settings - Fork 20
feat: agent-generated login link — view-as-agent (#190) #194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3d2953c
86c4eee
5d8259d
caf5f86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface AuthHeaders { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Authorization: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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); | ||
| }); | ||
| }); |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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”.
🤖 Prompt for AI Agents |
||
| // 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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 advertisedexpiresAtis already invalid or longer than the backend will honor.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents