Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions evaluations/asset-canister.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"skill": "asset-canister",
"description": "Evaluation cases for the asset-canister skill. Tests whether agents produce correct asset canister configuration, avoid top-level await in programmatic upload code, and handle SPA routing.",

"output_evals": [
{
"name": "Programmatic asset upload",
"prompt": "Show me a Node.js script to create an HttpAgent and AssetManager, then upload a single file to my asset canister on the local replica. Keep it minimal — no icp.yaml, no deploy steps.",
"expected_behaviors": [
"Uses HttpAgent.create() with shouldFetchRootKey for local development",
"Creates an AssetManager with the agent and a canisterId",
"Shows assetManager.store() for uploading a file",
"Warns about shouldFetchRootKey being unsafe on mainnet"
]
},
{
"name": "SPA routing configuration",
"prompt": "My single-page app returns 404 when I refresh on a deep route like /dashboard/settings. How do I fix this on the IC?",
"expected_behaviors": [
"Shows .ic-assets.json5 configuration with a fallback rule",
"The fallback redirects to /index.html for SPA routing",
"Mentions the match pattern for catching unmatched routes",
"Does NOT suggest server-side redirect rules or nginx config"
]
},
{
"name": "Asset canister recipe setup",
"prompt": "Show me the icp.yaml configuration for a frontend asset canister with a Vite build step. Just the YAML and the deploy command, no .ic-assets.json5 or programmatic upload code.",
"expected_behaviors": [
"Shows icp.yaml with @dfinity/asset-canister recipe and version pin",
"Includes build commands (npm install, npm run build)",
"Specifies the output directory (e.g., dir: dist)",
"Uses icp deploy commands, NOT dfx deploy"
]
},
{
"name": "Security: shouldFetchRootKey in Node.js scripts",
"prompt": "I'm writing a Node.js script to upload files to my asset canister using HttpAgent and AssetManager. What should I set for shouldFetchRootKey and host in local vs production? Just the relevant code snippet.",
"expected_behaviors": [
"Sets shouldFetchRootKey to true ONLY for local development",
"Explicitly warns that shouldFetchRootKey on mainnet is a security vulnerability",
"Uses different host values for local (localhost:8000) vs mainnet (ic0.app or icp-api.io)"
]
}
],

"trigger_evals": {
"description": "Queries to test whether the skill activates correctly.",
"should_trigger": [
"How do I deploy a frontend on the IC?",
"Set up an asset canister for my React app",
"My SPA routing doesn't work on the IC",
"How do I upload files to an asset canister programmatically?",
"Configure .ic-assets.json5 for my project",
"How does the asset canister handle content encoding?"
],
"should_not_trigger": [
"Add Internet Identity login",
"How do I make inter-canister calls?",
"Set up stable memory in Motoko",
"Connect a wallet to my dapp",
"How do I create an ICRC-1 token?",
"Deploy a Rust backend canister"
]
}
}
113 changes: 64 additions & 49 deletions skills/asset-canister/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The asset canister hosts static files (HTML, CSS, JS, images) directly on the In

## Prerequisites

- `@icp-sdk/canisters` npm package (for programmatic uploads)
- `@icp-sdk/canisters` (>= 3.5.0), `@icp-sdk/core` (>= 5.0.0) — for programmatic uploads

## Canister IDs

Expand All @@ -31,19 +31,21 @@ Access patterns:

## Mistakes That Break Your Build

1. **Wrong `source` path in icp.yaml.** The `source` array must point to the directory containing your build output. If you use Vite, that is `"dist"`. If you use Next.js export, it is `"out"`. If the path does not exist at deploy time, `icp deploy` fails silently or deploys an empty canister.
1. **Wrong `dir` path in icp.yaml.** The `configuration.dir` field must point to the directory containing your build output. If you use Vite, that is `dist`. If you use Next.js export, it is `out`. If the path does not exist at deploy time, `icp deploy` fails silently or deploys an empty canister.

2. **Missing `.ic-assets.json5` for single-page apps.** Without a rewrite rule, refreshing on `/about` returns a 404 because the asset canister looks for a file literally named `/about`. You must configure a fallback to `index.html`.

3. **Forgetting to build before deploy.** `icp deploy` runs the `build` command from icp.yaml, but if it is empty or misconfigured, the `source` directory will be stale or empty.
3. **Missing or misconfigured `build` in the recipe.** If `configuration.build` is specified, `icp deploy` runs those commands automatically before uploading the `dir` contents. If `build` is omitted, you must run your build command (e.g., `npm run build`) manually before deploying — otherwise the `dir` directory will be stale or empty.

4. **Not setting content-type headers.** The asset canister infers content types from file extensions. If you upload files programmatically without setting the content type, browsers may not render them correctly.

5. **Deploying to the wrong canister name.** If icp.yaml has `"frontend"` but you run `icp deploy assets`, it creates a new canister instead of updating the existing one.

6. **Exceeding canister storage limits.** The asset canister uses stable memory, which can hold well over 4GB. However, individual assets are limited by the 2MB ingress message size (the asset manager in `@icp-sdk/canisters` handles chunking automatically for uploads >1.9MB). The practical concern is total cycle cost for storage -- large media files (videos, datasets) become expensive. Use a dedicated storage solution for large files.

7. **Not configuring `allow_raw_access` correctly.** The asset canister has two serving modes: certified (via `ic0.app` / `icp0.io`, where HTTP gateways verify response integrity) and raw (via `raw.ic0.app` / `raw.icp0.io`, where no verification occurs). By default, `allow_raw_access` is `true`, meaning assets are also available on the raw domain. On the raw domain, boundary nodes or a network-level attacker can tamper with response content undetected. Set `"allow_raw_access": false` in `.ic-assets.json5` for any sensitive assets. Only enable raw access when strictly needed.
7. **Pinning the asset canister Wasm version below `0.30.2`.** The `ic_env` cookie (used by `safeGetCanisterEnv()` from `@icp-sdk/core` to read canister IDs and the root key at runtime) is only served by asset canister Wasm versions >= `0.30.2`. The Wasm version is set via `configuration.version` in the recipe, independently of the recipe version itself. If you pin an older Wasm version, the cookie is silently missing and frontend code relying on `ic_env` will fail. Either omit `configuration.version` (latest is used) or pin to `0.30.2` or later.

8. **Not configuring `allow_raw_access` correctly.** The asset canister has two serving modes: certified (via `ic0.app` / `icp0.io`, where HTTP gateways verify response integrity) and raw (via `raw.ic0.app` / `raw.icp0.io`, where no verification occurs). By default, `allow_raw_access` is `true`, meaning assets are also available on the raw domain. On the raw domain, boundary nodes or a network-level attacker can tamper with response content undetected. Set `"allow_raw_access": false` in `.ic-assets.json5` for any sensitive assets. Only enable raw access when strictly needed.

## Implementation

Expand All @@ -59,11 +61,6 @@ canisters:
build:
- npm install
- npm run build
- name: backend
recipe:
type: "@dfinity/motoko@v4.1.0"
configuration:
main: src/backend/main.mo
```

Key fields:
Expand All @@ -73,7 +70,7 @@ Key fields:

### SPA Routing and Default Headers: `.ic-assets.json5`

Create this file in your `source` directory (e.g., `dist/.ic-assets.json5`) or project root. For it to be included in the asset canister, it must end up in the `source` directory at deploy time.
Create this file in your `dir` directory (e.g., `dist/.ic-assets.json5`) or project root. For it to be included in the asset canister, it must end up in the `dir` directory at deploy time.

Recommended approach: place the file in your `public/` or `static/` folder so your build tool copies it into `dist/` automatically.

Expand Down Expand Up @@ -127,7 +124,7 @@ icp canister call frontend http_request '(record {

To serve your asset canister from a custom domain:

1. Create a file `.well-known/ic-domains` in your `source` directory containing your domain:
1. Create a file `.well-known/ic-domains` in your `dir` directory containing your domain:
```text
yourdomain.com
www.yourdomain.com
Expand All @@ -136,18 +133,29 @@ www.yourdomain.com
2. Add DNS records:
```text
# CNAME record pointing to boundary nodes
yourdomain.com. CNAME icp1.io.
yourdomain.com. CNAME yourdomain.com.icp1.io.

# ACME challenge record for TLS certificate provisioning
_acme-challenge.yourdomain.com. CNAME _acme-challenge.<your-canister-id>.icp2.io.
_acme-challenge.yourdomain.com. CNAME _acme-challenge.yourdomain.com.icp2.io.

# Canister ID TXT record for verification
_canister-id.yourdomain.com. TXT "<your-canister-id>"
```

3. Deploy your canister so the `.well-known/ic-domains` file is available, then register the custom domain with the boundary nodes. Registration is automatic -- the boundary nodes periodically check for the `.well-known/ic-domains` file and the DNS records. No NNS proposal is needed.
3. Deploy your canister so the `.well-known/ic-domains` file is available.

4. Wait for the boundary nodes to pick up the registration and provision the TLS certificate. This typically takes a few minutes. You can verify by visiting `https://yourdomain.com` once DNS has propagated.
4. Validate that DNS records and canister ownership are correct:
```bash
curl -sL -X GET https://icp0.io/custom-domains/v1/yourdomain.com/validate | jq
# Expected: { "status": "success", "message": "Domain is eligible for registration: DNS records are valid and canister ownership is verified", ... }
```

5. Register the domain with the boundary nodes (required — registration is NOT automatic):
```bash
curl -sL -X POST https://icp0.io/custom-domains/v1/yourdomain.com | jq
```

6. Wait for the boundary nodes to provision the TLS certificate. This typically takes a few minutes. Verify by visiting `https://yourdomain.com` once DNS has propagated.

### Programmatic Uploads with @icp-sdk/canisters

Expand All @@ -156,50 +164,57 @@ For uploading files from code (not just via `icp deploy`):
```javascript
import { AssetManager } from "@icp-sdk/canisters/assets"; // Asset management utility
import { HttpAgent } from "@icp-sdk/core/agent";
import { readFileSync, readdirSync } from "fs";

// SECURITY: shouldFetchRootKey fetches the root public key from the replica at
// runtime. In production the root key is hardcoded and trusted. Fetching it at
// runtime lets a man-in-the-middle supply a fake key and forge certified responses.
// NEVER set shouldFetchRootKey to true when host points to mainnet.
// NOTE: This script runs in Node.js where the ic_env cookie is not available.
// For browser frontends, use rootKey from safeGetCanisterEnv() instead (see
// the internet-identity skill or icp-cli/references/binding-generation.md).
const LOCAL_REPLICA = "http://localhost:8000";
const MAINNET = "https://ic0.app";
const host = LOCAL_REPLICA; // Change to MAINNET for production

const agent = await HttpAgent.create({
host,
// Only fetch the root key when talking to a local replica.
// Setting this to true against mainnet is a security vulnerability.
shouldFetchRootKey: host === LOCAL_REPLICA,
});

const assetManager = new AssetManager({
canisterId: "your-asset-canister-id",
agent,
});

// Upload a single file
// Files >1.9MB are automatically chunked (16 parallel chunks)
const key = await assetManager.store(fileBuffer, {
fileName: "photo.jpg",
contentType: "image/jpeg",
path: "/uploads",
});
console.log("Uploaded to:", key); // "/uploads/photo.jpg"

// List all assets
const assets = await assetManager.list();
console.log(assets); // [{ key: "/index.html", content_type: "text/html", ... }, ...]

// Delete an asset
await assetManager.delete("/uploads/old-photo.jpg");

// Batch upload a directory
import { readFileSync, readdirSync } from "fs";
const files = readdirSync("./dist");
for (const file of files) {
const content = readFileSync(`./dist/${file}`);
await assetManager.store(content, { fileName: file, path: "/" });
async function manageAssets() {
const agent = await HttpAgent.create({
host,
// Only fetch the root key when talking to a local replica.
// Setting this to true against mainnet is a security vulnerability.
shouldFetchRootKey: host === LOCAL_REPLICA,
});

const assetManager = new AssetManager({
canisterId: "your-asset-canister-id",
agent,
});

// Upload a single file
// Files >1.9MB are automatically chunked (16 parallel chunks)
const key = await assetManager.store(fileBuffer, {
fileName: "photo.jpg",
contentType: "image/jpeg",
path: "/uploads",
});
console.log("Uploaded to:", key); // "/uploads/photo.jpg"

// List all assets
const assets = await assetManager.list();
console.log(assets); // [{ key: "/index.html", content_type: "text/html", ... }, ...]

// Delete an asset
await assetManager.delete("/uploads/old-photo.jpg");

// Batch upload a directory
const files = readdirSync("./dist");
for (const file of files) {
const content = readFileSync(`./dist/${file}`);
await assetManager.store(content, { fileName: file, path: "/" });
}
}

manageAssets();
```

### Authorization for Uploads
Expand Down
Loading