Skip to content
Merged
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
6 changes: 3 additions & 3 deletions apps/web/src/app/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) };
Expand Down
120 changes: 120 additions & 0 deletions apps/web/src/components/agents-panel-token-result.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>; issue: ReturnType<typeof vi.fn<IssueAccessToken["execute"]>>;
revalidate: ReturnType<typeof vi.fn>; created: unknown; createAction: unknown;
}>(() => ({
session: vi.fn(), issue: vi.fn<IssueAccessToken["execute"]>(), 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<typeof import("react")>();
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<typeof fixture>, 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<typeof fixture>): Promise<TokenRow[]> {
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();
});
});
4 changes: 3 additions & 1 deletion apps/web/src/components/agents-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ export function AgentsPanel({

{!tokens.some(token => !token.revokedAt) && <p className="text-sm text-ink-muted">No active tokens. Create one below when you are ready to connect an agent.</p>}

{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) && (
<div role="status" className="rounded-xl border border-positive/40 p-3 text-sm">
<p className="font-medium text-positive">Copy your token now - it won&apos;t be shown again.</p>
<code className="mt-1 block break-all text-ink">{created.secret}</code>
Expand Down
32 changes: 31 additions & 1 deletion docs/frontend-browser-20261002.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/integration-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/release-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading