diff --git a/adapters/adapter-core/package.json b/adapters/adapter-core/package.json index 3bb90d4..d9a0598 100644 --- a/adapters/adapter-core/package.json +++ b/adapters/adapter-core/package.json @@ -27,6 +27,6 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs" + "test": "tsc -p tsconfig.json && node test/static.test.mjs && node test/host.test.mjs && node test/whoami.test.mjs" } } diff --git a/adapters/adapter-core/src/server.ts b/adapters/adapter-core/src/server.ts index 81a4295..49cd790 100644 --- a/adapters/adapter-core/src/server.ts +++ b/adapters/adapter-core/src/server.ts @@ -48,6 +48,17 @@ import { export const ADAPTER_CORE_VERSION = "0.1.0"; const PROTOCOL_VERSION = "0.1"; + +/** + * The default answer to "how do I get a credential?", attached to every 401. + * + * Both challenges send the same text because they are the same question, and a + * caller that met one and then the other should not have to learn the answer + * twice. An app serving anyone but its owner sets `credentialHint` instead: this + * default describes a file on the host, which is an answer only its owner can act + * on. + */ +const DEFAULT_CREDENTIAL_HINT = "Read the app's .agent-token file (mode 0600) in the project directory."; const TASK_TIMEOUT_MS = 60_000; const TASK_MAX_DELIVERIES = 5; const DESCRIBE_PREFIX = "/api/_a2app/describe/"; @@ -473,7 +484,7 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { if (credentialRequired) { return { reply: err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "This write requires an agent credential.", { - how: config.credentialHint ?? "Read the app's .agent-token file (mode 0600) in the project directory.", + how: config.credentialHint ?? DEFAULT_CREDENTIAL_HINT, }), }; } @@ -505,7 +516,7 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { function accessFor(req: A2AppRequest): Access { if (isSameOrigin(req)) return FULL_ACCESS; const grant = credentialOf(req); - if (!grant) return binding.authMode === "multi-user" ? NO_ACCESS : FULL_ACCESS; + if (!grant) return binding.authMode === "multi-user" ? NO_ACCESS : ANONYMOUS_ACCESS; const held = expandScopes(grant); return { canRead: (entity) => held.has(`data:${entity}:read`), @@ -514,6 +525,43 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { }; } + /** + * The same reach, written as the scope names a grant would have carried. + * + * whoami must answer a caller that holds no grant, and `scopes` is how that + * answer is shaped. Rendering it from the very {@link Access} describe uses + * means the two cannot drift into disagreeing about the same caller — which + * is the failure this whole change is about. + */ + function reachAsScopes(access: Access): Set { + const scopes = new Set(); + for (const name of Object.keys(binding.entities())) { + if (access.canRead(name)) scopes.add(`data:${name}:read`); + if (access.canWrite(name)) scopes.add(`data:${name}:write`); + } + for (const o of operations) if (access.canRun(o.name)) scopes.add(`op:${o.name}`); + return scopes; + } + + /** + * What an uncredentialled caller on a single-user app may do — reads, and + * nothing that changes anything. + * + * {@link authorize} requires a credential for every write and for any + * operation not declared `readOnly`, but reaches its anonymous context before + * the scope check, so reads pass unimpeded. Describe used to render that + * caller as FULL_ACCESS, which told it that it could write and then answered + * its write with a 401. An agent reads `access` precisely to know what it may + * plan, so an over-report there is not a cosmetic mismatch: it is a plan built + * against permission that was never held, spent and rejected a round trip + * later. + */ + const ANONYMOUS_ACCESS: Access = { + canRead: () => true, + canWrite: () => false, + canRun: (operation) => operations.find((o) => o.name === operation)?.readOnly === true, + }; + function writeAudit( ctx: { credentialId: string; agentName: string | null; principal: string | null } | null, target: string, @@ -953,14 +1001,35 @@ export function createA2App(binding: Binding, config: A2AppConfig): A2App { return handleDescribe(req, segments); } if (path === "/api/_a2app/whoami") { + // whoami answers whoever this app will actually serve, which is what + // `authorize` decides — so it asks the same question, in the same words. + // + // It used to demand a credential of its own. That made it STRICTER than + // the surface it reports on: an uncredentialled caller on a single-user + // app reads records perfectly well, and was told 401 by the one endpoint + // whose whole job is "what may I do here?". An agent that reads its grant + // before planning — which is the documented order — stopped at the gate + // and never learned it could have read everything. + // + // The 401 that remains is the honest one: on a multi-user app, or for a + // write, `authorize` really would refuse, and the challenge still carries + // the `how` the write path sends, because this is the first 401 many + // agents meet and a bare one sends them back to a step whose whole input + // is that field. + const authz = authorize(req, { scope: null, isWrite: false }); + if ("reply" in authz) return authz.reply; const grant = credentialOf(req); - if (!grant) return err(401, ERROR_CODES.AGENT_TOKEN_REQUIRED, "whoami requires a credential."); return ok({ a2app: true, - credentialId: grant.credentialId, - agentName: grant.agentName, - principal: grant.principal, - scopes: [...expandScopes(grant)].sort(), + credentialId: authz.ctx.credentialId, + agentName: authz.ctx.agentName, + principal: authz.ctx.principal, + // A credentialled caller reports its grant. A caller that reaches a + // context WITHOUT one was never scope-checked, so it has no scope set to + // report — what it has is the reach `accessFor` renders on describe, and + // the two must not be able to disagree. Deriving the list from that one + // source is what keeps them the same answer. + scopes: grant ? [...expandScopes(grant)].sort() : [...reachAsScopes(accessFor(req))].sort(), }); } if (path === "/api/_a2app/context") { diff --git a/adapters/adapter-core/test/whoami.test.mjs b/adapters/adapter-core/test/whoami.test.mjs new file mode 100644 index 0000000..82277b6 --- /dev/null +++ b/adapters/adapter-core/test/whoami.test.mjs @@ -0,0 +1,101 @@ +/** + * whoami answers whoever the app will actually serve. + * + * The conformance suite covers the single-user side, where an uncredentialled + * caller reads records and whoami must say so. The multi-user side cannot live + * there: that harness boots one reference app, and it is `authMode: "none"`. + * This is the other half — an app where refusing an uncredentialled caller is + * the honest answer, so the 401 has to survive, and has to keep carrying the + * `how` that tells the caller where a credential comes from. + * + * Standard library only, run directly. + */ +import { createA2App, MemoryBinding } from "../dist/index.js"; + +const failures = []; +const check = (label, got, want) => { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + if (g !== w) failures.push(`${label}\n expected: ${w}\n actual: ${g}`); +}; +const ok = (label, cond) => { + if (!cond) failures.push(label); +}; + +const entities = { + notes: { module: "desk", fields: [{ name: "title", type: "string", required: true }], seed: [] }, +}; +const operations = [ + { name: "count-notes", description: "Count them.", destructive: false, readOnly: true, module: "desk", params: {} }, + { name: "wipe-notes", description: "Delete them all.", destructive: true, module: "desk", params: {} }, +]; + +const build = (authMode) => + createA2App(new MemoryBinding({ appId: "whoami_test", appName: "Whoami Test", entities, authMode }), { + modules: [{ name: "desk", summary: "notes" }], + operations, + credentialHint: "Ask the owner.", + }); + +const call = (app, path, headers = {}) => + app.handle({ method: "GET", path, headers, query: {}, body: undefined }); + +/* ------------------------------------------------- multi-user: 401 stands */ + +const multi = build("multi-user"); +const refused = await call(multi, "/api/_a2app/whoami"); +check("multi-user: an uncredentialled whoami is refused", refused.status, 401); +check("multi-user: with the credential code", refused.json.code, "agent_token_required"); +check("multi-user: and the hint the write path sends", refused.json.how, "Ask the owner."); + +/* ------------------------------------------- single-user: the truthful answer */ + +const single = build("none"); +const answered = await call(single, "/api/_a2app/whoami"); +check("single-user: an uncredentialled whoami is answered", answered.status, 200); +check("single-user: the caller is named as anonymous", answered.json.credentialId, "anonymous"); +check("single-user: with no agent name to report", answered.json.agentName, null); + +// The set matters, not the order: reads and the readOnly operation, and nothing +// that changes anything. Anything else here would be whoami promising a write +// that `authorize` goes on to refuse. +const scopes = answered.json.scopes; +ok("single-user: the entity is readable", scopes.includes("data:notes:read")); +ok("single-user: the readOnly operation is runnable", scopes.includes("op:count-notes")); +ok("single-user: the entity is NOT writable", !scopes.includes("data:notes:write")); +ok("single-user: the state-changing operation is NOT runnable", !scopes.includes("op:wipe-notes")); +check("single-user: nothing else is claimed", scopes.length, 2); + +/* ------------------------------ whoami and describe cannot disagree */ + +// The whole point of deriving whoami's scopes from the same Access describe +// uses: one caller must not be told two different things about itself. +const described = await call(single, "/api/_a2app/describe"); +check("describe agrees: the caller is read-only, not full", described.json.modules[0].access, "read-only"); + +/* ------------------------------------------- a credential still reports itself */ + +const withGrant = createA2App( + new MemoryBinding({ appId: "whoami_test", appName: "Whoami Test", entities, authMode: "none" }), + { + modules: [{ name: "desk", summary: "notes" }], + operations, + credentials: [ + { token: "t_ro", credentialId: "cred_ro", agentName: "reader", principal: "owner", scopes: ["data:notes:read"] }, + ], + }, +); +const asGrant = await call(withGrant, "/api/_a2app/whoami", { "x-a2app-token": "t_ro" }); +check("a credentialled caller reports its own grant", asGrant.status, 200); +check("by credential id", asGrant.json.credentialId, "cred_ro"); +check("with its agent name", asGrant.json.agentName, "reader"); +check("and exactly its granted scopes", asGrant.json.scopes, ["data:notes:read"]); + +/* ---------------------------------------------------------------- report */ + +if (failures.length > 0) { + console.error(`whoami: ${failures.length} failure(s)\n`); + for (const f of failures) console.error(" " + f); + process.exit(1); +} +console.log("whoami: all checks passed"); diff --git a/conformance/harness/src/toolkits.ts b/conformance/harness/src/toolkits.ts index 3ca5b54..0dd639b 100644 --- a/conformance/harness/src/toolkits.ts +++ b/conformance/harness/src/toolkits.ts @@ -15,13 +15,46 @@ interface ToolkitSpec { /** react-node's real build needs `npm install` of a published adapter, so its * gate is checked with --no-build (Toolkit class validates the scaffold). */ noBuild?: boolean; + /** + * This blueprint scaffolds a STARTING POINT, not a finished app, so `validate` + * is expected to REFUSE a fresh scaffold and name the spec as unauthored — + * that refusal is the blueprint working, not failing. + * + * Asserting `validate` passes here would demand a spec for an app that does + * not exist yet, and the only way to satisfy it would be to write a fictional + * one. The check is inverted instead, so the suite states the real contract + * rather than a wish: a starting point is not a finished app, and `validate` + * saying so on every app that is not done is the whole point of `validate`. + * + * The string says WHY for each, because "incomplete" is a claim that should + * age badly if someone finishes the blueprint and forgets this line. + */ + incompleteBecause?: string; } const TOOLKITS: ToolkitSpec[] = [ - { id: "blueprint-base" }, + // Framework files and no runtime code at all: no entities, no operations, a + // pipeline of placeholder echoes. The author picks the stack. + { id: "blueprint-base", incompleteBecause: "ships framework files only, no runtime code" }, + // The one finished starter: a to-do app with a View, a model and operations. { id: "blueprint-react-node", noBuild: true }, - { id: "blueprint-python-fastapi", noBuild: true }, - { id: "blueprint-pocketbase-react", noBuild: true }, + // Runtime and model are complete, but the spec is the author's to write — the + // blueprint ships the template unfilled on purpose, like the other two + // starting points. Writing one on its behalf would put words in the mouth of + // whoever owns the blueprint. + { + id: "blueprint-python-fastapi", + noBuild: true, + incompleteBecause: "ships the requirements template unfilled; the spec is the author's to write", + }, + // Ships adapter hooks only. Its `tasks` collection is created by hand in + // PocketBase, and `install` prints "download the pocketbase binary" — so a + // fresh scaffold has neither data model nor runtime. + { + id: "blueprint-pocketbase-react", + noBuild: true, + incompleteBecause: "ships adapter hooks only; collections and the binary are added by hand", + }, ]; /** @@ -71,10 +104,21 @@ export async function runToolkitClass(cliEntry: string | null): Promise r.ok).length; diff --git a/conformance/suites/class-a-core.yaml b/conformance/suites/class-a-core.yaml index d6d9639..bf0675f 100644 --- a/conformance/suites/class-a-core.yaml +++ b/conformance/suites/class-a-core.yaml @@ -270,6 +270,46 @@ checks: status: 403 match: { code: insufficient_scope, required: "data:cards:write" } + - name: whoami answers the uncredentialled caller this app actually serves + # whoami reports what a caller may do. On a single-user app an + # uncredentialled caller reads records perfectly well, so refusing it here + # made whoami stricter than the surface it describes -- an agent that reads + # its grant before planning (the documented order) stopped at a gate it did + # not have to. + request: { method: GET, path: /api/_a2app/whoami } + expect: + status: 200 + match: { a2app: true, credentialId: anonymous, principal: owner } + + - name: that caller's scopes are reads and readOnly operations, nothing more + # `scopes` is sorted, so these ordinals are stable. The point is the SET: + # both entities readable, the readOnly operation runnable, and no write and + # no state-changing operation -- which is exactly what authorize enforces. + request: { method: GET, path: /api/_a2app/whoami } + expect: + status: 200 + match: + scopes.0: "data:cards:read" + scopes.1: "data:lists:read" + scopes.2: "op:count-cards" + len: { scopes: 3 } + + - name: describe tells that caller read-only, not full + # It used to say "full", then answer the write it had just promised with a + # 401. An agent reads access to know what it may plan; an over-report there + # is a plan built on permission that was never held. + request: { method: GET, path: /api/_a2app/describe } + expect: + status: 200 + match: { modules.0.access: read-only } + + - name: a state-changing operation is still refused without a credential + request: { method: POST, path: /api/ops/archive-board, body: {} } + expect: + status: 401 + match: { code: agent_token_required } + has: [how] + - name: whoami returns the calling credential's grant request: { method: GET, path: /api/_a2app/whoami, token: readonly } expect: