diff --git a/README.md b/README.md index 056d560..f66a339 100644 --- a/README.md +++ b/README.md @@ -147,14 +147,14 @@ results). | Tool | Args | Returns | |------|------|---------| | `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name is *refused*, and so is a URL that would need its own origin assumed as the derivation origin while showing no IC evidence (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags — for **up to eight** eligible canisters, with both fields *omitted* (not false) on any beyond that — plus a data-access note (which canister is read through the OQL path, and the origin that path requires). Wraps `resolve_app` + `discover_app_canisters`; no auth | -| `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared App Connect metadata first (`/ai-connect.html`'s `ic:canister-id` meta, `/.well-known/ic-app.json` manifest), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe | +| `discover_app_canisters` | `domain` | Canister ids behind a web domain — the app's own `/.well-known/ic-architecture` manifest first (the [ICP service-discoverability protocol](#the-icp-service-discoverability-protocol)'s composition layer: every canister the app *declares*, with names and roles), then the superseded `/.well-known/ic-app.json` manifest as a read-only fallback, then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe. Only the `ic-architecture` manifest is a declaration the write gate accepts; every other source here is a read-only hint that [cannot authorize a write](#update-call-authorization) | | `get_canister_candid` | `canister_id` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result for every documentation outcome — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next`, so "no compatible method was detected" is distinct from "no answer was obtained". An unusable `canister_id` is rejected before any lookup and is a plain error, not that shape; and `expected:true` is not proof of absence, since an interface the parser cannot read also comes up empty | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) and the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `create_canister`) on those canisters; and **every** update call is refused on the financial-service canisters the guard carries — all to protect the user. The refusal directs the user to perform the operation outside the connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. The policy is stated in the server-level instructions, deliberately not in any tool description. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), **`application_origin`**, `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Two layers of authorization, both of which must pass** (see [Update-call authorization](#update-call-authorization)). *Layer 1 — registration:* `application_origin` is **required**; the origin must be a registered application whose developer accepted the [ICP MCP Developer Terms](#the-developer-terms), and its own `/.well-known/ic-architecture` manifest — re-read on every call — must declare the target canister. A canister id found any other way (header, `/env.json`, JS bundle) cannot be written to, and every failure refuses. *Layer 2 — financial transactions are refused* even inside that surface: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) on those ledgers; and **every** update call is refused on a curated list of known financial-service canisters (token ledgers and minters, exchanges, wallet backends, staking/governance) — all to protect the user. The refusal directs the user to perform the operation outside this connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal — and deliberately names no venue. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) | -| `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | +| `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ii-derivation-origin`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` | | `icp_oql_guide` | — | The OQL query-surface dialect guide (for canisters where `get_canister_candid` reports `oql: true`): the JSON query object, predicate grammar, edges, and paged result shape. The entity/field names come from `get_canister_oql_schema` and queries run through `canister_query` (the `oql` argument) | | `get_canister_oql_schema` | `canister_id`, `derivation_origin`, `account?` | The canister's OQL schema catalogue (entities, primary keys, fields, edges) as JSON — wraps its `schema` method — plus a ready-to-run `canister_query` example per entity. **`derivation_origin` is required**: this server rejects an anonymous read (for now) with guidance — its own rule, not an inference about the canister — rather than calling `schema` anonymously and returning an empty list | @@ -165,10 +165,15 @@ canisters behind the app together (see [Typical flow](#typical-flow)). `discover_app_canisters` is the canister-only path underneath it, used directly when you already have the app's domain or URL (its `domain` argument accepts either) and only need the canister ids. Its sources are listed in the table row -above (app-declared metadata first, then the `x-ic-canister-id` frontend header, -then backend candidates mined from `/env.json` + the JS bundle); among the mined -candidates, pick by label, prefer production/`IC_` ids, and confirm with -`get_canister_candid`. +above (the app's own architecture manifest first, then the `x-ic-canister-id` +frontend header, then backend candidates mined from `/env.json` + the JS +bundle); among the mined candidates, pick by label, prefer production/`IC_` ids, +and confirm with `get_canister_candid`. + +Discovery and **authorization** are deliberately different things. Finding a +canister id behind a domain tells you what you might be able to *read*; it never +authorizes a *write*. Only the app's own architecture manifest does that — see +[Update-call authorization](#update-call-authorization). ### Typical flow @@ -182,7 +187,7 @@ Acting **for the user** at an app: unrelated or squatted site. The tool enforces this: a bare *unknown* name is refused (find the real URL — web-search or ask the user), and a URL that resolves to `app_url_default` while showing **no IC evidence** (no valid `x-ic-canister-id` - gateway header, no `ic-app.json` derivation origin) is refused too; when the host + gateway header, no declared `ii-derivation-origin`) is refused too; when the host resembles a known app the error names it and gives the real URL (a "did you mean" repair). For a single step, the narrower tools remain: **`resolve_app(url)`** (origin only), **`discover_app_canisters(url)`** @@ -209,35 +214,192 @@ independent of the identity steps (3/4), so they can run in parallel. Managing y **own** canisters is not part of this connector: create and manage them with the [`icp` CLI](https://github.com/dfinity/icp-cli) in your own terminal. -### App-declared canister metadata (App Connect) +### The ICP service-discoverability protocol + +An application on the Internet Computer describes itself to an agent through the +[ICP service-discoverability protocol][protocol]. This server speaks all five of +its layers: -Apps that adopt **Internet Computer App Connect** serve a bridge page at -`/ai-connect.html` whose `` declares the app's -**main backend** canister (spec §4.7/§6.1). Discovery reads that meta from the -raw served markup (no JavaScript is executed) and reports it as the -top-priority finding, labelled `main backend (App Connect)`. +| Layer | Where | What this server does with it | +|-------|-------|-------------------------------| +| **Composition** | `/.well-known/ic-architecture` at the application origin | `discover_app_canisters` / `open_app` report every declared canister with its name and role — and it is what **authorizes an update call** (below) | +| **Interface** | the canister's `candid:service` metadata | `get_canister_candid` fetches it; args and replies are encoded against it | +| **Behaviour** | the `getApiDoc` / `get_api_doc` query method | `get_canister_api_doc`, gated on the `api_doc_available` flag | +| **Data** | the OQL `schema` / `execute` query convention | `icp_oql_guide` → `get_canister_oql_schema` → `canister_query` with `oql` (see [OQL query surfaces](#oql-query-surfaces)) | +| **Identity** | `/.well-known/ii-derivation-origin` at the application origin | `open_app` / `resolve_app` read the app's declared Internet Identity derivation origin from it (`derivation_origin_source: declared`), falling back to the superseded `ic-app.json` key when an app serves no such file | -The App Connect spec **defers** multi-canister enumeration (§6.3: how an app -lists *all* the canisters it comprises, with roles). To fill that gap, this -server also reads a proposed convention: a `/.well-known/ic-app.json` manifest -the app serves itself — +The composition manifest is a JSON document the app serves itself: ```json { - "derivation_origin": "https://.icp0.io", + "version": "1.0.0", "canisters": [ - { "id": "aaaaa-…-cai", "role": "backend", "description": "orders + inventory API" }, - { "id": "bbbbb-…-cai", "role": "ledger" } + { "id": "hcv4s-…-cai", "name": "frontend", "role": "the frontend" }, + { "id": "hmxr2-…-cai", "name": "backend", "role": "the backend", + "description": "orders + inventory API; call getApiDoc() first" } ] } ``` -Each entry needs an `id` (a canister principal); `role` and `description` are -optional and become the finding's label (`role — description`). Unknown fields -are ignored, so the format can grow. Both sources are the app's own claim about -its composition — stronger than anything mined from client code — but an -SPA catch-all serving HTML at these paths simply yields no findings (no meta -tag; JSON parse fails), and every id is still validated as a principal. +DFINITY's earlier `/.well-known/ic-app.json` proposal is superseded by this +document. It is still *read*, as a read-only discovery fallback so apps that +shipped it stay discoverable while they migrate (findings from it are stamped +`ic-app.json` and rank below `ic-architecture`), and its top-level +`derivation_origin` key is still consulted when an app serves no +`ii-derivation-origin` — but it authorizes nothing: only `ic-architecture` can +[authorize a write](#update-call-authorization). + +`version` and each entry's `id` are required; `name`, `role`, and `description` +are optional and become the finding's label. Unknown fields are ignored (the +`1.x` line is accepted, so the schema can grow additively), every id is +validated as a canister principal, and app-supplied text is sanitized before +display. A body that isn't this schema — an SPA catch-all serving `index.html` +at the path is the usual cause — yields nothing; serve the path as +`application/json`, exempt from catch-all rewrites. + +The **identity** file is one line naming the origin Internet Identity derives +the user's principal against: + +``` +https://.icp.net +``` + +Omit it when the app derives against its own visible origin. The `https://` +scheme is required — a bare host is *not* accepted here, so an SPA catch-all +answering this path with a single token is read as "no declaration" rather than +as a bogus origin. It is the only authoritative way for `open_app` / +`resolve_app` to learn a **custom** derivation origin from an app URL — there is +no reverse lookup — so an app that pins one should serve it; otherwise the +connector assumes the derivation origin equals the application origin and flags +that assumption. A **cross-origin** declaration +is honoured only when the declared origin's own +`/.well-known/ii-alternative-origins` lists the application origin (the +browser/II rule); an unauthorized claim is refused rather than resolved to a +wrong identity. Note the direction: alternative-origins is the *inverse* +relation and is never used to infer a derivation origin. + +[protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/ + +### Update-call authorization + +Reads are open: `canister_query`, `get_canister_candid`, and the OQL tools work +against any canister. **Writes are not.** `canister_update_call` is authorized in +two layers, and a call runs only when every check in both passes — otherwise it +is refused, with no fallback path. + +**Layer 1 — the registration gate.** An update call must name an +`application_origin`, and that application must be *registered*: + +1. Layer 2 (below) does not refuse the method. +2. `application_origin` is supplied (an https origin, canonicalized). +3. That exact origin is in the server's registry of applications whose + developers accepted the current [ICP MCP Developer Terms](#the-developer-terms) — + and any `derivation_origin` the call would be signed as is one that + registration records for that application. +4. That origin serves a well-formed `/.well-known/ic-architecture` manifest, + fetched fresh on **every** call — nothing is cached. +5. The target canister is **pinned by the registration** *and* **declared in + that live manifest**. +6. Only then does the call execute. + +All six must pass, so the order decides only which refusal the caller reads. +Layer 2 is evaluated first because it is offline and its refusal is the more +useful one: "transfer 1 ICP" should be answered with *do it in a wallet you +control*, not with *that application isn't registered* — the latter reads as +though registering would make the transfer possible. A value-moving request +therefore also never triggers an outbound fetch, and never reveals whether the +named origin is registered. + +The consequence is the point: **discovery does not authorize writes.** A +canister id from the `x-ic-canister-id` header, from an `/env.json`, or mined out +of a JS bundle can be read, and can never be written to. Those signals are +evidence about bytes a frontend happened to ship; the manifest is the +application declaring, at its own origin, what it comprises. + +`application_origin` is a **different argument** from `derivation_origin`, and +one cannot stand in for the other: + +* A derivation origin is **shared** by design — this connector's own registry + maps five NNS frontends onto one derivation origin and eight Oisy hosts onto + another, and `identities::target_origin` collapses `.icp0.io` / + `.icp.net` onto `.ic0.app`. Keying authorization on it would let any + frontend in such a set write against a sibling's manifest. +* The manifest is served at the **application** origin, so the derivation origin + isn't even where it would be fetched from. + +So they do separate jobs: `application_origin` says *which application this call +belongs to* (and authorizes it), `derivation_origin` says *whose identity to act +as*. `open_app` and `resolve_app` return both. + +Separate, but not unrelated. Nothing in the identity path ties them together, so +the gate requires the *pair* to match what registration recorded: a registered +application may only act as an identity recorded for it. Otherwise a registered +application could have the server sign a call — to a canister it had listed — as +the user's principal at an unrelated app, which that app's canisters may well +trust. + +Refusals distinguish their causes, because the fixes differ: no +`application_origin` supplied; the origin's developer has no current Terms +acceptance; the manifest could not be read (retryable); the manifest does not +declare this canister. Every one of them points at the read path as the +available alternative. + +**Layer 2 — the financial guard.** Inside the authorized surface, the +standardized value-moving methods and every update call to a known +finance-related canister are refused anyway (the `canister_update_call` row in +[Tools](#tools) has the detail). This layer is deliberately origin-blind, so no +registration can launder a financial call through it — registration buys an +application access to its *own* declared canisters, never the right to move +value. + +Both layers apply to **every** deployment composing `imcp2-core`, the hosted +server and the local stdio binary alike: the gate lives in the shared tool +implementation, and there is no configuration that turns it off. + +#### The Developer Terms + +The protocol proves *composition* and nothing more. Serving a manifest does not +establish that the publisher accepted any terms, that it is entitled to expose +every canister it lists, which of its update methods are safe to call, or that +its behaviour stays inside this server's policies. That is what the +[ICP MCP Developer Terms](https://internetcomputer.org/icp-mcp/developer-terms/) +carry: registering an application is the +publisher's representation that it may expose every canister its manifest lists, +and that the operations reachable through this server move no value, are safe +for an assistant to call on a user's behalf, and handle personal data lawfully. + +Registration is therefore two facts, both required: the protocol's manifest +(technical, published by the app) and the Terms acceptance (contractual, +recorded here). The registry lives in `authorization.rs` as a reviewed table — +who accepted, which revision, when — so the set of applications that may receive +writes is public and auditable. **It ships empty**, which means no application +can receive an update call until a publisher accepts the Terms and a reviewed +change adds it. + +One row is one **origin**: an app served at both its own domain and its +`.icp0.io` gateway origin needs a row (and a manifest) at each — an +acceptance is not inherited across origins. Revocation is removal of the row, +and a Terms revision bump invalidates every acceptance stamped against the old +revision. Because the table is compiled in, either is a *release* rather than a +runtime switch; what "nothing is cached" buys is that the change is complete the +moment it is deployed — no TTL to wait out, no state to reconcile. + +A row **pins what was reviewed** — the canister ids, and the derivation origins +the application acts as. The live manifest can then only ever *narrow* that pin, +never widen it: dropping a canister from the manifest stops writes to it at once +(the app's own signal), while adding one grants nothing until a reviewed change +records it too. So a publisher that later edits its manifest — or is compromised +into editing it — cannot give itself a canister nobody reviewed, and cannot have +the server act as the user's identity at an application it does not own. The +Developer Terms carry the matching promise, but the code no longer depends on +that promise holding. + +Canister *management* (installing code, settings, lifecycle) is a different +surface with a different basis — it acts on canisters the **user** controls, +authenticated as them — and is not part of this connector's served tools; use +the [`icp` CLI](https://github.com/dfinity/icp-cli) in your own terminal. + +### Hardened discovery fetches Discovery fetches are **SSRF-hardened** (CWE-918). Only `https` URLs with a real host are fetched, and every outbound fetch runs under a redirect guard (a 3xx may only @@ -252,12 +414,22 @@ mid-flight. Fixed public-host enrichment (the IC dashboard) uses the redirect guard but is not separately address-pinned. No JavaScript is executed, and every extracted id is validated as a principal. -The optional top-level **`derivation_origin`** is the app's declaration of the -Internet Identity derivation origin its frontends pin (see the identity section -above). It is the only authoritative way for `open_app` / `resolve_app` to learn a -**custom** derivation origin from an app URL — there is no reverse lookup from an app URL to it — -so an app that uses one should declare it here; otherwise the connector assumes -the derivation origin equals the application origin and flags that assumption. +The authorization manifest fetch reuses exactly those guards, and adds two more. +The body is read **strictly** — an incomplete or over-cap body is an error, not a +prefix the gate would decide on (a truncated manifest could only ever deny a +canister, but a gate should not rule on a document it did not fully receive). And +the response must have come from the origin that was asked, not a redirect +target — the shared redirect policy permits same-host different-port hops, so +without that check a neighbouring origin's manifest could be read as this one's +declaration. The same attribution rule now applies to the identity files +(`/.well-known/ii-derivation-origin` and the superseded manifest's key): a +declaration served by a different origin is ignored rather than read as this +application's. Because the fetch happens only *after* the registry check, the set +of origins the **authorization path** will fetch is the curated registry: a +caller cannot use an update call to steer this server's client at an origin of +their choosing. (Discovery still reads the same path from any origin a caller +names — that is its job, under the same guards — so the property is about the +write path, not about the process never fetching an arbitrary origin.) When the user names a **token, project, or service** rather than a website or id, web search the canister id or ask the user for it. @@ -266,7 +438,11 @@ inline.) `canister_query` and `canister_update_call` run anonymously by default; pass a `derivation_origin` to call as -your account at that app. The server mints a **short-lived account delegation on +your account at that app. (`canister_update_call` additionally **requires** +`application_origin`, which is what authorizes the write — see +[Update-call authorization](#update-call-authorization). Calling anonymously is +about *identity*, not authorization: an anonymous update still needs a +registered application origin.) The server mints a **short-lived account delegation on demand** using the connection's registered Internet Identity session key (see [Domain identities](#domain-identities-on-demand)) — there is no per-app sign-in step. `get_app_principal` returns that account's principal @@ -291,7 +467,7 @@ token (see Auth). > URL. A derivation origin is a *stable per-app value*, so you **resolve it once** > and reuse it: `open_app` (or `resolve_app`) turns an app name/URL into it and > reports how — `derivation_origin_source`: **declared** -> (`/.well-known/ic-app.json` → `derivation_origin`), else a built-in **known-app** +> (`/.well-known/ii-derivation-origin`), else a built-in **known-app** > value for a few apps that pin a custom origin without declaring it (an app's > own declaration always overrides this), else the app origin *assumed* > (**app_url_default**). Feeding that resolved origin to an identity tool records @@ -408,13 +584,16 @@ cargo run # and $MCP_SERVE_METRICS (set it to serve the Prometheus exposition at /metrics) ``` -The human-facing pages — the landing page and the `/privacy-policy`, -`/support`, and `/terms` documents the connector directories require — are -maintained in [dfinity/internetcomputer-org] (`public/icp-mcp/`) and served at -, so the content exists exactly once. -This origin answers their old paths (`/`, `/privacy-policy`, `/support`, -`/terms`) with permanent redirects there, keeping every published link -working. `GET /version` is the operations probe (see [Auth](#auth-oauth-21-login-via-internet-identity)). +The human-facing pages — the landing page, the `/privacy-policy`, `/support`, +and `/terms` documents the connector directories require, and the publisher-facing +`/developer-terms` — are maintained in [dfinity/internetcomputer-org] +(`public/icp-mcp/`) and served at , so the +content exists exactly once. This origin answers their paths (`/`, +`/privacy-policy`, `/support`, `/terms`, `/developer-terms`) with permanent +redirects there, keeping every published link working. The Developer Terms' +source text lives here, in [`docs/icp-mcp-developer-terms-draft.md`](docs/icp-mcp-developer-terms-draft.md), +alongside the privacy policy's: it carries the revision the write gate enforces, +and a test fails if the two drift apart. `GET /version` is the operations probe (see [Auth](#auth-oauth-21-login-via-internet-identity)). [dfinity/internetcomputer-org]: https://github.com/dfinity/internetcomputer-org @@ -913,7 +1092,7 @@ mcp_get_delegation : *domain-based* derivation: a raw `derivation_origin` is canonicalized and used verbatim, with no recovery of a custom derivation origin from it. When an `app_url` is passed instead, `resolve_app` resolves the derivation origin by - precedence **declared** (`/.well-known/ic-app.json` `derivation_origin`) > + precedence **declared** (`/.well-known/ii-derivation-origin`) > built-in **known-app** registry > application origin, so a custom origin an app declares (or that ships in the registry, e.g. `oisy.com`) **is** honoured, and the app's `/.well-known/ii-alternative-origins` list is fetched and surfaced by diff --git a/crates/imcp2-core/src/architecture.rs b/crates/imcp2-core/src/architecture.rs new file mode 100644 index 0000000..04b15cc --- /dev/null +++ b/crates/imcp2-core/src/architecture.rs @@ -0,0 +1,485 @@ +//! The **ICP service-discoverability protocol** — the canonical way an +//! Internet Computer application describes itself to an agent, specified at +//! . +//! +//! The protocol has five layers, and this server speaks all of them: +//! +//! 1. **Composition** — `/.well-known/ic-architecture`, served at the +//! application origin: the app enumerates the canisters it comprises, +//! each with an `id` and human-readable `name`/`role`. THIS module. +//! 2. **Interface** — the canister's own `candid:service` metadata +//! (`get_canister_candid`, [`crate::calls`]). +//! 3. **Behaviour** — the `getApiDoc`/`get_api_doc` query method +//! (`get_canister_api_doc`). +//! 4. **Data** — the OQL `schema`/`execute` query convention +//! (`get_canister_oql_schema`, `canister_query`'s `oql` argument). +//! 5. **Identity** — `/.well-known/ii-derivation-origin`, the one line +//! naming the origin Internet Identity derives the user's principal +//! against. Parsed here, resolved in [`crate::discover`]. +//! +//! Layer 1 is load-bearing beyond discovery: it is what an **update call** +//! is authorized against ([`crate::authorization`]). An app's architecture +//! manifest is the app's own signed-by-serving statement of which canisters +//! belong to it, fetched from the exact application origin over HTTPS — so +//! unlike a canister id mined out of a JS bundle, an `/env.json`, or a +//! response header, it cannot be attributed to an app that never claimed it. +//! Everything here therefore **fails closed**: an unreachable origin, a +//! missing file, a body that isn't the declared schema, or an entry whose id +//! isn't a canister principal all yield "not declared", never "assume yes". +//! +//! The manifest is deliberately **not cached**. Each authorization decision +//! re-reads the live file, so an app that removes a canister from its +//! manifest loses write access to it on the next call rather than at the end +//! of a TTL. + +use candid::Principal; +use serde::Deserialize; + +use crate::discover; + +/// Layer 1: where the composition manifest lives. Path-exact, per the spec — +/// no extension, no alternate spelling, no fallback path. +pub const ARCHITECTURE_WELL_KNOWN: &str = "/.well-known/ic-architecture"; + +/// Layer 5: where an app declares the Internet Identity derivation origin its +/// frontends pin. A single line holding that origin; absent when the app +/// derives against the visible origin itself. +pub const II_DERIVATION_ORIGIN_WELL_KNOWN: &str = "/.well-known/ii-derivation-origin"; + +/// The schema version this server understands. The spec's `version` field +/// identifies the manifest schema; we accept the `1.x` line (unknown fields +/// are ignored for forward compatibility, which is what a minor bump is for) +/// and refuse anything else rather than guessing at a future shape. +const SUPPORTED_SCHEMA_MAJOR: &str = "1"; + +/// Cap on manifest entries. Generous — the body itself is capped at +/// [`discover::MAX_META_BYTES`], so this only bounds a hostile manifest that +/// packs the cap full of tiny entries. +/// +/// Exceeding it rejects the WHOLE manifest rather than truncating it. +/// Truncation is the wrong failure here: a legitimately declared canister +/// past the cut would be silently refused, and the app developer would see +/// one canister mysteriously not working with nothing to go on. A whole- +/// manifest refusal names the cap, so the signal is actionable. +const MAX_ARCHITECTURE_CANISTERS: usize = 1000; + +/// The `/.well-known/ic-architecture` document. Unknown fields are ignored +/// (the spec mandates forward compatibility); `version` is validated rather +/// than defaulted, so a body that merely happens to carry a `canisters` array +/// is not mistaken for a manifest. +#[derive(Debug, Deserialize)] +pub struct Architecture { + /// The manifest schema version, e.g. `"1.0.0"`. + pub version: String, + #[serde(default)] + pub canisters: Vec, +} + +/// One canister the app declares itself to comprise. +#[derive(Debug, Deserialize)] +pub struct ArchitectureCanister { + /// The canister's principal id — the only required field. + pub id: String, + /// A short identifier for the canister within the app, e.g. `"backend"`. + #[serde(default)] + pub name: Option, + /// What the canister does in the app, e.g. `"the backend"`. + #[serde(default)] + pub role: Option, + /// Optional longer prose, e.g. `"orders + inventory API"`. + #[serde(default)] + pub description: Option, +} + +impl ArchitectureCanister { + /// The entry's id as a principal, or `None` when it isn't one. App-supplied + /// text, so never assumed valid: a membership test compares parsed + /// principals, never raw strings, so `" aaaaa-aa "` and `"AAAAA-AA"` cannot + /// smuggle a different target past the comparison. + fn principal(&self) -> Option { + Principal::from_text(self.id.trim()).ok() + } + + /// The human label for this entry — `name`, `role`, and `description` + /// folded into one display string, each sanitized (app-supplied text, + /// never markup or unbounded). + pub fn label(&self) -> Option { + let clean = |s: &Option| { + s.as_deref() + .map(discover::clean_label) + .filter(|s| !s.is_empty()) + }; + let (name, role, desc) = ( + clean(&self.name), + clean(&self.role), + clean(&self.description), + ); + // `role` is the richer of the two identifiers ("the backend" vs + // "backend"), so it leads when both are present. + let head = match (name, role) { + (Some(n), Some(r)) if r.eq_ignore_ascii_case(&n) => Some(r), + (Some(n), Some(r)) => Some(format!("{r} ({n})")), + (Some(n), None) => Some(n), + (None, Some(r)) => Some(r), + (None, None) => None, + }; + match (head, desc) { + (Some(h), Some(d)) => Some(format!("{h} — {d}")), + (Some(h), None) => Some(h), + (None, Some(d)) => Some(d), + (None, None) => None, + } + } +} + +impl Architecture { + /// The declared entry for `canister_id` — the membership test an update + /// call is authorized against: `Some(label)` when the app lists it (the + /// label itself may be absent, hence the nested `Option`), `None` when it + /// doesn't. Compares parsed principals, so only a genuine id match counts. + pub fn role_of(&self, canister_id: &Principal) -> Option> { + self.canisters + .iter() + .find(|c| c.principal().as_ref() == Some(canister_id)) + .map(|c| c.label()) + } + + /// The declared canisters as `(id, label)` pairs for discovery output. + /// Entries whose id isn't a canister principal are dropped — the app said + /// something we can't act on, so we don't surface it as a finding. + pub fn findings(&self) -> Vec<(String, Option)> { + self.canisters + .iter() + .filter_map(|c| c.principal().map(|p| (p.to_text(), c.label()))) + .collect() + } +} + +/// Parse an `/.well-known/ic-architecture` body. `Err` carries why the body is +/// not a usable manifest, for the refusal message — every failure is a +/// fail-closed "this app declares nothing", never a soft default. +pub fn parse_architecture(text: &str) -> Result { + let arch: Architecture = serde_json::from_str(text).map_err(|e| { + // A frontend's SPA catch-all serves index.html for unknown paths, which + // is the overwhelmingly common reason this isn't JSON — say so, since + // the fix (exempt the path from the rewrite) is in the app's hands. + format!( + "the body is not the declared JSON schema ({e}) — an SPA catch-all \ + serving HTML at this path is the usual cause; the spec requires \ + {ARCHITECTURE_WELL_KNOWN} to be exempt from catch-all rewrites and \ + served as application/json" + ) + })?; + let major = arch.version.trim().split('.').next().unwrap_or_default(); + if major != SUPPORTED_SCHEMA_MAJOR { + return Err(format!( + "manifest schema version {:?} is not supported (this server reads the \ + {SUPPORTED_SCHEMA_MAJOR}.x line)", + arch.version.trim() + )); + } + if arch.canisters.len() > MAX_ARCHITECTURE_CANISTERS { + return Err(format!( + "the manifest declares {} canisters, past the {MAX_ARCHITECTURE_CANISTERS} this \ + server reads — the whole manifest is refused rather than silently truncated", + arch.canisters.len() + )); + } + Ok(arch) +} + +/// The app's declared Internet Identity derivation origin from a +/// `/.well-known/ii-derivation-origin` body: the file's single line, reduced +/// to a canonical bare `https://host[:port]` origin. `None` when the file is +/// blank or the line is not an explicit https origin — so a malformed +/// declaration falls back to the application origin instead of deriving +/// against garbage. +/// +/// The `https://` scheme is REQUIRED here, unlike the scheme-tolerant +/// [`discover::normalize_origin`] used for interactively-supplied origins. The +/// spec's file holds a full origin, and accepting a bare host would read any +/// one-word 200 body as a declaration: an SPA catch-all answering this path +/// with a single token would become a bogus CROSS-origin claim, which the +/// alternative-origins check then refuses — turning a missing file into a hard +/// failure to resolve the app at all, instead of the application-origin default +/// the spec prescribes. +pub fn parse_derivation_origin(text: &str) -> Option { + // "Single line" per the spec; tolerate a trailing newline, a UTF-8 BOM, and + // stray surrounding whitespace, but not a second line of content — a file + // with more than one origin in it is not something to guess at. + let mut lines = + text.trim_start_matches('\u{feff}').lines().map(str::trim).filter(|l| !l.is_empty()); + let first = lines.next()?; + if lines.next().is_some() { + return None; + } + // The `https://` scheme is REQUIRED (`get`, not slicing, so a multi-byte + // first character can't panic). See the doc above for why a bare host must + // not be accepted here. + if !first.get(..8).is_some_and(|p| p.eq_ignore_ascii_case("https://")) { + return None; + } + discover::normalize_origin(first) +} + +/// The outcome of reading an origin's architecture manifest. Both failure +/// variants deny authorization; they are distinct only so the refusal can tell +/// the agent whether the app's manifest said no or the app's origin couldn't be +/// read at all — two very different things for the developer to fix. +pub enum ArchitectureFetch { + /// The exact origin served a well-formed manifest. + Served(Architecture), + /// The origin answered, but not with a usable manifest (404, a redirect off + /// the origin, a catch-all HTML page, an unsupported schema version). + NotDeclared(String), + /// The origin could not be read at all (DNS, TLS, timeout, or the SSRF + /// guard refusing a non-public target). + Unreachable(String), +} + +/// How long the whole manifest read may take before the call is refused as +/// unreadable. Deliberately shorter than the shared site-fetch timeout: this +/// one sits in front of every state-changing call, so a slow origin must cost +/// the caller a prompt "retry" rather than a long stall. The refusal says it is +/// retryable, so a transient slow patch costs a round trip, not a wrong answer. +const FETCH_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); + +/// Fetch `origin`'s architecture manifest from the **exact** origin, within +/// [`FETCH_BUDGET`]. +/// +/// `origin` must already be canonical (see [`discover::normalize_origin`]). +/// Reuses the site-fetch guards every caller-supplied fetch in this crate +/// carries: the target is resolved to public addresses and pinned into the +/// client before the request (SSRF, CWE-918), the body is size-capped — and read +/// STRICTLY, so an incomplete or over-cap body is an error rather than a prefix +/// this gate would decide on — and the response must have come from the origin we +/// asked — the shared redirect +/// policy permits same-host different-PORT hops, so a manifest served after a +/// redirect could otherwise come from a neighbouring origin and be read as +/// this one's declaration. +pub async fn fetch_architecture(origin: &str) -> ArchitectureFetch { + match tokio::time::timeout(FETCH_BUDGET, read_architecture(origin)).await { + Ok(fetched) => fetched, + Err(_) => ArchitectureFetch::Unreachable(format!( + "reading {origin}{ARCHITECTURE_WELL_KNOWN} took longer than {}s", + FETCH_BUDGET.as_secs() + )), + } +} + +async fn read_architecture(origin: &str) -> ArchitectureFetch { + let (url, pinned) = match discover::resolve_public_url(origin).await { + Ok(v) => v, + Err(e) => return ArchitectureFetch::Unreachable(e), + }; + let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); + let client = match discover::site_client(&host, &pinned) { + Ok(c) => c, + Err(e) => return ArchitectureFetch::Unreachable(e), + }; + let expected = url.origin().ascii_serialization(); + let resp = match client + .get(format!("{expected}{ARCHITECTURE_WELL_KNOWN}")) + .send() + .await + { + Ok(r) => r, + Err(e) => { + return ArchitectureFetch::Unreachable(format!( + "could not read {expected}{ARCHITECTURE_WELL_KNOWN}: {e}" + )) + } + }; + let served_by = resp.url().origin().ascii_serialization(); + if served_by != expected { + return ArchitectureFetch::NotDeclared(format!( + "{expected}{ARCHITECTURE_WELL_KNOWN} redirected to {served_by} — the \ + manifest must be served by the application origin itself" + )); + } + if !resp.status().is_success() { + return ArchitectureFetch::NotDeclared(format!( + "{expected}{ARCHITECTURE_WELL_KNOWN} answered {} — the application serves \ + no architecture manifest", + resp.status().as_u16() + )); + } + // STRICTLY read: an incomplete body is an error here, not a prefix. A + // truncated manifest could only ever deny a canister (a prefix cannot add an + // entry), but a gate must not decide on a document it did not fully receive. + let text = match discover::read_strict(resp, discover::MAX_META_BYTES).await { + Ok(text) => text, + Err(e) => { + return ArchitectureFetch::Unreachable(format!( + "{expected}{ARCHITECTURE_WELL_KNOWN} could not be read in full: {e}" + )) + } + }; + match parse_architecture(&text) { + Ok(arch) => ArchitectureFetch::Served(arch), + Err(e) => ArchitectureFetch::NotDeclared(format!( + "{expected}{ARCHITECTURE_WELL_KNOWN} is not readable as a manifest: {e}" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The spec's own example manifest, verbatim from the guide. + const SPEC_EXAMPLE: &str = r#"{ + "version": "1.0.0", + "canisters": [ + { "id": "hcv4s-uaaaa-aaabq-qaaba-cai", "name": "frontend", "role": "the frontend" }, + { "id": "hmxr2-pqaaa-aaabq-qaaaa-cai", "name": "backend", "role": "the backend", + "description": "orders + inventory API; call getApiDoc() first" } + ] + }"#; + + fn p(s: &str) -> Principal { + Principal::from_text(s).unwrap() + } + + #[test] + fn parses_the_spec_example_and_answers_membership() { + let arch = parse_architecture(SPEC_EXAMPLE).expect("spec example must parse"); + assert_eq!(arch.version, "1.0.0"); + assert!(arch.role_of(&p("hcv4s-uaaaa-aaabq-qaaba-cai")).is_some()); + assert!(arch.role_of(&p("hmxr2-pqaaa-aaabq-qaaaa-cai")).is_some()); + // A canister the app does NOT list is not declared, however real it is. + assert!(arch.role_of(&p("ryjl3-tyaaa-aaaaa-aaaba-cai")).is_none()); + // Labels fold name/role/description for display. + assert_eq!( + arch.role_of(&p("hcv4s-uaaaa-aaabq-qaaba-cai")), + Some(Some("the frontend (frontend)".to_string())) + ); + assert_eq!( + arch.findings() + .into_iter() + .map(|(id, _)| id) + .collect::>(), + vec!["hcv4s-uaaaa-aaabq-qaaba-cai", "hmxr2-pqaaa-aaabq-qaaaa-cai"] + ); + } + + // Every malformed body is an error, not an empty-but-usable manifest: a + // membership test against a silently-empty manifest would refuse, but a + // membership test against a body we misread as a manifest could ALLOW. + #[test] + fn parse_fails_closed() { + for (body, why) in [ + ("", "empty"), + ( + "SPA catch-all", + "an SPA catch-all page", + ), + (r#"{"canisters":[{"id":"aaaaa-aa"}]}"#, "no version field"), + ( + r#"{"version":"2.0.0","canisters":[{"id":"aaaaa-aa"}]}"#, + "a future schema", + ), + ( + r#"{"version":"1.0.0","canisters":"aaaaa-aa"}"#, + "canisters not a list", + ), + ] { + assert!( + parse_architecture(body).is_err(), + "{why} must not parse: {body}" + ); + } + } + + // An entry whose id is not a canister principal is inert: it can neither + // authorize a call nor appear as a finding. So a manifest cannot smuggle a + // target past the membership test by spelling it oddly. + #[test] + fn junk_ids_authorize_nothing() { + let arch = parse_architecture( + r#"{"version":"1.0.0","canisters":[ + {"id":"not-a-principal"}, + {"id":""}, + {"id":" ryjl3-tyaaa-aaaaa-aaaba-cai ","role":"padded"}]}"#, + ) + .expect("parses"); + assert_eq!(arch.findings().len(), 1, "only the real id is a finding"); + // A padded id is trimmed to the same principal — the comparison is on + // parsed principals, so whitespace cannot fork the identity. + assert!(arch.role_of(&p("ryjl3-tyaaa-aaaaa-aaaba-cai")).is_some()); + } + + // Forward compatibility: unknown fields (top-level and per entry) and a + // minor/patch bump are accepted, because the spec says consumers must + // ignore what they don't know. + #[test] + fn unknown_fields_and_minor_bumps_are_accepted() { + let arch = parse_architecture( + r#"{"version":"1.4.2","future_key":{"x":1},"canisters":[ + {"id":"ryjl3-tyaaa-aaaaa-aaaba-cai","role":"ledger","future_entry_key":true}]}"#, + ) + .expect("a 1.x manifest with unknown fields must parse"); + assert!(arch.role_of(&p("ryjl3-tyaaa-aaaaa-aaaba-cai")).is_some()); + } + + // Past the entry cap the WHOLE manifest is refused, not truncated: a + // truncated manifest would silently deny a canister the app declared, + // leaving the developer with one canister that mysteriously doesn't work. + #[test] + fn entry_cap_rejects_the_whole_manifest_rather_than_truncating() { + let entry = r#"{"id":"ryjl3-tyaaa-aaaaa-aaaba-cai"}"#; + let at_cap = format!( + r#"{{"version":"1.0.0","canisters":[{}]}}"#, + vec![entry; MAX_ARCHITECTURE_CANISTERS].join(",") + ); + assert!( + parse_architecture(&at_cap).is_ok(), + "exactly at the cap is fine" + ); + let over_cap = format!( + r#"{{"version":"1.0.0","canisters":[{}]}}"#, + vec![entry; MAX_ARCHITECTURE_CANISTERS + 1].join(",") + ); + let msg = parse_architecture(&over_cap).expect_err("over the cap must be refused"); + assert!(msg.contains("truncated"), "the refusal must say why: {msg}"); + } + + // Layer 5: the identity file is one origin, canonicalized, or nothing. + #[test] + fn derivation_origin_file_parses_one_origin_or_nothing() { + assert_eq!( + parse_derivation_origin("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net\n").as_deref(), + Some("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net") + ); + // Canonicalized: case-normalized host, default port dropped. + assert_eq!( + parse_derivation_origin("HTTPS://Example.COM:443").as_deref(), + Some("https://example.com") + ); + // A BOM-prefixed file still reads (deployment tooling adds them). + assert_eq!( + parse_derivation_origin("\u{feff}https://example.com\n").as_deref(), + Some("https://example.com") + ); + for bad in [ + "", + "\n\n", + "http://example.com", // not https + "https://user@example.com", // user-info + "not a url", // unparseable + "https://a.com\nhttps://b.com", // two origins: don't guess + // A bare host is NOT accepted here: an SPA catch-all answering this + // path with one token would otherwise become a bogus cross-origin + // claim, and the alternative-origins check would then refuse to + // resolve the app at all rather than defaulting to its own origin. + "example.com", + "maintenance", + "app", + ] { + assert!( + parse_derivation_origin(bad).is_none(), + "{bad:?} must not resolve" + ); + } + } +} diff --git a/crates/imcp2-core/src/authorization.rs b/crates/imcp2-core/src/authorization.rs new file mode 100644 index 0000000..22c664e --- /dev/null +++ b/crates/imcp2-core/src/authorization.rs @@ -0,0 +1,1219 @@ +//! **Who may write.** The authorization boundary for state-changing canister +//! calls (`canister_update_call`), in two layers. +//! +//! ## Layer 1 — the registration gate (this module) +//! +//! An update call is authorized only when ALL of the following hold. Any one +//! of them failing refuses the call; there is no default-allow path: +//! +//! 1. Layer 2 (below) does not refuse the method. +//! 2. The caller supplies an **`application_origin`** — the https origin of +//! the application the call belongs to. +//! 3. That origin appears in [`REGISTERED_APPLICATIONS`] with an acceptance +//! of the **current** ICP MCP Developer Terms +//! ([`DEVELOPER_TERMS_VERSION`]), and any `derivation_origin` the call +//! would be signed as is one that registration records for it. +//! 4. That exact origin serves a well-formed +//! `/.well-known/ic-architecture` manifest — the composition layer of +//! the [ICP service-discoverability protocol]. +//! 5. The target canister is **pinned by the registration** AND **declared +//! in that live manifest** — so the manifest can narrow the reviewed +//! surface but never widen it. +//! 6. Only then does the call execute. +//! +//! All six must pass, so the ORDER only decides which refusal the caller +//! reads. Layer 2 is evaluated first because it is offline and its refusal is +//! the more specific and more useful one: "transfer 1 ICP" should be answered +//! with "do it in a wallet you control", not with "that application isn't +//! registered" — the latter reads as though a registration would make the +//! transfer possible. It also means a value-moving request never triggers an +//! outbound fetch, and never reveals whether the named origin is registered. +//! +//! ### Why this and not the older discovery signals +//! +//! This server can also *find* canisters behind a domain from a response +//! header, an `/env.json`, or literals mined out of a JS bundle (see +//! [`crate::discover`]). Those are useful for reading, and they remain — but +//! they are **evidence about bytes a frontend happened to ship**, not a +//! statement by the application about what it comprises. A canister id in a +//! bundle says nothing about who operates it, and anything that can serve a +//! header can claim any id. None of them can authorize a write. The +//! architecture manifest can: the application publishes it at its own origin, +//! over HTTPS, as its own declaration. +//! +//! ### Why `derivation_origin` cannot stand in for `application_origin` +//! +//! They are different things and the difference is load-bearing: +//! +//! * A derivation origin is **shared** by design. This crate's own registry +//! maps five NNS frontends onto one derivation origin and eight Oisy hosts +//! onto another, and [`crate::identities::target_origin`] additionally +//! collapses `.icp0.io` and `.icp.net` onto `.ic0.app`. Keying +//! authorization on it would let any frontend in such a set write against +//! a sibling's manifest. +//! * The manifest is served at the **application** origin, so the +//! derivation origin is not even where it would be fetched from. +//! +//! So the two are separate arguments with separate jobs: +//! `application_origin` says *which application this call belongs to* (and is +//! what authorizes it); `derivation_origin` says *whose identity to act as*. +//! Separate, but not unrelated: because nothing in the identity path ties them +//! together, this gate requires the pair to match what registration recorded — +//! otherwise a registered application could borrow an unrelated app's identity +//! to write to a canister it had listed. +//! +//! ### What the protocol does NOT establish +//! +//! Serving a manifest is a technical statement, not a promise. It does not +//! establish that the publisher accepted any terms, that it is entitled to +//! expose every canister it lists, which of its update methods are safe to +//! call, or that its behaviour stays inside this server's policies. Those +//! come from the **ICP MCP Developer Terms** ([`DEVELOPER_TERMS_URL`]), which the +//! publisher accepts out of band; [`REGISTERED_APPLICATIONS`] is this +//! server's record of who has. Hence step 2: the protocol proves composition, +//! the Terms carry the obligations, and an update call needs both. +//! +//! ## Layer 2 — the financial guard ([`crate::compliance`]) +//! +//! Inside the authorized surface, standardized value-moving methods and calls +//! to known finance-related canisters are refused anyway. Layer 2 is +//! deliberately origin-blind — `disallowed_update_method` takes no origin — so +//! no amount of registration can launder a financial call through it. +//! +//! [ICP service-discoverability protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/ + +use candid::Principal; + +use crate::{ + architecture::{self, Architecture, ArchitectureFetch, ARCHITECTURE_WELL_KNOWN}, + compliance, discover, +}; + +/// The revision of the ICP MCP Developer Terms an acceptance must be against +/// for update calls to be authorized. Bumping this **invalidates every +/// acceptance stamped with an older revision** — each publisher's row has to +/// be re-stamped after they accept the new revision, which is the intended +/// behaviour: a materially changed obligation nobody has agreed to yet must +/// not keep authorizing writes. Kept in step with the revision and effective +/// date of the published Terms, whose source text is +/// `docs/icp-mcp-developer-terms-draft.md` in this repository (pinned by a test +/// in the serving binary, so the two cannot drift). +pub const DEVELOPER_TERMS_VERSION: &str = "2026-08-28"; + +/// Where a publisher reads the obligations it is accepting. Named in every +/// refusal this module produces, so an agent can tell the user what the +/// application's developer would have to do. The page is served from the +/// landing site, which is where every human-facing page moved; this origin's +/// own `/developer-terms` permanently redirects there, so either spelling +/// reaches it. +pub const DEVELOPER_TERMS_URL: &str = "https://internetcomputer.org/icp-mcp/developer-terms/"; + +/// One application whose publisher has accepted the ICP MCP Developer Terms. +pub struct RegisteredApplication { + /// The application origin, in canonical form — exactly what + /// [`discover::normalize_origin`] produces (https, lowercased host, + /// default port dropped, no path, no user-info). Pinned by a test. + /// + /// Keyed by **origin**, not by host — deliberately unlike + /// [`crate::discover`]'s host-keyed derivation-origin registry: an + /// acceptance is for the exact origin whose manifest was reviewed, and a + /// different port is a different deployment that must not inherit it. + pub origin: &'static str, + /// Who accepted, for the audit trail. + pub publisher: &'static str, + /// The Developer Terms revision they accepted. Authorizes writes only + /// while it equals [`DEVELOPER_TERMS_VERSION`]. + pub accepted_terms_version: &'static str, + /// When the acceptance was recorded (ISO date). + pub accepted_on: &'static str, + /// The canisters REVIEWED at registration, pinned here. A call must clear + /// both this list and the application's live manifest, so the manifest can + /// **narrow** the surface (dropping a canister stops writes to it at once, + /// on the app's own signal) but can never **widen** it: a publisher that + /// later adds a canister — its own, someone else's, or one it was + /// compromised into listing — gains nothing until a reviewed change adds it + /// here too. Without this pin, "reviewed at registration" would mean + /// reviewed against a document the registrant can rewrite at will. + pub canisters: &'static [&'static str], + /// The Internet Identity derivation origins this application legitimately + /// acts as, in the CANONICAL EFFECTIVE form [`crate::identities::target_origin`] + /// produces (so the gateway remap is already applied). A call passing a + /// `derivation_origin` outside this list is refused. + /// + /// This is what stops one registered origin from borrowing another app's + /// identity: nothing else ties `application_origin` to `derivation_origin` + /// — they are separate arguments resolved independently — so without it a + /// registered application could have the server sign a call to a canister + /// it lists as the user's principal AT AN UNRELATED APP, which that + /// canister may well trust. Usually one entry, equal to `origin`. + pub derivation_origins: &'static [&'static str], +} + +/// Applications whose publishers have accepted the ICP MCP Developer Terms, +/// and whose update surface is therefore reachable through this server. +/// +/// **This table is empty, and that is the shipped default.** An empty registry +/// means no application can receive an update call through this server — the +/// gate fails closed for everyone until a publisher actually accepts the +/// Developer Terms and is added here. Adding a row is a reviewed change to +/// this file, which is also the audit record: who accepted, which revision, +/// and when. +/// +/// Before adding a row, confirm — and record in the review — that: +/// +/// * the publisher accepted revision [`DEVELOPER_TERMS_VERSION`], including +/// the clauses that it is entitled to expose every canister its manifest +/// lists and that its MCP-reachable operations stay inside this server's +/// financial and data policies; +/// * the origin is exactly the one whose `/.well-known/ic-architecture` was +/// reviewed, in canonical form; +/// * every id in `canisters` is one the publisher operates, taken from the +/// manifest as reviewed — not copied from it unread; +/// * every entry in `derivation_origins` is an origin this application really +/// derives against (check `resolve_app`'s `derivation_origin` for it), in +/// the canonical effective form. +/// +/// One row is one ORIGIN. An application served at several origins (its own +/// domain and its `.icp0.io` gateway origin, say) needs a row per +/// origin it will be called with, each with a manifest at that origin — an +/// acceptance is not inherited across origins, and neither is a manifest. +/// +/// **Revocation is removal**: deleting a row (or bumping +/// [`DEVELOPER_TERMS_VERSION`] past what a row carries) closes the gate for +/// that application from the first call after the change is deployed. This +/// table is compiled in, so revocation is a release, not a runtime switch; +/// what "no cache" buys is that nothing survives the release — there is no +/// TTL to wait out and no state to reconcile. +/// +/// A row pins what was reviewed: `canisters` and `derivation_origins`. The live +/// manifest can only ever NARROW that pin, never widen it, so a publisher who +/// later edits its manifest — or is compromised into editing it — cannot grant +/// itself a canister nobody reviewed, and cannot have the server act as the +/// user's identity at an application it does not own. The Developer Terms carry +/// the matching promise (that the publisher may expose everything it lists), and +/// removing the row remains the remedy; but the pins mean the code no longer +/// depends on that promise holding. +pub const REGISTERED_APPLICATIONS: &[RegisteredApplication] = &[]; + +/// What authorized a call, echoed back to the caller so an agent can see +/// exactly which application and which declared canister it acted on — and +/// catch an `application_origin` that resolved to the wrong app. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Authorization { + /// The canonical application origin the call was authorized against. + pub application_origin: String, + /// How the application's own manifest describes the target canister + /// (`name`/`role`/`description`, folded); `None` when it declares the id + /// with no labels. + pub canister_role: Option, +} + +/// The registered application at `origin`, looked up in `registry`. Both sides +/// are canonical origins, so the comparison is exact — no host-only match, no +/// case or port slack. Split from [`registration`] so tests can exercise the +/// lookup against their own table instead of the shipped one. +fn registration_in<'a>( + registry: &'a [RegisteredApplication], + origin: &str, +) -> Option<&'a RegisteredApplication> { + registry.iter().find(|a| a.origin == origin) +} + +/// The refusal for a call that arrived with no `application_origin`. Says what +/// to pass, where to get it, and — because an agent holding a +/// `derivation_origin` will otherwise try it here — why that is not the same +/// value. +fn missing_application_origin() -> String { + format!( + "`application_origin` is required for an update call and was not supplied. An update \ + call is authorized against the application it belongs to: pass the application's https \ + origin (e.g. `https://example.com` — scheme and host, no path), as returned by \ + open_app / resolve_app in `application_origin`. This is NOT the same value as \ + `derivation_origin`: several frontends can share one derivation origin, and the \ + `{ARCHITECTURE_WELL_KNOWN}` manifest that authorizes the call is served at the \ + application origin. Reads (canister_query) need no application origin — only \ + state-changing calls do." + ) +} + +/// The refusal for an origin with no current acceptance on file. Deliberately +/// says the same thing whether the origin is absent or carries a stale +/// revision — both mean "no current acceptance", and the recovery is identical. +fn not_registered(registry: &[RegisteredApplication], origin: &str) -> String { + // When NOTHING is registered, say so: otherwise an agent reads a + // single-origin refusal as "try another origin" and burns a loop + // rediscovering the same answer. + let scope = if registry.is_empty() { + concat!( + " No applications are registered with this server at present, so this is the", + " answer for every application — do not retry with a different origin or", + " canister id." + ) + } else { + "" + }; + format!( + "Update calls to {origin} are not available: its developer has not accepted the current \ + ICP MCP Developer Terms (revision {DEVELOPER_TERMS_VERSION}). State-changing calls \ + through this server are limited to applications that publish a \ + `{ARCHITECTURE_WELL_KNOWN}` manifest under the ICP service-discoverability protocol AND \ + whose developer has accepted those Terms — everything else is refused, including \ + canisters this server can otherwise discover behind the domain.{scope} Reading the \ + application is unaffected: use canister_query (and the OQL tools) instead. If you are \ + the application's developer, the Terms and how to register are at {DEVELOPER_TERMS_URL}." + ) +} + +/// Steps 2–3, offline: turn a caller-supplied `application_origin` into a +/// registered application with a current acceptance, or a refusal. +/// +/// Note the order: the registry is consulted BEFORE anything is fetched, so a +/// caller can never steer this server's HTTP client at an origin of its +/// choosing — the only origins ever fetched are ones already curated into +/// [`REGISTERED_APPLICATIONS`]. +fn authorized_origin_in<'a>( + registry: &'a [RegisteredApplication], + application_origin: Option<&str>, +) -> Result<(String, &'a RegisteredApplication), String> { + // 2. The argument is required. An empty or whitespace-only string counts + // as absent, so a client that "passes" the field blank gets the + // instructive refusal rather than an origin-not-registered one. + let raw = application_origin.map(str::trim).filter(|s| !s.is_empty()); + let Some(raw) = raw else { + return Err(missing_application_origin()); + }; + // Canonicalize with the same function every site fetch in this crate uses: + // https only, real host, no user-info, default port dropped. NOT + // `identities::target_origin` — that one remaps gateway domains for + // IDENTITY derivation, which would fetch the manifest from a different + // host than the caller named. + let Some(origin) = discover::normalize_origin(raw) else { + return Err(format!( + "`application_origin` must be an https origin (scheme + host, e.g. \ + `https://example.com`); {raw:?} is not one. Pass the application origin from \ + open_app / resolve_app." + )); + }; + // 3. A current acceptance of the Developer Terms, on the exact origin. + let app = registration_in(registry, &origin) + .ok_or_else(|| not_registered(registry, &origin))?; + if app.accepted_terms_version != DEVELOPER_TERMS_VERSION { + return Err(not_registered(registry, &origin)); + } + Ok((origin, app)) +} + +/// Steps 4–5, pure: decide against a manifest that has already been fetched +/// (or failed to be). Separated from the fetch so the whole decision is +/// testable offline — the fetch itself adds no policy. +fn decide( + app: &RegisteredApplication, + application_origin: &str, + fetched: &ArchitectureFetch, + canister_id: &Principal, +) -> Result { + // 4. The exact origin's manifest. Both failure modes deny; they differ + // only in what the developer would have to fix, so say which it is + // (a fetch failure is worth retrying, a denial is not). + let arch: &Architecture = match fetched { + ArchitectureFetch::Served(arch) => arch, + ArchitectureFetch::Unreachable(why) => { + return Err(format!( + "Update calls to {application_origin} could not be authorized: its \ + `{ARCHITECTURE_WELL_KNOWN}` manifest could not be read ({why}). The manifest \ + is re-read on every state-changing call and no call proceeds without it, so \ + this is worth retrying; if it keeps failing, the application's origin is not \ + serving the manifest reachably. Reads are unaffected — use canister_query." + )) + } + ArchitectureFetch::NotDeclared(why) => { + return Err(format!( + "Update calls to {application_origin} are not available: {why}. Under the ICP \ + service-discoverability protocol an application declares the canisters it \ + comprises in `{ARCHITECTURE_WELL_KNOWN}`, and this server authorizes a \ + state-changing call only against that declaration. Reads are unaffected — use \ + canister_query." + )) + } + }; + // 5. The target must be one of the canisters the application declares. + // This is the step that makes discovery non-authorizing: an id mined + // from a bundle, an `/env.json`, or a response header reaches this + // check with no standing whatsoever. + // 5a. The pin from the registration review. Checked BEFORE the manifest so + // an id nobody reviewed is refused as unreviewed, whatever the live + // manifest now says about it — the manifest may narrow this list, never + // widen it. + let pinned = canister_id.to_text(); + if !app.canisters.iter().any(|c| *c == pinned) { + return Err(format!( + "{canister_id} is not among the canisters reviewed for {application_origin}. A \ + state-changing call is authorized only against the canisters recorded when the \ + application was registered — an application cannot widen that set by editing its \ + own `{ARCHITECTURE_WELL_KNOWN}` manifest afterwards. If the application has added a \ + canister, its developer needs it reviewed and recorded ({DEVELOPER_TERMS_URL}). \ + Reading this canister is unaffected — use canister_query." + )); + } + // 5b. …and the application's LIVE manifest must still declare it, so + // removing it from the manifest stops writes at once. + let Some(canister_role) = arch.role_of(canister_id) else { + let declared = declared_ids(arch); + return Err(format!( + "{canister_id} is not declared by {application_origin}: its \ + `{ARCHITECTURE_WELL_KNOWN}` manifest lists {declared}. A state-changing call is \ + authorized only against the application's own declaration — finding a canister id \ + behind a domain some other way (a response header, an `/env.json`, a JS bundle) \ + does not authorize writing to it. Check the canister id, or call the application \ + origin that does declare it. Reading this canister is unaffected — use \ + canister_query." + )); + }; + Ok(Authorization { + application_origin: application_origin.to_string(), + canister_role, + }) +} + +/// The declared ids, for the "not declared" refusal — bounded so a large +/// manifest can't turn one refusal into a wall of principals. +fn declared_ids(arch: &Architecture) -> String { + const MAX_LISTED: usize = 12; + let ids: Vec = arch.findings().into_iter().map(|(id, _)| id).collect(); + if ids.is_empty() { + return "no canisters".to_string(); + } + if ids.len() > MAX_LISTED { + format!( + "{} (and {} more)", + ids[..MAX_LISTED].join(", "), + ids.len() - MAX_LISTED + ) + } else { + ids.join(", ") + } +} + +/// The whole gate: steps 1–6 for one update call. `Ok` means the call is +/// authorized and may execute; `Err` is the complete refusal text for the +/// caller. Fails closed at every step. +/// +/// A thin binding of [`authorize_with`] to the shipped registry and the real +/// manifest fetch. The policy itself lives there, and the tests drive THAT +/// function — with their own registry and their own fetch — so no test +/// re-implements the chain this function walks. +pub async fn authorize_update_call( + application_origin: Option<&str>, + derivation_origin: Option<&str>, + canister_id: &Principal, + method: &str, +) -> Result { + authorize_with( + REGISTERED_APPLICATIONS, + application_origin, + derivation_origin, + canister_id, + method, + |origin| async move { architecture::fetch_architecture(&origin).await }, + ) + .await +} + +/// The gate's six steps, with the registry and the manifest fetch injected. +/// +/// `fetch` is called with the canonical application origin, and — this is a +/// property, not an implementation detail — is called at most once, and ONLY +/// after steps 1–3 have passed. That is what keeps the set of origins this +/// server will ever fetch from equal to the curated registry: a caller cannot +/// make it request an origin of their choosing, whatever they pass. +async fn authorize_with( + registry: &[RegisteredApplication], + application_origin: Option<&str>, + derivation_origin: Option<&str>, + canister_id: &Principal, + method: &str, + fetch: F, +) -> Result +where + F: FnOnce(String) -> Fut, + Fut: std::future::Future, +{ + // 1. Layer 2 first: offline, and the more specific refusal for the request + // the caller actually made (see the module docs). A value-moving call is + // therefore answered without reaching the network or the registry at all. + if let Some(refusal) = compliance::disallowed_update_method(canister_id, method) { + return Err(refusal); + } + // 2–3. The argument, and a current acceptance on that exact origin. + let (origin, app) = authorized_origin_in(registry, application_origin)?; + // 3a. The identity the call would be signed as must be one this application + // actually acts as. `application_origin` and `derivation_origin` are + // resolved independently and nothing else relates them, so without this + // a registered application could have the server sign a call to a + // canister it lists as the user's principal at an UNRELATED app — an + // identity that app's canisters may trust. Offline, and before the fetch. + if let Some(acting_as) = derivation_origin { + if !app.derivation_origins.contains(&acting_as) { + return Err(format!( + "{origin} does not act as the identity {acting_as}. An update call is signed as \ + your account at the application being called, so `derivation_origin` must be one \ + of the origins recorded for {origin} when it was registered — pairing one \ + application's origin with another application's identity is refused. Use the \ + `derivation_origin` that open_app / resolve_app returns for {origin}, or omit it \ + to call anonymously. Reads are unaffected." + )); + } + } + // 4–5. The manifest, fetched fresh with no cache — so a manifest change or + // a revocation takes effect on the next call, not at the end of a TTL. + let fetched = fetch(origin.clone()).await; + let authorization = match decide(app, &origin, &fetched, canister_id) { + Ok(a) => a, + Err(refusal) => { + // Refused after the caller cleared registration: the operator's + // signal that a REGISTERED application's manifest is unreadable or + // has stopped declaring a canister its users are calling. The + // refusal text goes to the caller; this is the operational half. + tracing::info!( + application_origin = %origin, + publisher = %app.publisher, + canister_id = %canister_id, + method = %method, + "refused an update call at a registered application's manifest" + ); + return Err(refusal); + } + }; + // 6. One line per authorized write, naming what authorized it: the + // operator's record of which registration admitted a state-changing call. + tracing::info!( + application_origin = %origin, + publisher = %app.publisher, + terms_version = %app.accepted_terms_version, + accepted_on = %app.accepted_on, + canister_id = %canister_id, + method = %method, + "authorized an update call against a registered application" + ); + Ok(authorization) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::architecture::parse_architecture; + + // A registry standing in for the shipped one. Tests must not depend on + // curation: the shipped table is empty by design, and a real acceptance is + // a legal fact, not a fixture. + const TEST_REGISTRY: &[RegisteredApplication] = &[ + RegisteredApplication { + origin: "https://example-app.test", + publisher: "Example App GmbH", + accepted_terms_version: DEVELOPER_TERMS_VERSION, + accepted_on: "2026-08-28", + canisters: &[APP_BACKEND, APP_FRONTEND, ICP_LEDGER], + derivation_origins: &["https://example-app.test"], + }, + RegisteredApplication { + origin: "https://stale-app.test", + publisher: "Stale App GmbH", + accepted_terms_version: "2026-01-01", + accepted_on: "2026-01-01", + canisters: &[APP_BACKEND], + derivation_origins: &["https://stale-app.test"], + }, + ]; + + const REGISTERED: &str = "https://example-app.test"; + + // Deliberately NOT the ids from the spec's example manifest: those belong + // to a real exchange and are on the finance list, so Layer 2 would refuse + // them and mask what these tests are checking. These are ordinary app + // canisters on no list. + const APP_BACKEND: &str = "dmp3l-2yaaa-aaaae-aamva-cai"; + const APP_FRONTEND: &str = "bkyz2-fmaaa-aaaaa-qaaaq-cai"; + const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; + + fn p(s: &str) -> Principal { + Principal::from_text(s).unwrap() + } + + /// A served manifest declaring exactly `ids`. + fn manifest(ids: &[&str]) -> ArchitectureFetch { + let entries: Vec = ids + .iter() + .map(|id| format!(r#"{{"id":"{id}","name":"backend","role":"the backend"}}"#)) + .collect(); + let body = format!(r#"{{"version":"1.0.0","canisters":[{}]}}"#, entries.join(",")); + ArchitectureFetch::Served(parse_architecture(&body).expect("fixture manifest parses")) + } + + /// A served manifest from a literal body. + fn served(body: &str) -> ArchitectureFetch { + ArchitectureFetch::Served(parse_architecture(body).expect("fixture parses")) + } + + /// Drive the PRODUCTION gate — [`authorize_with`], the very function + /// [`authorize_update_call`] binds — against a chosen registry and a canned + /// manifest, reporting how many times the fetch was reached. No test + /// re-implements the chain, so a step added to or reordered in the gate + /// cannot slip past these. + async fn gate( + registry: &[RegisteredApplication], + application_origin: Option<&str>, + fetched: ArchitectureFetch, + canister_id: &Principal, + method: &str, + ) -> (Result, usize) { + gate_as(registry, application_origin, None, fetched, canister_id, method).await + } + + /// As [`gate`], but signing as a specific derivation origin. + async fn gate_as( + registry: &[RegisteredApplication], + application_origin: Option<&str>, + derivation_origin: Option<&str>, + fetched: ArchitectureFetch, + canister_id: &Principal, + method: &str, + ) -> (Result, usize) { + let fetches = std::rc::Rc::new(std::cell::Cell::new(0usize)); + let counter = std::rc::Rc::clone(&fetches); + let result = authorize_with( + registry, + application_origin, + derivation_origin, + canister_id, + method, + |origin| async move { + counter.set(counter.get() + 1); + assert_eq!( + discover::normalize_origin(&origin).as_deref(), + Some(origin.as_str()), + "the gate must hand the fetch a canonical origin" + ); + fetched + }, + ) + .await; + (result, fetches.get()) + } + + /// The common case: the gate over [`TEST_REGISTRY`], result only. + async fn authorize( + application_origin: Option<&str>, + fetched: ArchitectureFetch, + canister_id: &Principal, + method: &str, + ) -> Result { + gate(TEST_REGISTRY, application_origin, fetched, canister_id, method).await.0 + } + + // (c) The happy path: a registered application, a canister its own manifest + // declares, an ordinary method — authorized, the echo names what authorized + // it, and the manifest was actually read. + #[tokio::test] + async fn registered_app_can_update_a_declared_canister() { + let (result, fetches) = gate( + TEST_REGISTRY, + Some(REGISTERED), + manifest(&[APP_FRONTEND, APP_BACKEND]), + &p(APP_BACKEND), + "place_order", + ) + .await; + let auth = result.expect("a registered app's declared canister must be authorized"); + assert_eq!(auth.application_origin, REGISTERED); + assert_eq!(auth.canister_role.as_deref(), Some("the backend (backend)")); + assert_eq!(fetches, 1, "the manifest is read once per call, not zero or twice"); + } + + // The origin argument is canonicalized before the lookup, so the same + // application reached with a differently-spelled origin still authorizes — + // and a non-https or malformed value is refused outright rather than + // silently upgraded. + #[tokio::test] + async fn application_origin_is_canonicalized_then_matched_exactly() { + for spelling in [ + REGISTERED, + "HTTPS://Example-App.TEST", + "https://example-app.test:443", + " https://example-app.test/ ", + "example-app.test", // bare host: https is prepended + ] { + let r = authorize(Some(spelling), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping") + .await; + assert!(r.is_ok(), "{spelling} must resolve to the registered origin"); + } + for bad in [ + "http://example-app.test", // not https + "https://user@example-app.test", // user-info + "https://example-app.test:8443", // a different origin, not registered + "not a url", + ] { + let (r, fetches) = gate( + TEST_REGISTRY, + Some(bad), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "ping", + ) + .await; + assert!(r.is_err(), "{bad} must not authorize"); + assert_eq!(fetches, 0, "{bad} must not become a fetch target"); + } + } + + // (a) Provenance cannot authorize. A canister the application does not + // declare is refused however this server found it — the gate never sees a + // `sources` list (its signature has no way to receive one), so a bundle + // literal, an `/env.json` key, a response header, or any other heuristic + // has no path to an authorization. + #[tokio::test] + async fn only_the_manifest_authorizes_never_discovery() { + // Stand in for the OISY backend as this server really discovers it: from + // a labelled JS-bundle constant and the gateway header, never from a + // manifest. It is a real canister, reachable, and the app it belongs to + // is not the one being called. + let mined = p("be2us-64aaa-aaaaa-qaabq-cai"); + // Neither reviewed nor declared: refused, and the id is named so the + // caller can see which canister it asked for. + let msg = authorize( + Some(REGISTERED), + manifest(&[APP_FRONTEND, APP_BACKEND]), + &mined, + "set_name", + ) + .await + .expect_err("an unreviewed, undeclared canister must be refused"); + assert!(msg.contains(&mined.to_text()), "the refusal names the id: {msg}"); + // Even if the application ADDS it to its live manifest — the case a + // mined id most plausibly reaches — the registration pin still refuses. + let msg = authorize( + Some(REGISTERED), + manifest(&[APP_BACKEND, "be2us-64aaa-aaaaa-qaabq-cai"]), + &mined, + "set_name", + ) + .await + .expect_err("declaring it after review must not authorize it"); + assert!(msg.contains("not among the canisters reviewed"), "{msg}"); + // A REVIEWED canister the application does not declare is refused too, + // by the manifest half — so the two checks are independent, and the + // "provenance authorizes nothing" property does not rest on either alone. + let msg = authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_FRONTEND), "set_name") + .await + .expect_err("reviewed but undeclared must be refused"); + assert!(msg.contains(ARCHITECTURE_WELL_KNOWN), "the manifest path: {msg}"); + assert!(msg.contains("does not authorize writing"), "and why: {msg}"); + // An application declaring NOTHING authorizes nothing. + assert!(authorize(Some(REGISTERED), manifest(&[]), &p(APP_BACKEND), "set_name") + .await + .is_err()); + } + + // Membership is decided on parsed principals, so no spelling of a declared + // id can be mistaken for a different canister — and a padded entry still + // matches the canister it names. + #[tokio::test] + async fn membership_compares_parsed_principals() { + let padded = served(&format!( + r#"{{"version":"1.0.0","canisters":[{{"id":" {APP_BACKEND} "}}]}}"# + )); + assert!( + authorize(Some(REGISTERED), padded, &p(APP_BACKEND), "ping").await.is_ok(), + "a padded declaration still names its canister" + ); + // An entry that is not a principal at all authorizes nothing, even + // though its text is a prefix of a real id. + let junk = served(r#"{"version":"1.0.0","canisters":[{"id":"dmp3l-2yaaa"}]}"#); + assert!( + authorize(Some(REGISTERED), junk, &p(APP_BACKEND), "ping").await.is_err(), + "a non-principal entry must not authorize a lookalike" + ); + } + + // (b) A canister that IS declared still gets no write access when the + // application's developer has no current Terms acceptance — and the registry + // is consulted BEFORE any fetch, which is what keeps the set of origins this + // server will fetch from equal to the curated registry. + #[tokio::test] + async fn declared_but_unaccepted_terms_does_not_authorize() { + let (result, fetches) = gate( + TEST_REGISTRY, + Some("https://unregistered.test"), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "place_order", + ) + .await; + let msg = result.expect_err("an unregistered origin must be refused"); + assert!(msg.contains("Developer Terms"), "{msg}"); + assert!(msg.contains(DEVELOPER_TERMS_VERSION), "{msg}"); + assert!(msg.contains("canister_query"), "reads stay available: {msg}"); + assert_eq!( + fetches, 0, + "an unregistered origin must never be fetched — that is what keeps the fetch \ + target curated rather than caller-chosen" + ); + } + + // (e) A Terms bump closes the gate for a stale acceptance: the check is + // equality against the current revision, not "has ever accepted". + #[tokio::test] + async fn a_stale_terms_acceptance_does_not_authorize() { + assert_ne!( + registration_in(TEST_REGISTRY, "https://stale-app.test") + .expect("the row exists") + .accepted_terms_version, + DEVELOPER_TERMS_VERSION, + "fixture must carry an old revision" + ); + let (result, fetches) = gate( + TEST_REGISTRY, + Some("https://stale-app.test"), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "place_order", + ) + .await; + let msg = result.expect_err("a stale acceptance must be refused"); + assert!(msg.contains(DEVELOPER_TERMS_VERSION), "{msg}"); + assert_eq!(fetches, 0, "a stale row is not a fetch target either"); + } + + // (e) Revocation is removal, and it takes effect on the next call: the same + // origin and canister, authorized against a registry that still holds the + // row, refused against one that no longer does. + #[tokio::test] + async fn revocation_closes_the_gate_on_the_next_call() { + let call = |registry: &'static [RegisteredApplication]| async move { + gate(registry, Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping") + .await + .0 + }; + assert!(call(TEST_REGISTRY).await.is_ok(), "registered while the row is present"); + let msg = call(&[]).await.expect_err("removing the row refuses the very next call"); + assert!(msg.contains("Developer Terms"), "{msg}"); + } + + // (e) A manifest change takes effect on the next call: the decision is a + // function of the manifest read during THAT call, with nothing memoized + // between calls. If a cache is ever added, this test becomes its TTL + // contract and must advance a clock. + #[tokio::test] + async fn a_manifest_that_drops_the_canister_stops_authorizing() { + assert!( + authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order") + .await + .is_ok(), + "declared: authorized" + ); + let msg = + authorize(Some(REGISTERED), manifest(&[APP_FRONTEND]), &p(APP_BACKEND), "place_order") + .await + .expect_err("dropped from the manifest: refused"); + assert!(msg.contains("is not declared by"), "{msg}"); + // And back again, so the second result is the new manifest talking + // rather than a one-way latch. + assert!( + authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order") + .await + .is_ok(), + "re-declared: authorized again" + ); + } + + // (e) Fail closed when the manifest cannot be read at all — and say so + // distinguishably, since a fetch failure is worth retrying while a denial + // is not. + #[tokio::test] + async fn an_unreadable_manifest_fails_closed_and_says_it_is_retryable() { + let msg = authorize( + Some(REGISTERED), + ArchitectureFetch::Unreachable("dns failure".into()), + &p(APP_BACKEND), + "place_order", + ) + .await + .expect_err("an unreachable manifest must refuse"); + assert!(msg.contains("could not be read"), "{msg}"); + assert!(msg.contains("worth retrying"), "{msg}"); + + let msg = authorize( + Some(REGISTERED), + ArchitectureFetch::NotDeclared("answered 404".into()), + &p(APP_BACKEND), + "place_order", + ) + .await + .expect_err("a missing manifest must refuse"); + assert!(msg.contains("answered 404"), "{msg}"); + assert!(!msg.contains("worth retrying"), "a denial is not a retry: {msg}"); + } + + // (d) Layer 2 still refuses inside the authorized surface. Registration buys + // an application access to its OWN declared canisters; it does not make a + // value-moving call acceptable — even when the application declares the + // ledger in its own manifest. Layer 2 is also evaluated FIRST, so the caller + // gets the do-it-yourself redirect rather than a registration message, and + // the call costs no fetch. + #[tokio::test] + async fn the_financial_guard_still_refuses_inside_an_authorized_surface() { + // A standardized transfer is refused on any canister… + let (result, fetches) = gate( + TEST_REGISTRY, + Some(REGISTERED), + manifest(&[APP_BACKEND, ICP_LEDGER]), + &p(APP_BACKEND), + "icrc1_transfer", + ) + .await; + let msg = result.expect_err("a standardized transfer must stay refused"); + assert!(msg.contains("icrc1_transfer"), "{msg}"); + assert!( + msg.contains("outside this connector, in a trusted interface they control"), + "the redirect sends the user outside this connector: {msg}" + ); + assert_eq!(fetches, 0, "a value-moving call is refused without reaching the network"); + // …and every update on a known finance canister is refused, whatever the + // application says about it. + let msg = authorize( + Some(REGISTERED), + manifest(&[APP_BACKEND, ICP_LEDGER]), + &p(ICP_LEDGER), + "transfer", + ) + .await + .expect_err("the ledger must stay refused"); + assert!(msg.contains("the ICP ledger"), "{msg}"); + // The same non-financial method on the app's own canister is fine, so + // the refusals above are Layer 2 talking, not Layer 1. + assert!(authorize( + Some(REGISTERED), + manifest(&[APP_BACKEND, ICP_LEDGER]), + &p(APP_BACKEND), + "place_order" + ) + .await + .is_ok()); + } + + // Layer 2 runs before the registration checks, so a value-moving request is + // answered the same way whether or not the named application is registered: + // the caller learns to use their own wallet, and learns nothing about the + // registry by probing with one. + #[tokio::test] + async fn the_financial_refusal_does_not_depend_on_registration() { + for origin in [Some(REGISTERED), Some("https://unregistered.test"), None] { + let msg = authorize(origin, manifest(&[ICP_LEDGER]), &p(ICP_LEDGER), "icrc1_transfer") + .await + .expect_err("a transfer must be refused whatever the origin"); + assert!( + msg.contains("icrc1_transfer") && !msg.contains("Developer Terms"), + "{origin:?} must get the financial refusal, not a registration one: {msg}" + ); + } + } + + // (1) The argument is mandatory, and the refusal teaches the recovery — + // including that the derivation origin is a different value, which is the + // mistake an agent holding one will otherwise make. + #[tokio::test] + async fn a_missing_application_origin_is_refused_with_the_recovery() { + for missing in [None, Some(""), Some(" ")] { + let msg = authorize(missing, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping") + .await + .expect_err("an absent application_origin must be refused"); + assert!(msg.contains("`application_origin` is required"), "{msg}"); + assert!(msg.contains("derivation_origin"), "names the confusable value: {msg}"); + assert!(msg.contains("open_app"), "says where to get it: {msg}"); + } + } + + // The shipped registry is well-formed: canonical origins, no duplicates, no + // blank fields, and no row carrying a revision other than the current one (a + // stale row is dead weight that reads as authorization). Vacuously true while + // the table is empty — which is the shipped default, so this test is the + // guard for the day rows are added. + #[test] + fn the_shipped_registry_is_well_formed() { + let mut seen: Vec<&str> = Vec::new(); + for app in REGISTERED_APPLICATIONS { + assert_eq!( + discover::normalize_origin(app.origin).as_deref(), + Some(app.origin), + "{}: origins must be stored in canonical form", + app.origin + ); + assert!(!seen.contains(&app.origin), "{}: duplicate row", app.origin); + seen.push(app.origin); + assert!(!app.publisher.trim().is_empty(), "{}: publisher required", app.origin); + assert!(!app.accepted_on.trim().is_empty(), "{}: acceptance date required", app.origin); + // A row with no pinned canisters authorizes nothing, so it is dead + // weight that reads like a registration; an empty derivation-origin + // list means the app can only ever be called anonymously, which is + // almost certainly an oversight rather than an intent. + assert!( + !app.canisters.is_empty(), + "{}: pin the canisters reviewed at registration, or remove the row", + app.origin + ); + assert!( + !app.derivation_origins.is_empty(), + "{}: record the derivation origin(s) this application acts as", + app.origin + ); + for id in app.canisters { + let parsed = Principal::from_text(id) + .unwrap_or_else(|e| panic!("{}: pinned id {id:?}: {e}", app.origin)); + assert_eq!( + &parsed.to_text(), + id, + "{}: pinned ids must be in canonical text form", + app.origin + ); + } + for d in app.derivation_origins { + // Stored in the canonical EFFECTIVE form, so the comparison in + // the gate — which receives an already-remapped origin — matches. + assert_eq!( + &crate::identities::target_origin(d), + d, + "{}: derivation origins must be stored in canonical effective form", + app.origin + ); + } + assert_eq!( + app.accepted_terms_version, DEVELOPER_TERMS_VERSION, + "{}: a row that does not carry the current Terms revision authorizes nothing — \ + re-stamp it after the publisher accepts, or remove it", + app.origin + ); + } + } + + // An EMPTY shipped registry authorizes nothing at all — the state this + // server ships in. Pinned so "the gate fails closed for everyone until a + // publisher is added" is a tested property rather than a claim in a doc + // comment, and so a future default-allow path cannot creep in unnoticed. + // The refusal also SAYS the registry is empty, so an agent stops instead of + // looping over other origins and canister ids to reach the same answer. + #[tokio::test] + async fn an_empty_registry_authorizes_nothing_and_says_so() { + for origin in [Some(REGISTERED), Some("https://anything.test")] { + let (result, fetches) = + gate(&[], origin, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order").await; + let msg = result.expect_err("must be refused against an empty registry"); + assert!( + msg.contains("No applications are registered"), + "{origin:?}: the refusal must say the registry is empty: {msg}" + ); + assert!(msg.contains("do not retry"), "{origin:?}: and say not to loop: {msg}"); + assert_eq!(fetches, 0, "{origin:?} must not be fetched"); + } + // A missing argument still gets the argument's own refusal, not the + // empty-registry one — the caller's first problem is the one to fix. + let msg = gate(&[], None, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "place_order") + .await + .0 + .expect_err("no origin at all must be refused"); + assert!(msg.contains("`application_origin` is required"), "{msg}"); + // …and a non-empty registry does not carry the empty-registry wording. + let msg = authorize( + Some("https://unregistered.test"), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "place_order", + ) + .await + .expect_err("refused"); + assert!(!msg.contains("No applications are registered"), "{msg}"); + } + + // The registration PIN bounds the live manifest: a canister the manifest + // declares but the registration never recorded is refused. Without this, a + // publisher (or whoever compromised it) could widen its own write scope by + // editing a document only it controls, after the review that admitted it. + #[tokio::test] + async fn the_manifest_cannot_widen_the_reviewed_surface() { + // An id nobody reviewed — the registration lists APP_BACKEND, + // APP_FRONTEND and ICP_LEDGER, not this. + let unreviewed = p("be2us-64aaa-aaaaa-qaabq-cai"); + let msg = authorize( + Some(REGISTERED), + manifest(&[APP_BACKEND, "be2us-64aaa-aaaaa-qaabq-cai"]), + &unreviewed, + "set_name", + ) + .await + .expect_err("a canister the manifest added after review must be refused"); + assert!(msg.contains("not among the canisters reviewed"), "{msg}"); + assert!(msg.contains("cannot widen"), "and say why: {msg}"); + // The manifest may still NARROW: a pinned canister the app has dropped + // from its manifest stops working immediately. + let msg = authorize(Some(REGISTERED), manifest(&[APP_FRONTEND]), &p(APP_BACKEND), "ping") + .await + .expect_err("dropped from the live manifest: refused"); + assert!(msg.contains("is not declared by"), "{msg}"); + // Pinned AND declared: authorized. + assert!(authorize(Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping") + .await + .is_ok()); + } + + // The identity a call is signed as must be one the named application acts + // as. `application_origin` and `derivation_origin` are separate arguments + // resolved independently, so without this check a registered application + // could have the server sign a call to a canister it lists as the user's + // principal AT AN UNRELATED APP — an identity that canister may trust. + #[tokio::test] + async fn an_application_cannot_borrow_another_apps_identity() { + // The registered application acting as its own identity: fine. + let (result, fetches) = gate_as( + TEST_REGISTRY, + Some(REGISTERED), + Some("https://example-app.test"), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "place_order", + ) + .await; + assert!(result.is_ok(), "its own identity must be allowed: {result:?}"); + assert_eq!(fetches, 1); + + // The same application asking to sign as a DIFFERENT app's identity: + // refused, offline, before the manifest is even read. + for victim in ["https://victim.test", "https://nns.ic0.app", "https://oisy.com"] { + let (result, fetches) = gate_as( + TEST_REGISTRY, + Some(REGISTERED), + Some(victim), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "place_order", + ) + .await; + let msg = result.expect_err("borrowing another app's identity must be refused"); + assert!(msg.contains("does not act as the identity"), "{msg}"); + assert!(msg.contains(victim), "the refusal names the identity asked for: {msg}"); + assert_eq!(fetches, 0, "and it is refused before any fetch"); + } + + // An anonymous call (no identity at all) is unaffected by this check. + assert!(gate_as( + TEST_REGISTRY, + Some(REGISTERED), + None, + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "place_order" + ) + .await + .0 + .is_ok()); + } + + // Every refusal this module produces is prose a user reads, so none of them + // may carry a run of whitespace. Pinned because the bug is invisible in + // source: a `\`-continued literal that rustfmt later joins onto one line + // keeps the continuation's indentation as literal spaces, and the assertions + // above all match single-line substrings that straddle no continuation. + #[tokio::test] + async fn no_refusal_carries_stray_whitespace() { + let mut refusals = vec![ + // Step 2: the argument is missing. + gate(TEST_REGISTRY, None, manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping").await.0, + // Step 2: the argument is not an origin. + gate(TEST_REGISTRY, Some("not a url"), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping") + .await + .0, + // Step 3: not registered, against a NON-empty registry… + gate( + TEST_REGISTRY, + Some("https://unregistered.test"), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "ping", + ) + .await + .0, + // …and against an empty one, which appends the extra sentence. + gate(&[], Some(REGISTERED), manifest(&[APP_BACKEND]), &p(APP_BACKEND), "ping").await.0, + // Step 4: unreachable, and not served. + gate( + TEST_REGISTRY, + Some(REGISTERED), + ArchitectureFetch::Unreachable("dns failure".into()), + &p(APP_BACKEND), + "ping", + ) + .await + .0, + gate( + TEST_REGISTRY, + Some(REGISTERED), + ArchitectureFetch::NotDeclared("answered 404".into()), + &p(APP_BACKEND), + "ping", + ) + .await + .0, + // Step 5: declared by nobody, and a long list that hits the cap. + gate(TEST_REGISTRY, Some(REGISTERED), manifest(&[]), &p(APP_BACKEND), "ping").await.0, + gate( + TEST_REGISTRY, + Some(REGISTERED), + manifest(&[APP_FRONTEND]), + &p(APP_BACKEND), + "ping", + ) + .await + .0, + ]; + // Layer 2's refusals travel the same path, so hold them to it too. + refusals.push( + gate( + TEST_REGISTRY, + Some(REGISTERED), + manifest(&[APP_BACKEND]), + &p(APP_BACKEND), + "icrc1_transfer", + ) + .await + .0, + ); + for r in refusals { + let msg = r.expect_err("every case above must refuse"); + assert!(!msg.contains(" "), "a refusal carries a run of spaces: {msg:?}"); + assert!(!msg.contains('\n'), "a refusal carries a newline: {msg:?}"); + assert!(!msg.contains('\t'), "a refusal carries a tab: {msg:?}"); + } + } + + // The fixture registry is held to the same shape as the shipped one, so it + // can't drift into testing something the real table could never be. (The + // deliberately-stale row is exempt from the revision rule — it exists to + // prove the revision rule.) + #[test] + fn the_test_registry_mirrors_the_shipped_shape() { + for app in TEST_REGISTRY { + assert_eq!( + discover::normalize_origin(app.origin).as_deref(), + Some(app.origin), + "{}: fixture origins must be canonical too", + app.origin + ); + assert!(!app.canisters.is_empty(), "{}: fixture rows are pinned too", app.origin); + for d in app.derivation_origins { + assert_eq!( + &crate::identities::target_origin(d), + d, + "{}: fixture derivation origins must be canonical effective form too", + app.origin + ); + } + } + } +} diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs index 353c846..40be5aa 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -187,6 +187,33 @@ pub struct CanisterUpdateCallArgs { pub canister_id: String, /// Update method name to invoke. pub method: String, + /// REQUIRED. The https origin of the application this call belongs to — + /// scheme and host only, e.g. `https://example.com` (no path). This is + /// what AUTHORIZES the call: the origin must be a registered application + /// whose developer accepted the ICP MCP Developer Terms, its + /// `/.well-known/ic-architecture` manifest is read fresh on every call, + /// and the target canister must be one the manifest declares. A canister + /// id found any other way (a response header, an `/env.json`, a JS + /// bundle) cannot be written to. Get the value from open_app / resolve_app + /// (`application_origin`). NOT interchangeable with `derivation_origin`: + /// several frontends can share one derivation origin, and the manifest + /// lives at the application origin. Reads (canister_query) need no + /// application origin. + /// + /// `Option` + `schemars(required)` deliberately: the SCHEMA marks it + /// required, so a client sends it rather than discovering the requirement + /// from an error, while the type still lets a client that omits it anyway + /// reach the gate's own refusal — which names the argument, says where to + /// get it, and distinguishes it from `derivation_origin` — instead of an + /// opaque invalid-params protocol error rmcp would raise for a missing + /// required `String`. + /// + /// No `#[serde(default)]`: schemars treats a defaulted field as optional + /// regardless of `required` (schemars_derive `schema_exprs.rs`), and serde + /// already deserializes a missing `Option` field to `None` without it — so + /// the pair only works this way round. Pinned by a test on both halves. + #[schemars(required)] + pub application_origin: Option, /// Arguments in textual Candid syntax, e.g. `()` or `(record { owner = principal "..." })`. #[serde(default = "default_args")] pub args: String, @@ -219,6 +246,16 @@ pub struct CanisterUpdateCallOutput { pub canister_id: String, /// The method that was invoked. pub method: String, + /// The registered application origin the call was authorized against, in + /// canonical form. Compare it with what you passed to catch an origin that + /// canonicalized to a different application than you meant. + pub application_origin: String, + /// How that application's own `/.well-known/ic-architecture` manifest + /// describes the canister that was called (its name/role) — null when the + /// manifest declares the id with no labels. Read it as confirmation that + /// the canister you called is the one the application says it is. + #[serde(skip_serializing_if = "Option::is_none")] + pub declared_as: Option, /// The decoded reply in textual Candid. pub reply: String, /// The principal the call was signed as — null for an anonymous call. diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index b735600..e5a4931 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -1,4 +1,20 @@ -//! The financial-transactions guard for the generic update-call tool. +//! **Layer 2 of the write gate: the financial-transactions guard.** +//! +//! `canister_update_call` is authorized in two layers, and this is the inner +//! one. [`crate::authorization`] decides *whether the application may be +//! written to at all* — it must be registered under the ICP +//! service-discoverability protocol, with its developer's acceptance of the +//! ICP MCP Developer Terms on file, and must declare the target canister in +//! its own `/.well-known/ic-architecture` manifest. This module then refuses +//! value-moving calls **inside** that authorized surface. +//! +//! The two layers answer different questions and neither substitutes for the +//! other. Layer 1 is what keeps arbitrary canisters — a ledger a user names by +//! hand, a canister mined out of a frontend bundle — out of reach entirely, so +//! this list is not the thing standing between an agent and the ICP ledger. +//! Layer 2 is what keeps a *registered* application from moving value through +//! its own authorized surface, which registration must never buy: it is +//! deliberately origin-blind, so no registration state can reach it. //! //! This server is not a financial tool: its purpose is reading, building, and //! operating canisters, and the marketplace directories it is listed in @@ -81,7 +97,11 @@ //! launch: entries cover each service's central canisters (verified //! against the IC dashboard's registry and the services' own published //! sources), and the standardized-methods group plus the stated policy -//! cover the rest. +//! cover the rest. What bounds the un-enumerable remainder is Layer 1, not +//! this list: an update call can only reach a canister a registered +//! application declares as its own, so a bespoke value-moving method is +//! reachable only inside an application whose developer accepted Terms +//! that forbid exposing one. //! * Legacy pre-ICRC token standards (DIP20/EXT `transfer`/`transferFrom`/ //! `approve` on arbitrary canisters) are deliberately NOT matched: the //! names are too abstract to block everywhere without breaking diff --git a/crates/imcp2-core/src/discover.rs b/crates/imcp2-core/src/discover.rs index b225342..68c076b 100644 --- a/crates/imcp2-core/src/discover.rs +++ b/crates/imcp2-core/src/discover.rs @@ -1,24 +1,38 @@ -//! Best-effort discovery of the canisters behind a web domain served from the -//! Internet Computer, folding together the patterns we've seen across apps: +//! Discovery of the canisters behind a web domain served from the Internet +//! Computer: one **declaration** and three **heuristics**, in that order of +//! authority. //! -//! 1. **App-declared metadata** (most authoritative — the app says so): -//! the `ic:canister-id` `` on `/ai-connect.html` (the App Connect -//! bridge page, spec §4.7/§6.1 — the app's MAIN backend), and the -//! `/.well-known/ic-app.json` manifest enumerating ALL the app's -//! canisters with roles (our proposed convention for the spec's deferred -//! §6.3 "multi-canister applications" — see README). -//! 2. `x-ic-canister-id` response header — the frontend/asset canister. This -//! is the one universal signal (the HTTP gateway sets it). -//! 3. a runtime config asset (`/env.json`) carrying `*canister_id*` keys — +//! 1. **The app's own architecture manifest** — +//! `/.well-known/ic-architecture`, the composition layer of the [ICP +//! service-discoverability protocol] ([`crate::architecture`]): the +//! application enumerates the canisters it comprises, with names and +//! roles. This is the app *declaring* its composition at its own origin, +//! which is why it is also the only source that can authorize a +//! state-changing call ([`crate::authorization`]). +//! 2. `/.well-known/ic-app.json` — the earlier, DFINITY-proposed manifest +//! the protocol's layer 1 supersedes. Still read, so apps that shipped it +//! keep being discovered while they migrate, but it is a **read-only +//! fallback**: it authorizes nothing. +//! 3. `x-ic-canister-id` response header — the frontend/asset canister. The +//! one universal signal (the HTTP gateway sets it). +//! 4. a runtime config asset (`/env.json`) carrying `*canister_id*` keys — //! e.g. Caffeine apps expose `backend_canister_id` here. -//! 4. canister-id literals in the JS bundle, preferring labelled +//! 5. canister-id literals in the JS bundle, preferring labelled //! `*_CANISTER_ID` constants — e.g. dfx/Vite apps like OISY bake //! `IC_BACKEND_CANISTER_ID`, `IC_SIGNER_CANISTER_ID`, etc. //! -//! There is NO authoritative reverse lookup for "this site's backend" — (1) -//! is declared by the app itself and (2) is certain for the frontend; (3) and -//! (4) are mined from client code, so each result carries its provenance and -//! the caller decides (and should confirm with `get_canister_candid`). +//! There is NO authoritative reverse lookup for "this site's backend". (1) and +//! (2) are declared by the app itself and (3) is certain for the frontend; (4) +//! and (5) are mined from client code, so each result carries its provenance +//! and the caller decides (and should confirm with `get_canister_candid`). +//! +//! **Provenance is not permission.** Only (1) can authorize a state-changing +//! call; (2)–(5) are read-only hints. (3)–(5) say a canister id appeared in +//! bytes served behind a domain, not that the application claims it, and even +//! (2) — a real app declaration — is not the protocol's manifest, so it is not +//! what a write is checked against. See [`crate::authorization`]. +//! +//! [ICP service-discoverability protocol]: https://docs.internetcomputer.org/guides/frontends/service-discoverability/ use std::{ collections::BTreeMap, @@ -33,13 +47,16 @@ use rmcp::schemars; use serde::{Deserialize, Serialize}; use tokio::task::JoinSet; +use crate::architecture; + #[derive(Serialize, Clone, Debug)] pub struct Found { pub canister_id: String, - /// A human label if one was attached (App Connect role, env.json key, - /// bundle constant name, or "frontend"); None for a bare bundle literal. + /// A human label if one was attached (the manifest's name/role, an + /// env.json key, a bundle constant name, or "frontend"); None for a bare + /// bundle literal. pub label: Option, - /// Where it was found: "ai-connect.html", "ic-app.json", "header", + /// Where it was found: "ic-architecture", "ic-app.json", "header", /// "env.json", "bundle: