From a5e032479a124755de3ebd2809e3773b925482c8 Mon Sep 17 00:00:00 2001 From: James Waugh Date: Thu, 5 Nov 2026 01:52:11 -0500 Subject: [PATCH] Owner actions as a wallet owner's explicit opt-in, at connect or by replacing the connection --- README.md | 2 +- apps/server/src/consent.ts | 28 +++++++++------ apps/server/src/contract.ts | 2 ++ apps/server/src/http.ts | 17 +++++---- apps/server/test/consent.test.ts | 6 ++-- apps/server/test/http.test.ts | 23 ++++++++++++ web/PLATFORM-SESSION.md | 2 ++ web/src/proposals/connection.tsx | 58 +++++++++++++++++++----------- web/src/proposals/proposals-api.ts | 3 +- web/test/proposals-api.test.ts | 9 +++++ web/test/proposals.e2e.ts | 21 +++++++++++ 11 files changed, 131 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 10c92b0..7117572 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ SDK and platform feedback goes to [MultiAgency/platform](https://github.com/Mult - **Owner sign-in:** a NEP-413 challenge addressed to this server, verified with `near-kit` before any chain call, then a full-access key of the signing account on chain (FastNEAR's RPC, configured as the platform's `src/chain.ts`). Challenges are stateless (the nonce carries its issue time and an HMAC under a per-process key), so issuing one stores nothing and a flood can't crowd out an owner's; each is single-use once validly signed (only its canonical base64 text is accepted) and expires in 5 minutes. Challenges and sign-in attempts are each rate-limited per client (an IPv4 address, or an IPv6 /64). - **Sessions:** a `Secure`, httpOnly, `SameSite=Strict` cookie, ended after 30 minutes idle, 12 hours or sign-out, and swept when abandoned; in memory, so a restart signs everyone out. Signing in again ends the previous session, and sign-out always clears the cookie, with or without a body. Every write checks `Origin` and requires JSON. - **Per-agent authorization:** every `/api/agents/:agent` route checks that the signed-in account is the agent's owner, as the platform answers it, and that the agent's identity names this deployment. The platform allows a credential 1,200 calls an hour, shared by every owner, so a successful answer is reused for up to a minute per agent (never a failure, so an agent set up a moment later is reachable at once), concurrent reads share one call, and each account may make 60 owner-route requests a minute. A reused answer that would refuse the account is read once more anew, so a new owner isn't locked out after a transfer. Routes that answer from the server's own records (`owner-fresh`, from PR 2) always read it anew. A missing agent and someone else's get the same answer, `not_owner`; any other platform failure is `unavailable`, as is an identity naming another deployment (this server misconfigured). The platform's error text is logged, never sent to the browser. -- **Connecting an agent:** the server builds the consent text from the agent's identity as the platform answers it now (`read` and `treasury` for 90 days, under the owner's Organization: their account with each `.` written as `--`), and the owner's wallet signs it for the platform's host. An agent already connected isn't offered another signature. The server relays only that exact consent, submits the same signed body once more if the answer is lost, revokes the owner's older consents for the agent once a new one is stored, and limits each account to `CONSENTS_PER_DAY` new consents (the platform caps a deployment's unexpired consents, revoked ones included). A consent counts as the agent's only for its current ID, with `read` and `treasury`, unrevoked and unexpired. Disconnecting revokes those, at most five a request, found in the platform's own list, never by an ID from the browser. +- **Connecting an agent:** the server builds the consent text from the agent's identity as the platform answers it now (`read` and `treasury` for 90 days, with `owner` too only when a wallet owner opts in: at connect, or later to add it, which replaces the connection and so withdraws its open proposals; under the owner's Organization: their account with each `.` written as `--`), and the owner's wallet signs it for the platform's host. An agent already connected isn't offered another signature. The server relays only that exact consent, submits the same signed body once more if the answer is lost, revokes the owner's older consents for the agent once a new one is stored, and limits each account to `CONSENTS_PER_DAY` new consents (the platform caps a deployment's unexpired consents, revoked ones included). A consent counts as the agent's only for its current ID, with `read` and `treasury`, unrevoked and unexpired. Disconnecting revokes those, at most five a request, found in the platform's own list, never by an ID from the browser. - **Setup (#12):** an account with no Treasury signs the server's setup text (a Treasury agent for its own Organization, good for an hour, for the platform's host); the server relays only that exact text to `/apps/v1/setup`, sending the same body once more if the answer is lost. The platform reserves the agent and stores a setup consent that lets the server relay only its creation: `agent_create`, naming the key that signed the setup, which the owner signs on the platform's confirm page; the page follows it by `owner/status`. A DAO sets up by its own vote instead (`daoSetups`, filed and stored like its consent), then votes on the creation, which must pass within minutes. `GET /api/setup` lists the agents the account's consents name, set up or created; the setup routes count against the account's requests a minute. - **Proposals:** the owner lists and reads their agent's proposals, under their own Organization only. They may ask for a rebalance to the saved strategy, proposed as `watch` proposes one (the strategy's legs, open for a day) under a `requestId` the page names: after a lost answer the page finds it by that ID, never asking again. Approving or rejecting relays the owner's wallet signature over the SDK's `proposalText` of the proposal the page shows; the server reads it anew first and refuses, with nothing relayed, unless it's unexpired, still open and exactly that text, signed with a full-access key of the owner for the platform's host. Execute is sent only for an approved proposal, once per request, and never again after a lost answer: the proposal is read and answers `final`, `pending` (offer no Execute; read it again later) or `not_executable` (nothing was sent; it may be tried later). The platform claims a proposal once, so it never runs twice. - **Owner reads (#61):** the agent's overview, activity, payouts held, operations without a proven outcome, and the autopilot's runs and one run's recording, each one platform read under the owner's consent. A proposal is found by its `requestId` among the agent's proposals (the platform has no lookup by it), and an operation's status by its `correlationId` from its activity row, never `owner/status`, which answers `unknown` for any swap. Every `bigint` the SDK reads goes out as its exact decimal string. No live consent is `not_connected`; any other failure, a thrown call included, is `unavailable`, and the platform's text stays in the log. diff --git a/apps/server/src/consent.ts b/apps/server/src/consent.ts index be186f2..12603a3 100644 --- a/apps/server/src/consent.ts +++ b/apps/server/src/consent.ts @@ -3,8 +3,16 @@ import { consentMessage, parseConsentMessage } from "@multiagency/sdk"; import type { Identity } from "./contract.ts"; import type { ConsentRecord } from "./platform.ts"; -/** What this server asks an owner to consent to: reads and Treasury proposals the owner approves one by one. Never `treasury-execute` or `owner`. */ +/** What this server asks an owner to consent to: reads and Treasury proposals the owner approves one by one. Never `treasury-execute`. */ export const CONSENT_SCOPES = ["read", "treasury"] as const; +/** + * With a wallet owner's explicit opt-in, also `owner`: the app may prepare owner actions (a strategy grant) and relay + * the owner's own signature over each, which alone makes one take effect. A DAO's consent never carries it: the + * platform files a DAO's owner actions for its vote under `treasury`. + */ +export const OWNER_CONSENT_SCOPES = ["read", "treasury", "owner"] as const; +/** Whether a consent carries `owner`. */ +export const withOwner = (scopes: readonly string[]) => scopes.includes("owner"); /** How long a consent this server asks for lasts. The platform allows up to 366 days. */ export const CONSENT_DAYS = 90; const DAY = 86_400_000; @@ -20,35 +28,35 @@ export const organizationOf = (account: string) => account.replaceAll(".", "--") /** * The consent this server asks for, as its canonical text: the owner's Organization, the agent's current identity - * (so an agent created anew needs a new one), `CONSENT_SCOPES`, for `CONSENT_DAYS`. A wallet owner signs it; a DAO - * votes on it. + * (so an agent created anew needs a new one), `CONSENT_SCOPES` (or `OWNER_CONSENT_SCOPES` when the owner opts in), for + * `CONSENT_DAYS`. A wallet owner signs it; a DAO votes on it. */ -export const consentFor = ({ identity, deployment, now = Date.now() }: { identity: Identity & { agentId: string }; deployment: string; now?: number }) => +export const consentFor = ({ identity, deployment, owner = false, now = Date.now() }: { identity: Identity & { agentId: string }; deployment: string; owner?: boolean; now?: number }) => consentMessage({ host: identity.host, deployment, organization: organizationOf(identity.owner), owner: identity.owner, agent: identity.agent, - agentId: identity.agentId, scopes: [...CONSENT_SCOPES], expiresAt: new Date(now + CONSENT_DAYS * DAY).toISOString(), + agentId: identity.agentId, scopes: [...(owner ? OWNER_CONSENT_SCOPES : CONSENT_SCOPES)], expiresAt: new Date(now + CONSENT_DAYS * DAY).toISOString(), }); /** * The consent text for the owner's wallet to sign, with a fresh 32-byte nonce, and the recipient to sign it for: the * platform's host, never this server's. */ -export function consentText(expected: { identity: Identity & { agentId: string }; deployment: string; now?: number }) { +export function consentText(expected: { identity: Identity & { agentId: string }; deployment: string; owner?: boolean; now?: number }) { return { message: consentFor(expected), nonce: randomBytes(32).toString("base64"), recipient: expected.identity.host }; } /** * Whether a signed text is exactly a consent this server would ask for now: the canonical text naming this host, - * deployment, Organization, owner, agent and identity, with `read` and `treasury` only, unexpired and no longer than - * `CONSENT_DAYS`. Anything else is refused before the platform sees it, so the server never relays a wider consent - * than it asked for. + * deployment, Organization, owner, agent and identity, with `read` and `treasury` only, or with `owner` as well, + * unexpired and no longer than `CONSENT_DAYS`. Anything else is refused before the platform sees it, so the server never + * relays a wider consent than it asks for. */ export function checkConsent(message: string, { identity, deployment, now = Date.now() }: Expected): { ok: true } | { ok: false; code: "not_this_consent" } { const text = parseConsentMessage(message); const expiresAt = text ? Date.parse(text.expiresAt) : NaN; const matches = text !== null && text.host === identity.host && text.deployment === deployment && text.organization === organizationOf(identity.owner) && text.owner === identity.owner && text.agent === identity.agent - && text.agentId === identity.agentId && text.scopes.join() === [...CONSENT_SCOPES].sort().join() + && text.agentId === identity.agentId && [CONSENT_SCOPES, OWNER_CONSENT_SCOPES].some(scopes => text.scopes.join() === [...scopes].sort().join()) && expiresAt > now && expiresAt <= now + CONSENT_DAYS * DAY; return matches ? { ok: true } : { ok: false, code: "not_this_consent" }; } diff --git a/apps/server/src/contract.ts b/apps/server/src/contract.ts index 37ee2fd..46df6c5 100644 --- a/apps/server/src/contract.ts +++ b/apps/server/src/contract.ts @@ -194,6 +194,8 @@ export const sweepRun = z.strictObject({ /** A rebalance the owner asks for, under a `requestId` the page names (the platform's own format): answered as the proposal. */ export const rebalanceRequest = z.strictObject({ requestId: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/) }); export const rebalanceMade = z.strictObject({ proposal: proposalView }); +/** What a consent text is asked for with: `owner` too, at a wallet owner's explicit choice. */ +export const consentRequest = z.strictObject({ owner: z.literal(true).optional() }); /** A proposal found by the `requestId` it was proposed with. */ export const requestRead = z.strictObject({ proposal: proposalView }); /** diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index e81d09a..5265e8a 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -6,7 +6,7 @@ import { accessOf, mayCall, proposalId, type ProposalView } from "@multiagency/s import { verifyNep413Signature } from "near-kit"; import type { z } from "zod"; import { sha256, type Audit } from "./audit.ts"; -import { checkConsent, consentFor, consentText, liveConsents, organizationOf } from "./consent.ts"; +import { checkConsent, consentFor, consentText, liveConsents, organizationOf, withOwner } from "./consent.ts"; import { offerFor } from "./dao-offer.ts"; import type { DaoRequests, StoredDaoRequest } from "./dao-requests.ts"; import { createSettle, type Settle } from "./dao-settle.ts"; @@ -607,17 +607,22 @@ export const routes: Route[] = [ { /** * The text the owner's wallet signs to connect the agent, built here from the agent's identity as the platform - * answers it now, never from the browser. An agent already connected isn't offered another signature. For a - * DAO-owned agent, the consent is filed for the DAO's vote instead (`fileDaoConsent`), once per agent at a time. + * answers it now, never from the browser, with `owner` only when the owner asks for it (`{ owner: true }`). An agent + * already connected isn't offered another signature, except to add `owner` to a connection without it: the new + * consent replaces the old once stored. For a DAO-owned agent, the consent is filed for the DAO's vote instead + * (`fileDaoConsent`), once per agent at a time, and never with `owner`. */ method: "POST", path: "/api/agents/:agent/consent/text", access: "owner-fresh", needs: "file", - async handle({ account, identity }, context) { + async handle({ body, account, identity }, context) { const { agentId } = identity!; + const asked = parse(contract.consentRequest, body ?? {}); + if (asked === undefined || (asked.owner && identity!.signer === "dao")) return refused(400, "bad_request"); if (agentId === null) return refused(409, "not_created"); const listed = await context.platform.consents.list(organizationOf(identity!.owner)); if (!listed.ok) { log("platform_consents_failed", { agent: identity!.agent, ...failure(listed) }); return unavailable(); } - if (liveConsents(listed.value.consents, identity!).length) return refused(409, "already_connected"); - if (identity!.signer === "wallet") return ok(consentText({ identity: { ...identity!, agentId }, deployment: context.deployment })); + const live = liveConsents(listed.value.consents, identity!); + if (live.length && !(asked.owner && !live.some(consent => withOwner(consent.scopes)))) return refused(409, "already_connected"); + if (identity!.signer === "wallet") return ok(consentText({ identity: { ...identity!, agentId }, deployment: context.deployment, owner: asked.owner === true })); return fileDao({ kind: "consent" }, account!, { ...identity!, agentId }, context); }, }, diff --git a/apps/server/test/consent.test.ts b/apps/server/test/consent.test.ts index a782e12..0b9cccc 100644 --- a/apps/server/test/consent.test.ts +++ b/apps/server/test/consent.test.ts @@ -35,10 +35,11 @@ test("the consent text names this deployment, the owner's Organization, the agen assert.deepEqual(checkConsent(text.message, expected), { ok: true }); }); -test("a signed text is relayed only if it's exactly the consent this server would ask for now", () => { +test("a signed text is relayed only if it's exactly a consent this server would ask for now: read and treasury, or with owner too", () => { const base = { host: "platform.test", deployment: "treasury", organization: "owner--near", owner: "owner.near", agent: "portfolio", agentId: "id-portfolio", scopes: ["read", "treasury"] as ("read" | "treasury" | "treasury-execute" | "owner")[], expiresAt: new Date(now + 30 * DAY).toISOString() }; assert.deepEqual(checkConsent(consentMessage(base), expected), { ok: true }); + assert.deepEqual(checkConsent(consentMessage({ ...base, scopes: ["owner", "read", "treasury"] }), expected), { ok: true }, "owner actions, opted into"); const cases: [string, Partial][] = [ ["another host", { host: "elsewhere.test" }], ["another deployment", { deployment: "other" }], @@ -47,7 +48,8 @@ test("a signed text is relayed only if it's exactly the consent this server woul ["another agent", { agent: "other" }], ["another agent identity", { agentId: "id-old" }], ["unattended execution", { scopes: ["read", "treasury", "treasury-execute"] }], - ["owner actions", { scopes: ["owner", "read", "treasury"] }], + ["owner actions without treasury", { scopes: ["owner", "read"] }], + ["owner actions and unattended execution", { scopes: ["owner", "read", "treasury", "treasury-execute"] }], ["read only", { scopes: ["read"] }], ["already expired", { expiresAt: new Date(now - 1).toISOString() }], ["longer than this server asks", { expiresAt: new Date(now + CONSENT_DAYS * DAY + 1).toISOString() }], diff --git a/apps/server/test/http.test.ts b/apps/server/test/http.test.ts index 21b96e0..36f2605 100644 --- a/apps/server/test/http.test.ts +++ b/apps/server/test/http.test.ts @@ -476,6 +476,26 @@ test("an agent already connected isn't offered a new signature, and a reconnect assert.ok(results().some(entry => entry.action === "revoke" && entry.subject === kept.id && entry.answer.ok), "and recorded"); }); +test("owner is asked for only at the owner's choice: at connect, or to add it to a connection without it, which the new consent then replaces", async () => { + clean(); + const cookie = await signIn(owner); + const textFor = async (body: unknown) => json(await call("POST", "/api/agents/portfolio/consent/text", { cookie, body })); + const scopesOf = (text: { message: string }) => parseConsentMessage(text.message)!.scopes; + assert.deepEqual(scopesOf((await textFor({})).body.value), ["read", "treasury"], "never by default"); + assert.deepEqual(scopesOf((await textFor({ owner: true })).body.value), ["owner", "read", "treasury"]); + assert.equal((await textFor({ owner: false })).status, 400, "only true asks for it"); + const kept = (await json(await call("POST", "/api/agents/portfolio/consent", { cookie, body: (await signedConsent(cookie)).submission }))).body.value.consent; + const upgrade = await textFor({ owner: true }); + assert.equal(upgrade.status, 200, "a connection without owner may add it"); + const signed = owner.sign(upgrade.body.value); + const stored = await json(await call("POST", "/api/agents/portfolio/consent", { cookie, + body: { message: upgrade.body.value.message, nonce: upgrade.body.value.nonce, publicKey: signed.publicKey, signature: signed.signature } })); + assert.deepEqual(stored.body.value.consent.scopes, ["owner", "read", "treasury"]); + assert.equal(fake.consents.find(consent => consent.id === kept.id)?.revoked, true, "the one without owner is replaced"); + for (const body of [{}, { owner: true }]) + assert.deepEqual(await textFor(body), { status: 409, body: { ok: false, kind: "refused", code: "already_connected" } }, JSON.stringify(body)); +}); + test("new consents are limited per account a day, since even revoked ones count against the deployment's cap", async t => { const counted = fakePlatform({ portfolio: "owner.near" }); const limited = createHttpServer(createServer({ @@ -879,6 +899,9 @@ test("a member who may file connects a DAO's agent: the consent filed once for t await freshDao(t); const cookie = await signIn(council); fake.calls.length = 0; + assert.deepEqual(await json(await call("POST", "/api/agents/dao-treasury/consent/text", { cookie, body: { owner: true } })), + { status: 400, body: { ok: false, kind: "refused", code: "bad_request" } }, "a DAO's consent never carries owner"); + assert.deepEqual(fake.calls.filter(made => made.startsWith("daoConsents")), [], "nothing filed for it"); const { status, body } = await json(await call("POST", "/api/agents/dao-treasury/consent/text", { cookie, body: {} })); assert.equal(status, 200, JSON.stringify(body)); const { request } = contract.daoFiled.parse(body.value); diff --git a/web/PLATFORM-SESSION.md b/web/PLATFORM-SESSION.md index 96c347e..f4643f7 100644 --- a/web/PLATFORM-SESSION.md +++ b/web/PLATFORM-SESSION.md @@ -232,6 +232,8 @@ A second build (`src/proposals/`, built to `dist-proposals/`), served only by th | Funding transfer to sign; Cancel, Send or File funding proposal | group (the exact transfer); buttons | Money in, once reviewed | | Open payment in Trezu | link, with the agent's `nearcom:` recipient | Money in, a confidential DAO | | Get a deposit address or Get a new deposit address | button | Receive funds | +| Also let this app relay owner actions you sign, such as a strategy grant. … | checkbox | Connection, not connected, a wallet owner | +| Allow owner actions; Allow owner actions for this agent?, Sign and allow | button; dialog (warns that open proposals are withdrawn), button | Connection, connected without owner, a wallet owner | | Propose a rebalance; Proposed. Review it below: nothing is sent until it's approved.; Review the proposal | button; status; link | Proposals, once connected, for an account that may file proposals | | Shield public balances | heading (level 3) | Funding | | Preview, Shield; Public balances | buttons; list (each balance exactly, then its shield's status) | Shield public balances, once the executor holds a treasury grant | diff --git a/web/src/proposals/connection.tsx b/web/src/proposals/connection.tsx index 8792ee9..ae38f6f 100644 --- a/web/src/proposals/connection.tsx +++ b/web/src/proposals/connection.tsx @@ -9,7 +9,9 @@ import { sign } from "./wallet"; import { failureText } from "./words"; type Reads = ReturnType; -type Review = { phase: "reading" } | { phase: "review"; text: ConsentText; notice?: string } | { phase: "signing"; text: ConsentText }; +/** A consent text under review: `owner` when it adds owner actions, `upgrade` when it replaces a live connection. */ +type Asked = { owner: boolean; upgrade: boolean }; +type Review = { phase: "reading" } | ({ phase: "review"; text: ConsentText; notice?: string } & Asked) | ({ phase: "signing"; text: ConsentText } & Asked); /** * The agent's connection to this app: the owner's live consent, which every proposal call needs. Connecting has the @@ -25,43 +27,45 @@ export function Connection({ reads, identity }: { reads: Reads; identity: Identi const [disconnecting, setDisconnecting] = useState<"review" | "busy">(); const [error, setError] = useState(); const [notice, setNotice] = useState(); + /** The owner's choice, before connecting, to let the app relay owner actions they sign. */ + const [ownerActions, setOwnerActions] = useState(false); const refresh = () => client.invalidateQueries({ queryKey: reads.consent.queryKey }); /** * Asks the server for the consent text, built from the agent as the platform answers it now. A text asked for again * (`notice` says why) keeps the dialog open until it arrives. */ - const prepare = async (notice?: string) => { + const prepare = async (asked: Asked, notice?: string) => { setError(undefined); if (!notice) setReview({ phase: "reading" }); - const text = await reads.agent.consentText(); - if (text.ok) return setReview({ phase: "review", text: text.value, notice }); + const text = await reads.agent.consentText({ owner: asked.owner }); + if (text.ok) return setReview({ phase: "review", text: text.value, notice, ...asked }); setReview(undefined); setError(failureText(text, "consent")); if (text.kind === "refused" && text.code === "already_connected") void refresh(); }; - const connect = async (text: ConsentText) => { - setReview({ phase: "signing", text }); + const connect = async (text: ConsentText, asked: Asked) => { + setReview({ phase: "signing", text, ...asked }); let signed; try { signed = await sign(text); } catch (problem) { - setReview({ phase: "review", text, notice: `The wallet didn't sign: ${errorMessage(problem)}. Nothing was sent.` }); + setReview({ phase: "review", text, notice: `The wallet didn't sign: ${errorMessage(problem)}. Nothing was sent.`, ...asked }); return; } if (signed.accountId !== owner) { - setReview({ phase: "review", text, notice: `Your wallet signed as ${signed.accountId}, but this agent's owner is ${owner}. Nothing was sent. Sign with ${owner}.` }); + setReview({ phase: "review", text, notice: `Your wallet signed as ${signed.accountId}, but this agent's owner is ${owner}. Nothing was sent. Sign with ${owner}.`, ...asked }); return; } const stored = await reads.agent.connect({ message: text.message, nonce: text.nonce, publicKey: signed.publicKey, signature: signed.signature }); if (stored.ok) { setReview(undefined); - setNotice("Connected. The agent's proposals now reach this page."); + setNotice(asked.upgrade ? "Owner actions allowed. The connection was replaced: its open proposals were withdrawn." : "Connected. The agent's proposals now reach this page."); client.setQueryData(reads.consent.queryKey, stored.value); void client.invalidateQueries({ queryKey: reads.proposals.queryKey }); return; } // The agent changed since the text was built: a new text, and a new signature. - if (stored.kind === "refused" && stored.code === "agent_changed") return prepare(failureText(stored, "consent")); + if (stored.kind === "refused" && stored.code === "agent_changed") return prepare(asked, failureText(stored, "consent")); setReview(undefined); setError(failureText(stored, "consent")); void refresh(); @@ -125,32 +129,46 @@ export function Connection({ reads, identity }: { reads: Reads; identity: Identi {consent.isPending ? : consent.isError ? consent.refetch()} /> : live ?
-

Connected: {live.scopes.join(" and ")}, until {when(new Date(live.expiresAt).toISOString())}.

- +

Connected: {live.scopes.join(", ").replace(/, ([^,]*)$/, " and $1")}, until {when(new Date(live.expiresAt).toISOString())}.

+
+ {!live.scopes.includes("owner") && } + +
:

Not connected. The app can't read or act on this agent's proposals until you connect it.

- + +
}

{notice}

setReview(undefined)} - title="Connect this agent to the Treasury app?" + title={review && review.phase !== "reading" && review.upgrade ? "Allow owner actions for this agent?" : "Connect this agent to the Treasury app?"} description="Your wallet signs the platform's consent text below, for the platform's host. It lets this app read the agent and use its Treasury procedures for the time it names." - actions={review && review.phase !== "reading" && }> {review && review.phase !== "reading" && <> {review.phase === "review" && review.notice &&

{review.notice}

} + {review.upgrade &&

This replaces your current connection. Proposals made under it are withdrawn: they can't be approved + or executed anymore. The app proposes again, and you can ask for a rebalance.

}

What the treasury scope allows the app

- Read the agent; list, read, propose and withdraw its Treasury proposals; ask for an approved proposal's execution; save strategy settings (which act only once you sign a grant for them); turn autopilot on or off; and sweep public balances into confidential ones. - This server only lists, reads and executes proposals, and relays your own signed approvals and rejections. It never calls the other procedures. Nothing moves until you approve a proposal's exact terms with your own signature. + Read the agent; list, read, propose and withdraw its Treasury proposals; ask for an approved proposal's execution; save strategy settings (which act only once you sign a grant for them); turn autopilot on or off; link its funding account and get deposit addresses; and sweep public balances into confidential ones. + This server proposes rebalances, executes proposals you approved, gets deposit addresses, links the funding account and sweeps when you ask on this page, and relays your own signed approvals and rejections. Nothing moves out until you approve a proposal's exact terms with your own signature. + {review.owner && <> +

What the owner scope allows the app

+ Prepare owner actions for you, such as a grant for your saved strategy, and relay your wallet's signature over each. None takes effect without your own signature. + }
}
diff --git a/web/src/proposals/proposals-api.ts b/web/src/proposals/proposals-api.ts index 729034e..06d90b7 100644 --- a/web/src/proposals/proposals-api.ts +++ b/web/src/proposals/proposals-api.ts @@ -71,7 +71,8 @@ export function proposalsApi(fetcher: typeof fetch = (input, init) => fetch(inpu identity: () => call(contract.agent, base), consent: () => call(contract.consent, `${base}/consent`), /** The consent text for the owner's wallet, built by the server from the agent as the platform answers it now. */ - consentText: () => call(contract.consentText, `${base}/consent/text`, { method: "POST" }), + consentText: ({ owner = false }: { owner?: boolean } = {}) => + call(contract.consentText, `${base}/consent/text`, { method: "POST", body: contract.consentRequest.parse(owner ? { owner: true } : {}) }), /** * For a DAO-owned agent: files its consent for the DAO's vote, or answers the request already open. The server * builds the text, and the platform the proposal a member files. diff --git a/web/test/proposals-api.test.ts b/web/test/proposals-api.test.ts index ce86980..2e4493d 100644 --- a/web/test/proposals-api.test.ts +++ b/web/test/proposals-api.test.ts @@ -205,3 +205,12 @@ test("asking for a rebalance is one JSON POST naming the request; its proposal p assert.deepEqual(server.sent.map(request => [request.method, request.path, JSON.parse(request.body)]), [["POST", `${base}/proposals`, { requestId: "page-1" }]]); assert.throws(() => agent.proposeRebalance("has space"), "held to the server's schema before anything is sent"); }); + +test("the consent text is asked for with owner only at the owner's choice", async () => { + const server = scripted(), agent = proposalsApi(server.fetcher).agent("portfolio"); + const text = { message: "m", nonce: NONCE, recipient: "platform.test" }; + server.answer(`POST ${base}/consent/text`, ok(text)); + await agent.consentText(); + await agent.consentText({ owner: true }); + assert.deepEqual(server.sent.map(request => JSON.parse(request.body)), [{}, { owner: true }]); +}); diff --git a/web/test/proposals.e2e.ts b/web/test/proposals.e2e.ts index 13e42b9..42df7d6 100644 --- a/web/test/proposals.e2e.ts +++ b/web/test/proposals.e2e.ts @@ -314,3 +314,24 @@ test("the owner asks for a rebalance to the drifted strategy, and reviews the pr await visible(page.getByRole("region", { name: "State" }).getByText("Awaiting your decision")); assert.deepEqual(unexpected, []); }); + +test("the owner allows owner actions: told first that open proposals are withdrawn, then the connection is replaced by one with owner", { timeout: 90_000 }, async () => { + await setTargets(target, { [USDC]: 10_000, [WNEAR]: 0, [ETH]: 0 }); + const open = await propose(`page-${Date.now()}-owner`); + await page.goto(`${origin}/agents/${target.agent}`); + const connection = page.getByRole("region", { name: "Connection" }); + await signInIfAsked(connection.getByRole("button", { name: "Allow owner actions" })); + await connection.getByRole("button", { name: "Allow owner actions" }).click(); + const allow = dialog("Allow owner actions for this agent?"); + await visible(allow); + await visible(allow.getByRole("alert").filter({ hasText: "This replaces your current connection. Proposals made under it are withdrawn" })); + assert.match(await allow.getByLabel("Text to sign").innerText(), /owner/); + assert.deepEqual(await axe(), [], "the owner consent dialog"); + await allow.getByRole("button", { name: "Sign and allow" }).click(); + await visible(connection.getByRole("status").filter({ hasText: /^Connected: owner, read and treasury, until / })); + assert.equal(await connection.getByRole("button", { name: "Allow owner actions" }).count(), 0, "not offered again"); + // The platform answers a read of a proposal whose consent ended as withdrawn. + await page.goto(`${origin}/agents/${target.agent}/proposals/${open.id}`); + await visible(page.getByText("It was withdrawn: the consent it was made under has ended. Nothing was relayed.")); + assert.deepEqual(unexpected, []); +});