@mediamonks/canopy-cli 0.7.39 → 0.7.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{ai-EyrXJC5z.mjs → ai-PXXMv7kO.mjs} +2 -2
  2. package/dist/{api-ArfD6-t5.mjs → api-BHzCvDcY.mjs} +12 -0
  3. package/dist/{browse-DEhnJAPT.mjs → browse-DRz1DowL.mjs} +1 -1
  4. package/dist/{db-DGrCgj-R.mjs → db-5LfBG6N7.mjs} +3 -3
  5. package/dist/{deploy-B7KItUnh.mjs → deploy-6rYq1Iq8.mjs} +3 -3
  6. package/dist/dev-D38FQ2LD.mjs +2 -0
  7. package/dist/{dev-BMWj4NDZ.mjs → dev-D_gyIeUq.mjs} +7 -7
  8. package/dist/{dev-worker-CpSB_Okh.mjs → dev-worker-D78leKBJ.mjs} +30 -2
  9. package/dist/docs/cli-dev.md +1 -1
  10. package/dist/docs/cli-feedback.md +5 -3
  11. package/dist/docs/developers-architecture.md +17 -5
  12. package/dist/docs/platform-client-access.md +8 -0
  13. package/dist/docs/platform-egress.md +69 -12
  14. package/dist/docs/porting-nextjs.md +13 -8
  15. package/dist/docs/sdk-auth.md +32 -0
  16. package/dist/docs/sdk-mcp.md +5 -4
  17. package/dist/docs/troubleshooting.md +16 -0
  18. package/dist/{docs-Bplp4sOY.mjs → docs-B9xGnLSR.mjs} +3 -3
  19. package/dist/egress-CCpOsYpS.mjs +397 -0
  20. package/dist/{feedback-DFK0TXlt.mjs → feedback-CdjtPumG.mjs} +6 -6
  21. package/dist/index.mjs +31 -31
  22. package/dist/{local-BnfAEwso.mjs → local-Dshy0NsJ.mjs} +3 -3
  23. package/dist/{login-B2FJGIrD.mjs → login-CKNFCp7c.mjs} +2 -2
  24. package/dist/{logs-aJwWWaMx.mjs → logs-D3WakFME.mjs} +3 -3
  25. package/dist/{mcp-DuT6_wei.mjs → mcp-CoVS5Mj3.mjs} +2 -2
  26. package/dist/{migrations-CG_O5Ghw.mjs → migrations-DDJ5JJqj.mjs} +1 -1
  27. package/dist/{open-COFMburt.mjs → open-BPYozPQo.mjs} +1 -1
  28. package/dist/{package-CJJpjVh_.mjs → package-SWgTmctH.mjs} +1 -1
  29. package/dist/project-template-CMgD5hnV.mjs +2 -0
  30. package/dist/{project-template-DtTfXyLI.mjs → project-template-DACdMdpn.mjs} +2 -2
  31. package/dist/{projects-B3Wi01lw.mjs → projects-naWQ89Qj.mjs} +4 -4
  32. package/dist/{repo-4x-CJwCp.mjs → repo-BCua4L-I.mjs} +2 -2
  33. package/dist/repo-Un_p617X.mjs +2 -0
  34. package/dist/{schedule-CbaDIJXr.mjs → schedule-DjHcaxyt.mjs} +2 -2
  35. package/dist/{secrets-CmY4dUHz.mjs → secrets-COwRvCev.mjs} +2 -2
  36. package/dist/{skill-C2lGGz9n.mjs → skill-DSYQA4Ls.mjs} +2 -2
  37. package/dist/{src-CxzGOEhP.mjs → src-RUf3Gfuh.mjs} +6 -2
  38. package/dist/{staging-ypR79MlD.mjs → staging-CYMTZyyM.mjs} +2 -2
  39. package/dist/templates/spa/package.json +1 -1
  40. package/dist/templates/worker/package.json +1 -1
  41. package/dist/{token-CDrHQ5Xb.mjs → token-CCpJ1ES4.mjs} +2 -2
  42. package/dist/{ui-CaHgvvkJ.mjs → ui-SBUdC9rX.mjs} +1 -1
  43. package/dist/{upgrade-Caj1RsA7.mjs → upgrade-B_tLCpc4.mjs} +2 -2
  44. package/dist/{usage-CP8rBVZi.mjs → usage-BSPnsMXd.mjs} +9 -2
  45. package/package.json +2 -2
  46. package/dist/dev-BdSq6CGD.mjs +0 -2
  47. package/dist/egress-CzxRD6Tp.mjs +0 -188
  48. package/dist/project-template-DlzMx8Oj.mjs +0 -2
  49. package/dist/repo-CmbakFVi.mjs +0 -2
@@ -1,5 +1,5 @@
1
- import { a as apiRequest, l as withContext } from "./api-ArfD6-t5.mjs";
2
- import { r as reportErrors } from "./ui-CaHgvvkJ.mjs";
1
+ import { a as apiRequest, l as withContext } from "./api-BHzCvDcY.mjs";
2
+ import { r as reportErrors } from "./ui-SBUdC9rX.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/ai.ts
5
5
  /**
@@ -156,6 +156,18 @@ const ERROR_COPY = {
156
156
  why: "the schedule was saved but syncing it to the platform scheduler failed",
157
157
  fix: "Retry the same command — it re-syncs the saved schedule."
158
158
  },
159
+ fixed_ip_hosts_required: {
160
+ why: "routing listed hosts through the fixed addresses needs at least one host",
161
+ fix: "Add a host with `canopy egress fixed-ip add <host>`, or turn it off with `canopy egress fixed-ip off`."
162
+ },
163
+ fixed_ip_not_optional: {
164
+ why: "every request on this site already leaves from the fixed addresses",
165
+ fix: "There is nothing to set here — `canopy egress fixed-ip` shows the addresses to register."
166
+ },
167
+ invalid_fixed_ip_mode: {
168
+ why: "the fixed outbound address mode must be off, hosts or all",
169
+ fix: "Use `canopy egress fixed-ip add <host>`, `canopy egress fixed-ip all` or `canopy egress fixed-ip off`."
170
+ },
159
171
  worker_egress_sync_failed: {
160
172
  why: "the list was saved but redeploying the running worker with it failed",
161
173
  fix: "Retry, or redeploy the project (`canopy deploy`) to apply the new allowlist."
@@ -1,5 +1,5 @@
1
1
  import { d as tryResolveProject, n as siteConfig } from "./site-DM2J10CB.mjs";
2
- import { a as apiRequest, t as ApiError } from "./api-ArfD6-t5.mjs";
2
+ import { a as apiRequest, t as ApiError } from "./api-BHzCvDcY.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/browse.ts
5
5
  /**
@@ -1,7 +1,7 @@
1
1
  import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
2
- import { a as apiRequest, l as withContext, r as apiDataRequest } from "./api-ArfD6-t5.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CaHgvvkJ.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-CG_O5Ghw.mjs";
2
+ import { a as apiRequest, l as withContext, r as apiDataRequest } from "./api-BHzCvDcY.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-SBUdC9rX.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-DDJ5JJqj.mjs";
5
5
  import { defineCommand } from "citty";
6
6
  import { readFileSync, writeFileSync } from "node:fs";
7
7
  import * as p from "@clack/prompts";
@@ -1,7 +1,7 @@
1
1
  import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
- import { a as apiRequest, i as apiDownload, l as withContext, r as apiDataRequest } from "./api-ArfD6-t5.mjs";
3
- import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-CaHgvvkJ.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-CG_O5Ghw.mjs";
2
+ import { a as apiRequest, i as apiDownload, l as withContext, r as apiDataRequest } from "./api-BHzCvDcY.mjs";
3
+ import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-SBUdC9rX.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-DDJ5JJqj.mjs";
5
5
  import { builtinModules } from "node:module";
6
6
  import { defineCommand } from "citty";
7
7
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -0,0 +1,2 @@
1
+ import { n as devCommand } from "./dev-D_gyIeUq.mjs";
2
+ export { devCommand };
@@ -1,8 +1,8 @@
1
1
  import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
2
- import { a as apiRequest, l as withContext, t as ApiError } from "./api-ArfD6-t5.mjs";
3
- import { r as reportErrors, t as formatDateTime } from "./ui-CaHgvvkJ.mjs";
4
- import { r as readMigrationFiles } from "./migrations-CG_O5Ghw.mjs";
5
- import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER } from "./src-CxzGOEhP.mjs";
2
+ import { a as apiRequest, l as withContext, t as ApiError } from "./api-BHzCvDcY.mjs";
3
+ import { r as reportErrors, t as formatDateTime } from "./ui-SBUdC9rX.mjs";
4
+ import { r as readMigrationFiles } from "./migrations-DDJ5JJqj.mjs";
5
+ import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER } from "./src-RUf3Gfuh.mjs";
6
6
  import { n as writeDevServerRecord } from "./dev-server-file-Dk9pk4AR.mjs";
7
7
  import { createRequire } from "node:module";
8
8
  import { defineCommand } from "citty";
@@ -41,7 +41,7 @@ async function workerPlugin(flags, slug, workerAttest, remoteUser) {
41
41
  console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
42
42
  return;
43
43
  }
44
- const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CpSB_Okh.mjs");
44
+ const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-D78leKBJ.mjs");
45
45
  const secrets = readDevVars(flags.dir);
46
46
  const email = siteConfig().email;
47
47
  const user = await remoteUser?.() ?? (email ? {
@@ -251,7 +251,7 @@ async function devRemote(flags, slug) {
251
251
  async function devLocal(flags, slug) {
252
252
  let dump;
253
253
  if (flags.pull) {
254
- const { fetchProjectDump } = await import("./local-BnfAEwso.mjs");
254
+ const { fetchProjectDump } = await import("./local-Dshy0NsJ.mjs");
255
255
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
256
256
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
257
257
  }
@@ -267,7 +267,7 @@ async function devLocal(flags, slug) {
267
267
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
268
268
  }
269
269
  const workerAttest = randomBytes(24).toString("base64url");
270
- const { createSandbox } = await import("./local-BnfAEwso.mjs");
270
+ const { createSandbox } = await import("./local-Dshy0NsJ.mjs");
271
271
  const sandbox = await createSandbox({
272
272
  slug,
273
273
  persistDir: join(flags.dir, ".canopy", "local"),
@@ -1,4 +1,4 @@
1
- import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER, r as redactCanopyCredentials, t as CALLER_HEADER } from "./src-CxzGOEhP.mjs";
1
+ import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER, r as redactCanopyCredentials, t as CALLER_HEADER } from "./src-RUf3Gfuh.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, resolve, sep } from "node:path";
4
4
  //#region src/dev-worker.ts
@@ -200,6 +200,31 @@ function userFromHeaders(headers, fallback) {
200
200
  };
201
201
  }
202
202
  /**
203
+ * The caller in dev: stamped caller headers when a request poses as one
204
+ * (a client-password visitor, say), else the same monk env.USER resolves to —
205
+ * the developer, since no company login fronts a local server.
206
+ */
207
+ function callerFromHeaders(headers, fallback) {
208
+ const kind = (headers.get("x-canopy-identity-kind") ?? "").trim();
209
+ const id = (headers.get("x-canopy-identity-id") ?? "").trim();
210
+ const agent = headers.get("x-canopy-caller") === "agent";
211
+ if ((kind === "monk" || kind === "external") && id) return {
212
+ kind,
213
+ id,
214
+ email: (headers.get("x-canopy-identity-email") ?? "").trim(),
215
+ name: (headers.get("x-canopy-identity-name") ?? "").trim(),
216
+ agent
217
+ };
218
+ const user = userFromHeaders(headers, fallback);
219
+ return user ? {
220
+ kind: "monk",
221
+ id: user.sub,
222
+ email: user.email,
223
+ name: user.name,
224
+ agent
225
+ } : null;
226
+ }
227
+ /**
203
228
  * The env object the worker sees, in the shim's own key order: secrets first,
204
229
  * platform keys after, so a stray `.dev.vars` entry can never shadow
205
230
  * `CANOPY`/`DB`/`USER` (the dashboard rejects those names outright —
@@ -207,12 +232,14 @@ function userFromHeaders(headers, fallback) {
207
232
  */
208
233
  function buildWorkerEnv(options, request, parts) {
209
234
  const blobPrefix = `projects/${options.slug}/`;
235
+ const caller = callerFromHeaders(request.headers, options.user);
210
236
  return {
211
237
  ...options.secrets,
212
238
  CANOPY: { fetch: parts.canopyFetch },
213
239
  DB: parts.db,
214
240
  BLOB: parts.blob,
215
- USER: userFromHeaders(request.headers, options.user),
241
+ USER: caller?.kind === "external" ? null : userFromHeaders(request.headers, options.user),
242
+ CALLER: caller,
216
243
  PROJECT_SLUG: options.slug,
217
244
  BLOB_PREFIX: blobPrefix,
218
245
  CANOPY_CHANNEL: options.channel
@@ -316,6 +343,7 @@ function workerDevPlugin(options) {
316
343
  return;
317
344
  }
318
345
  env.USER = null;
346
+ env.CALLER = null;
319
347
  await worker.scheduled({
320
348
  name,
321
349
  cron: typeof body.cron === "string" ? body.cron : "",
@@ -39,7 +39,7 @@ With the lock in place, `--local` runs on an embedded SQLite engine instead —
39
39
 
40
40
  **Your backend worker.** If `canopy.json` names a `worker` entry (the worker scaffold does), `canopy dev` serves it too: requests to `/api/*` go to your worker, everything else to the SPA — the same split the platform makes once you deploy. There's no build step; save the file and the next request runs the new code.
41
41
 
42
- Your worker gets the same `env` it gets in production: `env.CANOPY.fetch` for the SDK, `env.DB` and `env.BLOB` for raw database and file access, `env.USER` for the signed-in person, `env.PROJECT_SLUG`. They all follow the mode you're in — live project data by default, your sandbox under `--local`. Two differences worth knowing: `env.USER` is you (the account you logged in with), since there's no company login in front of a local server, and a write through `env.DB` reports no row count locally.
42
+ Your worker gets the same `env` it gets in production: `env.CANOPY.fetch` for the SDK, `env.DB` and `env.BLOB` for raw database and file access, `env.CALLER` and `env.USER` for whoever is calling, `env.PROJECT_SLUG`. They all follow the mode you're in — live project data by default, your sandbox under `--local`. Two differences worth knowing: `env.CALLER` and `env.USER` are you (the account you logged in with), since there's no company login in front of a local server, and a write through `env.DB` reports no row count locally.
43
43
 
44
44
  To tick a schedule by hand, post its name to the same path the platform uses:
45
45
 
@@ -17,6 +17,7 @@ canopy feedback note <id> "stack trace and table names for the team"
17
17
  canopy feedback status <id> investigated --comment "root cause: the grant expires too early"
18
18
  canopy feedback list --status accepted
19
19
  canopy feedback status <id> resolved --comment "fixed in v42"
20
+ canopy feedback status <id> declined --comment "works as intended: drafts are private"
20
21
  canopy feedback mine
21
22
  canopy feedback watch --project my-project
22
23
  canopy feedback report "canopy deploy exits 1 after upload" --command "canopy deploy"
@@ -107,9 +108,10 @@ queries — goes in `note <id> "<detail>"`, which only project
107
108
  members see. An internal note counts as findings for `investigated`.
108
109
 
109
110
  **Build:** `list --status accepted` is what a person has asked for. Do the
110
- work, then `resolve` with a note, or `declined` if it turns out to be a bad
111
- idea. Set `accepted` yourself only when the person you are working with told
112
- you to.
111
+ work, then `status <id> resolved --comment "<what changed>"`, or
112
+ `status <id> declined --comment "<why>"` if it turns out to be a bad idea.
113
+ Set `accepted` yourself only when the person you are working with told you
114
+ to.
113
115
 
114
116
  When a `canopy` command itself fails: rule out user error, check
115
117
  `canopy feedback mine` for a duplicate, then `report` with the exact command
@@ -67,17 +67,29 @@ else is still static serving. A few things to know:
67
67
 
68
68
  - **Requests arrive authenticated.** The worker sits behind the same gateway
69
69
  wall as the frontend.
70
- - **The caller is already identified.** `c.env.USER` is the signed-in person
71
- (`null` during a scheduled run) — no round-trip needed:
70
+ - **The caller is already identified.** `c.env.CALLER` is whoever the
71
+ platform let in, with no round-trip needed: a signed-in Monk
72
+ (`kind: "monk"`) or a visitor who used the project's
73
+ [client password](https://docs.canopy.website/platform/client-access) (`kind: "external"`). It is
74
+ `null` only when nobody called, such as during a scheduled run:
72
75
 
73
76
  ```ts
74
77
  app.get("/api/hello", (c) => {
75
- const user = c.env.USER;
76
- if (!user) return c.json({ error: "unauthenticated" }, 401);
77
- return c.json({ message: `Hello, ${user.name}!` });
78
+ const caller = c.env.CALLER;
79
+ if (!caller) return c.json({ error: "no caller" }, 400); // a scheduled run
80
+ if (caller.kind === "external") return c.json({ message: "Hello!" });
81
+ return c.json({ message: `Hello, ${caller.name}!` });
78
82
  });
79
83
  ```
80
84
 
85
+ `c.env.USER` is the shorter Monk-only form: the signed-in Monk, or `null`
86
+ for client-password visitors and scheduled runs. Use it when a route is for
87
+ Monks only, and don't read a `null` `USER` as "nobody is here".
88
+
89
+ A backend gets `CALLER` from its next deploy. One deployed before then can
90
+ ask the platform instead: `await canopy.auth.user()` through the `CANOPY`
91
+ binding (below) answers for every visitor.
92
+
81
93
  - **Platform access goes through the same SDK**, via the `CANOPY` service
82
94
  binding — same budgets, same data isolation as the browser:
83
95
 
@@ -46,6 +46,14 @@ immediately, unlocked browsers included.
46
46
  > wrong for anything sensitive. Anyone who has it can view the app and share it further, so rotate
47
47
  > it when a review round ends.
48
48
 
49
+ ## Clients and your backend
50
+
51
+ A client who unlocks the project with the password can use its backend
52
+ routes too, as an anonymous visitor. The worker sees them on `env.CALLER`
53
+ with `kind: "external"`, while `env.USER` (Monks only) is `null` for them. So a
54
+ route that should serve clients checks `env.CALLER`, not `env.USER`. See
55
+ [Auth](https://docs.canopy.website/sdk/auth#in-a-backend-worker).
56
+
49
57
  ## Playing well with other access controls
50
58
 
51
59
  Client access is independent of
@@ -112,12 +112,11 @@ Switching back is one command (`canopy egress mode allowlist`), and the
112
112
  dashboard offers to **seed the allowlist from the observed hosts** so
113
113
  tightening up later doesn't break the app.
114
114
 
115
- ## Allowlisting Canopy on the other side
115
+ ## Fixed outbound addresses
116
116
 
117
117
  Some external services only accept calls from IP addresses you register with
118
- them first — a partner API, a corporate database, a payment provider's
119
- allowlist. Projects hosted on **Google Cloud** (the `gcp-pilot` site,
120
- `canopy.monks.zone`) make every outbound request from a fixed pair of
118
+ them first: a partner API, a corporate database, a payment provider's
119
+ allowlist. Canopy can send your backend's requests from a fixed pair of
121
120
  addresses, so you can hand them over:
122
121
 
123
122
  | Address |
@@ -126,16 +125,74 @@ addresses, so you can hand them over:
126
125
  | `34.158.168.43` |
127
126
 
128
127
  Register **both**. Traffic may leave from either one, and the second gives the
129
- platform room to grow without asking you to update your allowlist. These
130
- addresses are shared by every project on that site, so registering them lets
131
- any Canopy project on Google Cloud reach the service — the other service's own
132
- authentication (API keys, tokens) still decides who gets in.
128
+ platform room to grow without asking you to update the other side. These
129
+ addresses are shared by every project that uses them, so registering them lets
130
+ any Canopy project reach the service. The other service's own authentication
131
+ (API keys, tokens) still decides who gets in.
133
132
 
134
133
  The addresses are a platform commitment: they will not change without notice
135
- and a migration period. Projects hosted on Cloudflare have **no** fixed
136
- outbound address (requests leave from Cloudflare's shared edge), so if a
137
- service you depend on insists on IP allowlisting, run that project on the
138
- Google Cloud site.
134
+ and a migration period.
135
+
136
+ ### Route a partner's host
137
+
138
+ By default requests leave normally, not from these addresses. Route the hosts
139
+ that need them:
140
+
141
+ ```bash
142
+ canopy egress fixed-ip add api.partner.com
143
+ canopy egress fixed-ip # show the setting and the addresses
144
+ canopy egress fixed-ip remove api.partner.com
145
+ canopy egress fixed-ip off
146
+ ```
147
+
148
+ or in the dashboard: **Backend → Outbound hosts → Fixed outbound addresses**.
149
+ Entries follow the same rules as the allowlist (a bare host or a `*.`
150
+ subdomain wildcard, up to 32). Each request is routed by its own host, so if
151
+ the partner redirects you to a CDN that isn't listed, that second request
152
+ leaves normally. The setting applies to the running worker right away, to
153
+ both channels, and to scheduled runs as well as HTTP requests.
154
+
155
+ Only the **project owner** can turn the setting on or route all traffic. Any
156
+ member can change the listed hosts or turn it off.
157
+
158
+ **Routing a host doesn't allow it.** In allowlist mode the host still has to be
159
+ on the allowlist, or requests to it are denied as usual. The dashboard and
160
+ `canopy egress fixed-ip` flag routed hosts that aren't allowed and offer to
161
+ allow them. The setting works in open mode too.
162
+
163
+ ### All traffic
164
+
165
+ `canopy egress fixed-ip all` sends **every** outbound request from the fixed
166
+ addresses. It's there for apps that can't list their partners up front, but
167
+ it's rarely the better choice: every request takes the slower route, counts
168
+ toward the daily quota, and leaves from addresses shared with other projects.
169
+ List the hosts that need it instead when you can.
170
+
171
+ ### What to expect
172
+
173
+ - **A little latency.** A routed request makes an extra hop, usually tens of
174
+ milliseconds. The first routed request after a quiet spell (about 15
175
+ minutes) can take a second or two longer while the route wakes up. Give
176
+ tight partner timeouts some slack.
177
+ - **A daily transfer quota.** What a project sends and receives through the
178
+ fixed addresses counts toward 1 GB a day, shown on the **Usage** tab. Once
179
+ it's used up, routed requests are refused until 00:00 UTC; everything else
180
+ keeps working. Ask the platform team if you need more.
181
+ - **`fetch` only.** Raw socket connections (`connect()`) and outbound
182
+ WebSockets don't leave from the fixed addresses.
183
+ - **Uploads up to 32 MB.** A larger request body to a routed host is refused.
184
+
185
+ On some sites every request of every project already leaves from these
186
+ addresses. There the setting shows as always on and there is nothing to set.
187
+
188
+ ### Errors from routed requests
189
+
190
+ | Status | `error` | What it means |
191
+ | ------ | -------------------------------- | ------------------------------------------------------------------------------------------------ |
192
+ | 429 | `egress_fixed_ip_quota_exceeded` | Today's fixed-address quota is used up. Routed requests work again at 00:00 UTC. |
193
+ | 502 | `egress_fixed_ip_unavailable` | The fixed-address route couldn't send the request. Nothing went out from another address; retry. |
194
+ | 413 | `egress_fixed_ip_body_too_large` | The request body is over 32 MB. |
195
+ | 403 | `egress_denied` | As always: the host isn't allowed, or it resolves to a private address. |
139
196
 
140
197
  ## The outbound traffic log
141
198
 
@@ -69,7 +69,7 @@ three buckets:
69
69
  | Route handlers (`app/api/*/route.ts`) | Worker endpoints under `/api/*` |
70
70
  | Server actions | Explicit worker endpoints, called with `fetch` from the client |
71
71
  | Middleware | Usually deleted (auth is platform-provided); anything else moves into the worker |
72
- | NextAuth / auth middleware | Deleted — use `canopy.auth.user()` (client) or `env.USER` (worker) |
72
+ | NextAuth / auth middleware | Deleted — use `canopy.auth.user()` (client) or `env.CALLER` (worker) |
73
73
  | Database (Prisma, Postgres, …) | `canopy.db` — per-project database, document API + SQL escape hatch |
74
74
  | File uploads (S3, …) | `canopy.blob` — per-project file storage with signed downloads |
75
75
  | AI SDK / OpenAI client | `canopy.ai.chat()`, or point the Vercel AI SDK at `canopy.ai.endpoint()` |
@@ -117,12 +117,14 @@ Route handlers and server actions become endpoints in one fetch handler.
117
117
 
118
118
  ```ts
119
119
  // worker/index.ts
120
- import { createCanopy } from "@mediamonks/canopy";
120
+ import { type CanopyCaller, createCanopy } from "@mediamonks/canopy";
121
121
  import { Hono } from "hono";
122
122
 
123
123
  type Env = {
124
124
  CANOPY: { fetch: typeof fetch };
125
- /** Signed-in monk, verified by the platform; null for cron. */
125
+ /** Whoever the platform let in, client-password visitors included; null for cron. */
126
+ CALLER: CanopyCaller | null;
127
+ /** The signed-in monk only; null for cron and client-password visitors. */
126
128
  USER: { sub: string; email: string; name: string } | null;
127
129
  /** Project secrets from dashboard Settings appear as plain keys. */
128
130
  MY_API_KEY?: string;
@@ -132,8 +134,10 @@ const app = new Hono<{ Bindings: Env }>();
132
134
 
133
135
  // was: app/api/report/route.ts
134
136
  app.post("/api/report", async (c) => {
137
+ // Reports belong to a Monk's email, so this route is Monks-only. A
138
+ // client-password visitor is still let in, as env.CALLER with no email.
135
139
  const user = c.env.USER;
136
- if (!user) return c.json({ error: "unauthenticated" }, 401);
140
+ if (!user) return c.json({ error: "monks_only" }, 403);
137
141
 
138
142
  // The SDK works inside the worker through the CANOPY binding —
139
143
  // same database and files the frontend sees, caller's identity attached.
@@ -184,8 +188,8 @@ egress allowlist, and even an allowed hop would arrive without your session.
184
188
  The platform recognizes this shape and short-circuits it: a fetch to the
185
189
  project's **own hostname** (or to `localhost` on any port — the common
186
190
  `BASE_URL` fallback) with a path under `/api/` re-enters your worker directly,
187
- in-process. No network hop, no allowlist entry, and `env.USER` is the same
188
- caller who made the original request. Two things to know:
191
+ in-process. No network hop, no allowlist entry, and `env.CALLER` and
192
+ `env.USER` are the same caller who made the original request. Two things to know:
189
193
 
190
194
  - The loopback covers `/api/*` only. A self-fetch to any other path fails
191
195
  like any other non-allowlisted host.
@@ -222,7 +226,8 @@ runs, so the ported app removes sign-in pages, session handling, and auth
222
226
  middleware entirely:
223
227
 
224
228
  - **Client:** `canopy.auth.user()` returns the signed-in monk.
225
- - **Worker:** `env.USER` carries the verified identity (no round-trip).
229
+ - **Worker:** `env.CALLER` carries the verified caller (no round-trip),
230
+ client-password visitors included; `env.USER` is the Monk-only form.
226
231
  - Client-facing access without OneLogin is a per-project
227
232
  [password](https://docs.canopy.website/platform/client-access), configured in Settings — not code.
228
233
 
@@ -252,7 +257,7 @@ past greenhouse size (see [Use cases](https://docs.canopy.website/overview/use-c
252
257
  - [ ] Frontend: `output: 'export'` or Vite SPA; fix dynamic segments and `next/image`
253
258
  - [ ] Worker: route handlers + server actions → `/api/*` endpoints in `worker/index.ts`
254
259
  - [ ] Data: ORM → `canopy.db`; uploads → `canopy.blob`; AI clients → `canopy.ai`
255
- - [ ] Delete NextAuth/middleware; read identity from `canopy.auth.user()` / `env.USER`
260
+ - [ ] Delete NextAuth/middleware; read identity from `canopy.auth.user()` / `env.CALLER`
256
261
  - [ ] Secrets via `canopy secrets set` (or dashboard Settings); `NEXT_PUBLIC_*` into your CI build
257
262
  - [ ] Crons into Settings → Schedules, handled in `scheduled()`
258
263
  - [ ] `canopy.json` with `build`, `dist`, `worker` — then `canopy deploy`
@@ -43,6 +43,38 @@ if (user.kind === "external") {
43
43
  > **Warning:** Branching on `kind` in the browser is presentation, not security — anything your page can fetch, a
44
44
  > visitor can fetch. The platform's access rules are what actually protect data.
45
45
 
46
+ ### In a backend worker
47
+
48
+ A worker route already knows who is calling, without asking: `env.CALLER`
49
+ holds the same `id`, `email`, `name` and `kind` that `auth.user()` returns,
50
+ plus `agent`, which is `true` when an AI agent is acting for a Monk.
51
+
52
+ ```ts
53
+ import type { CanopyCaller } from "@mediamonks/canopy";
54
+
55
+ app.get("/api/me", (c) => {
56
+ const caller: CanopyCaller | null = c.env.CALLER;
57
+ if (!caller) return c.json({ error: "no caller" }, 400); // a scheduled run
58
+ return c.json({ kind: caller.kind, name: caller.name });
59
+ });
60
+ ```
61
+
62
+ `env.CALLER` is `null` only when nobody called, such as during a scheduled
63
+ run. `env.USER` is the Monk-only form: the signed-in Monk, or `null` for a
64
+ client-password visitor.
65
+
66
+ A client-password visitor has no email address and is named `"Client"`. Its
67
+ `id` changes with every password unlock, so don't key data on it that has to
68
+ last.
69
+
70
+ A backend gets `env.CALLER` from its next deploy. Until it is redeployed,
71
+ `auth.user()` through the `CANOPY` binding gives the same answer:
72
+
73
+ ```ts
74
+ const canopy = createCanopy({ fetch: c.env.CANOPY.fetch.bind(c.env.CANOPY) });
75
+ const user = await canopy.auth.user();
76
+ ```
77
+
46
78
  ## auth.logout()
47
79
 
48
80
  Ends the platform session for the current visitor. Their next request hits the
@@ -66,8 +66,8 @@ export default {
66
66
  ```
67
67
 
68
68
  **Returns** `(request, env?, executionCtx?) => Promise<Response>`. Pass the
69
- backend's `env` so handlers and route tools see `env.USER`, `env.CANOPY` and
70
- your secrets.
69
+ backend's `env` so handlers and route tools see `env.USER`, `env.CALLER`,
70
+ `env.CANOPY` and your secrets.
71
71
 
72
72
  **Options:** `{ app?, name?, version? }`. `app` is required when the list has
73
73
  route tools: they run its routes.
@@ -197,8 +197,9 @@ app.get("/api/notes", (c) => {
197
197
  ```
198
198
 
199
199
  **Returns** `McpCaller`: `{ user: McpUser | null, agent: boolean }`.
200
- `user` comes from `env.USER`. Without `env`, it falls back to the identity the
201
- platform attached to the request.
200
+ `user` comes from `env.USER`, and `agent` from `env.CALLER` when the backend
201
+ has it. Without `env`, both fall back to what the platform attached to the
202
+ request.
202
203
 
203
204
  ## When the tool list is wrong
204
205
 
@@ -75,6 +75,22 @@ or use dashboard Settings → Outbound hosts. Details and entry rules:
75
75
  [Outbound requests](https://docs.canopy.website/platform/egress). Calls made through the SDK /
76
76
  `env.CANOPY.fetch` don't count as egress and are unaffected.
77
77
 
78
+ ## `egress_fixed_ip_quota_exceeded` (429)
79
+
80
+ The project used up today's transfer through the
81
+ [fixed outbound addresses](https://docs.canopy.website/platform/egress#fixed-outbound-addresses). Routed
82
+ requests are refused until 00:00 UTC; requests to other hosts keep working.
83
+ `canopy usage` and the dashboard's Usage tab show how much was sent. If routing
84
+ all traffic used it up, list only the hosts that need a fixed address
85
+ (`canopy egress fixed-ip add <host>`).
86
+
87
+ ## `egress_fixed_ip_unavailable` (502)
88
+
89
+ A request to a host routed through the fixed outbound addresses couldn't be
90
+ sent that way. It never went out from another address, so the partner didn't
91
+ see it. Retry; if it keeps happening, the detail says why (and the platform
92
+ team hears about it).
93
+
78
94
  ## The worker returns errors and you can't see why
79
95
 
80
96
  Read its console output instead of adding diagnostic endpoints:
@@ -1,8 +1,8 @@
1
- import { t as version } from "./package-CJJpjVh_.mjs";
1
+ import { t as version } from "./package-SWgTmctH.mjs";
2
2
  import { n as siteConfig } from "./site-DM2J10CB.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
- import { n as agentHeaders } from "./api-ArfD6-t5.mjs";
5
- import { r as reportErrors } from "./ui-CaHgvvkJ.mjs";
4
+ import { n as agentHeaders } from "./api-BHzCvDcY.mjs";
5
+ import { r as reportErrors } from "./ui-SBUdC9rX.mjs";
6
6
  import { defineCommand } from "citty";
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";