From a879103535b2dfb7d46b63c5d41b7d5ccecdd65f Mon Sep 17 00:00:00 2001 From: Jordan Kail <13952435+jckail@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:16:30 -0700 Subject: [PATCH] Hide one-time token display after confirmed matching revocation --- apps/web/src/app/agent-actions.ts | 6 +- .../agents-panel-token-result.test.ts | 120 ++++++++++++++++++ apps/web/src/components/agents-panel.tsx | 4 +- docs/frontend-browser-20261002.md | 32 ++++- docs/integration-status.md | 2 +- docs/release-backlog.md | 2 +- 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/agents-panel-token-result.test.ts diff --git a/apps/web/src/app/agent-actions.ts b/apps/web/src/app/agent-actions.ts index 1d648ea..65d5665 100644 --- a/apps/web/src/app/agent-actions.ts +++ b/apps/web/src/app/agent-actions.ts @@ -20,7 +20,7 @@ import { getContainer } from "@/server/container"; export type CreateTokenResult = | ActionResult - | { status: "created"; secret: string }; + | { status: "created"; secret: string; tokenId: string }; export async function createAccessTokenAction( _previous: CreateTokenResult, @@ -32,14 +32,14 @@ export async function createAccessTokenAction( const scopes = formData.getAll("scopes").map(String).filter(isScope); const ttl = Number(formData.get("ttlDays") ?? ""); try { - const { plaintext } = await getContainer().useCases.issueAccessToken.execute({ + const { token, plaintext } = await getContainer().useCases.issueAccessToken.execute({ userId, name: String(formData.get("name") ?? ""), scopes: scopes.length ? scopes : [], ttlDays: Number.isFinite(ttl) && ttl > 0 ? ttl : undefined, }); revalidatePath("/dashboard/agents"); - return { status: "created", secret: plaintext }; + return { status: "created", secret: plaintext, tokenId: token.id }; } catch (error) { if (error instanceof DomainError) { return { status: "error", message: messageForDomainError(error.code) }; diff --git a/apps/web/src/components/agents-panel-token-result.test.ts b/apps/web/src/components/agents-panel-token-result.test.ts new file mode 100644 index 0000000..b13ef5b --- /dev/null +++ b/apps/web/src/components/agents-panel-token-result.test.ts @@ -0,0 +1,120 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hashToken, IssueAccessToken, ListAccessTokens, RevokeAccessToken, UserId } from "@pointup/core"; +import { InMemoryTokens, RecordingEventing } from "../../../../packages/core/test/fakes"; + +const state = vi.hoisted<{ + session: ReturnType; issue: ReturnType>; + revalidate: ReturnType; created: unknown; createAction: unknown; +}>(() => ({ + session: vi.fn(), issue: vi.fn(), revalidate: vi.fn(), + created: { status: "idle" }, createAction: undefined, +})); +vi.mock("@/server/auth", () => ({ getSessionUserId: state.session })); +vi.mock("@/server/container", () => ({ getContainer: () => ({ useCases: { issueAccessToken: { execute: state.issue } } }) })); +vi.mock("next/cache", () => ({ revalidatePath: state.revalidate })); +// Supply a completed action state to the actual component. React renders its +// real subtree; this seam does not emulate dispatch, hydration or revalidation. +vi.mock("react", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useActionState: (action: unknown, initialState: unknown) => [action === state.createAction ? state.created : initialState, () => {}, false] }; +}); +import { createAccessTokenAction } from "@/app/agent-actions"; +import { AgentsPanel, type TokenRow } from "./agents-panel"; + +const owner = UserId.parse("synthetic-token-display-owner"); +const clock = { now: () => new Date("2026-10-03T00:00:00Z") }; +function form(name = "Synthetic agent") { + const data = new FormData(); data.set("name", name); data.append("scopes", "portfolio:read"); data.set("ttlDays", "30"); return data; +} +function fixture() { + const tokens = new InMemoryTokens(); const eventing = new RecordingEventing(); + const issue = new IssueAccessToken(tokens, clock, eventing); + const list = new ListAccessTokens(tokens); const revoke = new RevokeAccessToken(tokens, clock, eventing); + state.issue.mockImplementation(input => issue.execute(input)); + return { tokens, eventing, list, revoke }; +} +async function create(f: ReturnType, name = "Synthetic agent") { + const result = await createAccessTokenAction({ status: "idle" }, form(name)); + if (result.status !== "created") throw new Error("Synthetic token creation failed"); + const rows = await f.list.execute(owner); + const token = rows.find(row => row.name === name); + if (!token) throw new Error("Synthetic created token missing"); + return { result, token }; +} +async function rows(f: ReturnType): Promise { + return (await f.list.execute(owner)).map(row => ({ ...row, scopes: [...row.scopes] })); +} +function render(tokens: TokenRow[]) { + return renderToStaticMarkup(createElement(AgentsPanel, { + tokens, consents: [], observations: [], pendingReviews: [], providers: [], mcpUrl: "http://localhost:8787/mcp", + })); +} +beforeEach(() => { + vi.resetAllMocks(); state.session.mockResolvedValue(owner); + state.createAction = createAccessTokenAction; state.created = { status: "idle" }; +}); + +describe("actual token creation result and rendered one-time credential", () => { + it("returns the actual issued token ID alongside its matching plaintext, without private stored fields", async () => { + const f = fixture(); const { result, token } = await create(f); + expect(result).toEqual({ status: "created", secret: result.secret, tokenId: token.id }); + const stored = f.tokens.rows.get(token.id); + expect(stored?.userId).toBe(owner); expect(stored?.tokenHash).toBe(await hashToken(result.secret)); + expect(Object.keys(result).sort()).toEqual(["secret", "status", "tokenId"]); + expect(f.eventing.types()).toEqual(["token.issued"]); + expect(state.revalidate).toHaveBeenCalledExactlyOnceWith("/dashboard/agents"); + }); + + it("removes the created plaintext and copy guidance when the same token is authoritatively revoked", async () => { + const f = fixture(); const { result, token } = await create(f); state.created = result; + expect(render(await rows(f))).toContain(result.secret); + await f.revoke.execute(owner, token.id); + const authoritative = await rows(f); + expect(authoritative.find(row => row.id === token.id)?.revokedAt).toEqual(clock.now()); + const html = render(authoritative); + expect(html).not.toContain(result.secret); expect(html).not.toContain("Copy your token now"); + expect(html).toContain("No active tokens."); + }); + + it("preserves the created credential when another token is revoked", async () => { + const f = fixture(); const first = await create(f); const other = await create(f, "Other synthetic agent"); + const otherStored = f.tokens.rows.get(other.token.id); + if (!otherStored) throw new Error("Synthetic other token missing"); + // Labels/prefixes can collide; only the immutable actual ID may hide state. + await f.tokens.update({ ...otherStored, name: first.token.name, displayPrefix: first.token.displayPrefix }); + state.created = first.result; await f.revoke.execute(owner, other.token.id); + const authoritative = await rows(f); + expect(authoritative.find(row => row.id === other.token.id)).toMatchObject({ name: first.token.name, displayPrefix: first.token.displayPrefix, revokedAt: clock.now() }); + const html = render(authoritative); + expect(html).toContain(first.result.secret); expect(html).toContain("Copy your token now"); + expect(html).not.toContain(other.result.secret); + }); + + it("preserves the created credential when the same token is still unrevoked", async () => { + const f = fixture(); const { result, token } = await create(f); state.created = result; + expect(token.revokedAt).toBeNull(); + const html = render(await rows(f)); + expect(html).toContain(result.secret); expect(html).toContain("Copy your token now"); + }); + + it("preserves the creation result when the authoritative token list has not yet included that ID", async () => { + const f = fixture(); const { result } = await create(f); state.created = result; + const html = render([]); + expect(html).toContain(result.secret); expect(html).toContain("Copy your token now"); + }); + + it("renders actual failed creation feedback instead of a stale plaintext block", async () => { + const f = fixture(); const { result } = await create(f); state.created = result; + expect(render(await rows(f))).toContain(result.secret); + state.revalidate.mockClear(); + const failed = await createAccessTokenAction(result, form("")); state.created = failed; + expect(failed).toEqual({ status: "error", message: "Give the token a name, at least one scope, and a lifetime of 1-365 days." }); + const html = render(await rows(f)); + expect(html).toContain('role="alert"'); expect(html).toContain("Give the token a name"); + expect(html).not.toContain(result.secret); expect(html).not.toContain("Copy your token now"); + expect(f.tokens.rows.size).toBe(1); expect(f.eventing.types()).toEqual(["token.issued"]); + expect(state.revalidate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/agents-panel.tsx b/apps/web/src/components/agents-panel.tsx index 93e1b2f..c9aca65 100644 --- a/apps/web/src/components/agents-panel.tsx +++ b/apps/web/src/components/agents-panel.tsx @@ -198,7 +198,9 @@ export function AgentsPanel({ {!tokens.some(token => !token.revokedAt) &&

No active tokens. Create one below when you are ready to connect an agent.

} - {created.status === "created" && ( + {/* A confirmed revocation hides only that token's one-time result. + A different token's revocation or a failed request must preserve it. */} + {created.status === "created" && !tokens.some(token => token.id === created.tokenId && token.revokedAt !== null) && (

Copy your token now - it won't be shown again.

{created.secret} diff --git a/docs/frontend-browser-20261002.md b/docs/frontend-browser-20261002.md index 72e062f..4fe0542 100644 --- a/docs/frontend-browser-20261002.md +++ b/docs/frontend-browser-20261002.md @@ -59,7 +59,7 @@ Curated receipt: `/tmp/pointup-native-frontend-evidence-20261002.json`. | CSV/JSON | Malformed CSV showed error without changing account count; quoted multiline Delta membership imported and re-exported with quoting; endpoints returned 200/content types/attachment metadata | Full snapshot round trip and all malformed-row cases; notes are outside CSV format | | Share | Actual share page hid memberships/notes; Revoke remained after last unlink; revocation made public fetch return 404 | Expired links and production anonymous/auth separation | | Opportunities/expiry | Estimate caveats; explicitly seeded local expiry produced warning and calendar link; calendar returned 200 with valid VCALENDAR/United | Live award/provider integration and complete optimizer interactions | -| Agent access | UI created read-only one-day PAT; full token read returned 200 and write returned 403 INSUFFICIENT_SCOPE; revoke removed active row/secret; consent granted then revoked | Held observation/review and cross-owner native scenarios | +| Agent access | UI created read-only one-day PAT; full token read returned 200 and write returned 403 INSUFFICIENT_SCOPE; revoke removed active row and denied the token; navigation cleared one-time plaintext; consent granted then revoked | Held observation/review and cross-owner native scenarios | | Assistant | Actual panel Enter/send returned fallback chat 200; successful correlated run-start/run-complete events; clearing persisted across navigation/reopen | Live Agents SDK model calls, recovery faults, generated proposal approval/reject/expiry and exporter delivery | | Settings | Actual unconfigured ChatGPT-linking state and capability boundaries rendered | Real Clerk/OpenAI identity acceptance | | Responsive | No page overflow at verified 390px dashboard/detail/share/agents/settings; desktop 1440px dashboard had three-column grid and no overflow | Full axe/contrast/focus/screen-reader and other narrow routes | @@ -134,3 +134,33 @@ fixtures blindly or bypassed the shared admission gate. Production, identity, model, native extension, data and licensing gates remain in [release-backlog.md](release-backlog.md). Shared Graphify still excludes PointUp and its semantic index remains held; this work verified live source. + + +## Authentication fix release and token-display follow-up + +[PR #50](https://github.com/jckail/point_bot/pull/50) is merged from source +`385cba8313a77bdfcf909cdfafb6bcdc67d5d317` at master +`a88b001b222c49ab450947d5fd5160d07580d997`; source, prospective merge and +master share tree `b149f2f3bfd21c72167abb2949f2cf623bf8a6f8`. Candidate CI +37070296895, CodeQL 37070296840, Bugbot, master Deploy 37070787761 and +CodeQL 37070787313 passed. Candidate and master each ran 1,851 workspace +tests plus one paid live skip. AWS deployment remained skipped for missing role. + +The patched native pass also found that the creation result still displayed +one-time plaintext after revoking that exact token on the same page. The token +was immediately denied with 401 and navigation cleared the result. A narrow +follow-up associates the creation result with its nonsecret token ID and +hides it after an authoritative same-ID revoked row is returned. Other-token +revocation and failed requests must preserve a still-valid creation result. +This changes rendered visibility, without claiming JavaScript memory zeroization. + + +The display follow-up reproduced the original source defect with two failing +and four passing cases. The patched six-case regression exercises real token +issue/list/revoke use cases and the actual server action, then renders the actual +React panel with a controlled completed `useActionState` value. It verifies the +matching-token case, unrelated same-name/prefix token, active token, absent list +entry and real creation error. This is rendered-output coverage; native +dispatch, hydration and server revalidation after this follow-up remain pending. +The focused run including share-revocation controls passed 12 cases; web types +and targeted lint passed after correcting two test-only type-assertion findings. diff --git a/docs/integration-status.md b/docs/integration-status.md index ab5e793..36203d2 100644 --- a/docs/integration-status.md +++ b/docs/integration-status.md @@ -18,7 +18,7 @@ share/unlink/restore, access/consent, offline assistant and responsive flows. Its scope, evidence and open cases are in [frontend-browser-20261002.md](frontend-browser-20261002.md). It found invalid PAT errors mapping to 500; the reviewed genuine-object identity fix passes 52 targeted tests, core/web types and lint. A restarted real app now verifies 401 invalid/revoked-token responses and preserves -valid read 200/write-scope 403. The new iteration's release gates remain pending. The full overhaul is unfinished. +valid read 200/write-scope 403. The authentication fix is merged in [PR #50](https://github.com/jckail/point_bot/pull/50), master `a88b001b222c49ab450947d5fd5160d07580d997`, with 1,851 workspace tests plus one paid live skip and successful release/CodeQL checks. AWS deployment remains skipped for missing role configuration. The full overhaul is unfinished. Pending expiry/rejection now returns safe conditional-transition receipts. The diff --git a/docs/release-backlog.md b/docs/release-backlog.md index ac7ed3e..a90bd6f 100644 --- a/docs/release-backlog.md +++ b/docs/release-backlog.md @@ -401,7 +401,7 @@ share/unlink/restore, access/consent, offline assistant and responsive flows. Its scope, evidence and open cases are in [frontend-browser-20261002.md](frontend-browser-20261002.md). It found invalid PAT errors mapping to 500; the reviewed genuine-object identity fix passes 52 targeted tests, core/web types and lint. A restarted real app now verifies 401 invalid/revoked-token responses and preserves -valid read 200/write-scope 403. The new iteration's release gates remain pending. The full overhaul is unfinished. +valid read 200/write-scope 403. The authentication fix is merged in [PR #50](https://github.com/jckail/point_bot/pull/50), master `a88b001b222c49ab450947d5fd5160d07580d997`, with 1,851 workspace tests plus one paid live skip and successful release/CodeQL checks. AWS deployment remains skipped for missing role configuration. The full overhaul is unfinished. The current follow-up returns exact source requirements, missing-route/card