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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 18 additions & 10 deletions apps/server/src/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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" };
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
/**
Expand Down
17 changes: 11 additions & 6 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
},
Expand Down
6 changes: 4 additions & 2 deletions apps/server/test/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof base>][] = [
["another host", { host: "elsewhere.test" }],
["another deployment", { deployment: "other" }],
Expand All @@ -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() }],
Expand Down
23 changes: 23 additions & 0 deletions apps/server/test/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading