@mediamonks/canopy-cli 0.7.12 → 0.7.13

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 (119) hide show
  1. package/dist/{db-8Etjmzxh.mjs → db-DKnJQPm5.mjs} +2 -2
  2. package/dist/{deploy-B1WrPq5W.mjs → deploy-CnxJ4TE6.mjs} +2 -2
  3. package/dist/dev-BqLvttKc.mjs +2 -0
  4. package/dist/{dev-BnUYGQQm.mjs → dev-CyJT6EfQ.mjs} +4 -4
  5. package/dist/docs/ci.md +50 -0
  6. package/dist/docs/cli-create.md +46 -0
  7. package/dist/docs/cli-db.md +86 -0
  8. package/dist/docs/cli-delete.md +44 -0
  9. package/dist/docs/cli-deploy.md +58 -0
  10. package/dist/docs/cli-dev.md +82 -0
  11. package/dist/docs/cli-docs.md +47 -0
  12. package/dist/docs/cli-download.md +42 -0
  13. package/dist/docs/cli-egress.md +37 -0
  14. package/dist/docs/cli-feedback.md +75 -0
  15. package/dist/docs/cli-index.md +61 -0
  16. package/dist/docs/cli-init.md +46 -0
  17. package/dist/docs/cli-login.md +43 -0
  18. package/dist/docs/cli-logout.md +32 -0
  19. package/dist/docs/cli-logs.md +57 -0
  20. package/dist/docs/cli-open.md +41 -0
  21. package/dist/docs/cli-projects.md +43 -0
  22. package/dist/docs/cli-promote.md +44 -0
  23. package/dist/docs/cli-rollback.md +45 -0
  24. package/dist/docs/cli-skill.md +39 -0
  25. package/dist/docs/cli-staging.md +54 -0
  26. package/dist/docs/cli-token.md +47 -0
  27. package/dist/docs/cli-upgrade.md +43 -0
  28. package/dist/docs/cli-usage.md +43 -0
  29. package/dist/docs/cli-whoami.md +46 -0
  30. package/dist/docs/cli.md +86 -0
  31. package/dist/docs/data.md +85 -0
  32. package/dist/docs/deploy.md +81 -0
  33. package/dist/docs/dev.md +72 -0
  34. package/dist/docs/developers-agents-and-ai.md +95 -0
  35. package/dist/docs/developers-architecture.md +106 -0
  36. package/dist/docs/getting-started.md +108 -0
  37. package/dist/docs/index.json +336 -0
  38. package/dist/docs/infrastructure.md +406 -0
  39. package/dist/docs/migrations.md +183 -0
  40. package/dist/docs/overview-faq.md +105 -0
  41. package/dist/docs/overview-how-it-works.md +82 -0
  42. package/dist/docs/overview-use-cases.md +76 -0
  43. package/dist/docs/overview-what-is-canopy.md +75 -0
  44. package/dist/docs/platform-ai-usage.md +65 -0
  45. package/dist/docs/platform-client-access.md +54 -0
  46. package/dist/docs/platform-deploys-and-rollbacks.md +93 -0
  47. package/dist/docs/platform-egress.md +75 -0
  48. package/dist/docs/platform-feedback.md +114 -0
  49. package/dist/docs/platform-google.md +213 -0
  50. package/dist/docs/platform-members-and-access.md +71 -0
  51. package/dist/docs/platform-project-data.md +76 -0
  52. package/dist/docs/platform-schedules.md +90 -0
  53. package/dist/docs/platform-sites-and-profiles.md +61 -0
  54. package/dist/docs/porting-nextjs.md +233 -0
  55. package/dist/docs/sdk-ai.md +103 -0
  56. package/dist/docs/sdk-auth.md +57 -0
  57. package/dist/docs/sdk-database.md +126 -0
  58. package/dist/docs/sdk-files.md +82 -0
  59. package/dist/docs/sdk-services.md +58 -0
  60. package/dist/docs/sdk.md +126 -0
  61. package/dist/docs/staging.md +106 -0
  62. package/dist/docs/static-serving.md +323 -0
  63. package/dist/docs/troubleshooting.md +102 -0
  64. package/dist/{docs-DxB7NhZs.mjs → docs-BBMVxVta.mjs} +2 -2
  65. package/dist/{egress-CN_axsAN.mjs → egress-BFSwa8Uu.mjs} +1 -1
  66. package/dist/{feedback-B-GXUBPs.mjs → feedback-BMX_d4KZ.mjs} +2 -2
  67. package/dist/index.mjs +25 -24
  68. package/dist/{local-BGRd_FMZ.mjs → local-BQxsp-ZQ.mjs} +2 -2
  69. package/dist/{login-D7dI8D9N.mjs → login-BDTO3nVw.mjs} +1 -1
  70. package/dist/{logs-DNiZquki.mjs → logs-C53y2eAS.mjs} +1 -1
  71. package/dist/{migrations-C-5x6SYf.mjs → migrations-C6EcKD6a.mjs} +1 -1
  72. package/dist/{open-DRPp4QJg.mjs → open-C5x3tX6I.mjs} +1 -1
  73. package/dist/{package-CDLufecy.mjs → package-vJXq4RNN.mjs} +1 -1
  74. package/dist/{projects-C6JQjVqg.mjs → projects-MMbv7MmK.mjs} +1 -1
  75. package/dist/schedule-Cq9K0rnE.mjs +106 -0
  76. package/dist/skill/canopy/SKILL.md +160 -0
  77. package/dist/{skill-BOWfqwr2.mjs → skill-Zq9aIPKf.mjs} +2 -2
  78. package/dist/{staging-Bw1D_J8M.mjs → staging-D8G4amjO.mjs} +1 -1
  79. package/dist/templates/spa/.github/workflows/canopy-deploy.yml +34 -0
  80. package/dist/templates/spa/AGENTS.md +47 -0
  81. package/dist/templates/spa/README.md +14 -0
  82. package/dist/templates/spa/_gitignore +4 -0
  83. package/dist/templates/spa/canopy.json +5 -0
  84. package/dist/templates/spa/index.html +12 -0
  85. package/dist/templates/spa/package.json +21 -0
  86. package/dist/templates/spa/src/app.tsx +25 -0
  87. package/dist/templates/spa/src/canopy.ts +5 -0
  88. package/dist/templates/spa/src/main.tsx +10 -0
  89. package/dist/templates/spa/src/style.css +29 -0
  90. package/dist/templates/spa/tsconfig.json +16 -0
  91. package/dist/templates/spa/vite.config.ts +7 -0
  92. package/dist/templates/static/AGENTS.md +45 -0
  93. package/dist/templates/static/README.md +9 -0
  94. package/dist/templates/static/_gitignore +2 -0
  95. package/dist/templates/static/canopy.json +4 -0
  96. package/dist/templates/static/index.html +21 -0
  97. package/dist/templates/static/main.js +9 -0
  98. package/dist/templates/static/style.css +29 -0
  99. package/dist/templates/worker/.dev.vars.example +9 -0
  100. package/dist/templates/worker/.github/workflows/canopy-deploy.yml +34 -0
  101. package/dist/templates/worker/AGENTS.md +63 -0
  102. package/dist/templates/worker/README.md +17 -0
  103. package/dist/templates/worker/_gitignore +5 -0
  104. package/dist/templates/worker/canopy.json +6 -0
  105. package/dist/templates/worker/index.html +12 -0
  106. package/dist/templates/worker/package.json +22 -0
  107. package/dist/templates/worker/src/app.tsx +25 -0
  108. package/dist/templates/worker/src/canopy.ts +5 -0
  109. package/dist/templates/worker/src/main.tsx +10 -0
  110. package/dist/templates/worker/src/style.css +29 -0
  111. package/dist/templates/worker/tsconfig.json +16 -0
  112. package/dist/templates/worker/vite.config.ts +7 -0
  113. package/dist/templates/worker/worker/index.ts +52 -0
  114. package/dist/{token-D7zrIF8G.mjs → token-BUxgRJVA.mjs} +1 -1
  115. package/dist/{ui-2mrJcjYC.mjs → ui-B1Px5y7q.mjs} +17 -0
  116. package/dist/{upgrade-fhfrid_O.mjs → upgrade-ES8u8_Pn.mjs} +2 -2
  117. package/dist/{usage-CX1Izj_l.mjs → usage-DjNngyvy.mjs} +1 -1
  118. package/package.json +1 -1
  119. package/dist/dev-CySBxW7I.mjs +0 -2
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Using the SDK
3
+ description: One typed client for auth, data, files, AI, and services — no keys, no connection strings, the platform authenticates for you.
4
+ ---
5
+
6
+ `@mediamonks/canopy` is a thin, typed client over the platform's project API.
7
+ One `createCanopy()` call gives you five modules — `auth`, `db`, `blob`, `ai`,
8
+ and `services` — and that is the entire surface. There are no API keys,
9
+ connection strings, or environment variables to manage: every call rides the
10
+ session the platform already established before your code ran.
11
+
12
+ The same code works everywhere your project runs — in the browser during
13
+ `canopy dev`, in the deployed site, and in a project's backend worker.
14
+
15
+ ## Getting the SDK
16
+
17
+ You usually don't install anything. The `spa` and `worker` templates ship with
18
+ `@mediamonks/canopy` in `package.json`; the `static` template imports the same
19
+ bundle straight from the platform, untyped but always version-matched to the
20
+ API:
21
+
22
+ ```ts
23
+ // spa / worker templates (typed, from npm)
24
+ import { createCanopy } from "@mediamonks/canopy";
25
+
26
+ // static template (untyped, served by the platform)
27
+ import { createCanopy } from "/_api/data/v1/sdk.js";
28
+ ```
29
+
30
+ ## createCanopy(config)
31
+
32
+ `createCanopy` takes an optional config object and returns the client:
33
+
34
+ ```ts
35
+ import { createCanopy } from "@mediamonks/canopy";
36
+
37
+ const canopy = createCanopy();
38
+ // canopy.auth, canopy.db, canopy.blob, canopy.ai, canopy.services
39
+ ```
40
+
41
+ | Option | Type | Default | What it does |
42
+ | --------- | -------------- | -------------- | --------------------------------------------------------------------------------------------------------------------- |
43
+ | `baseUrl` | `string` | `"/_api"` | Base URL of the platform data API. The default is same-origin, so it works untouched in the browser. |
44
+ | `token` | `string` | none | A project-scoped `cnpy_` token sent as a bearer on every request — for scripts and CI running outside Canopy hosting. |
45
+ | `fetch` | `typeof fetch` | global `fetch` | Override how requests are sent — a backend worker passes its platform service binding here. |
46
+
47
+ In the browser no config is needed. In a backend worker (the `worker`
48
+ template), the platform is reachable through the `CANOPY` service binding, so
49
+ create the client per request with that binding's fetch:
50
+
51
+ ```ts
52
+ app.get("/api/hello", async (c) => {
53
+ const canopy = createCanopy({ fetch: c.env.CANOPY.fetch.bind(c.env.CANOPY) });
54
+ const user = await canopy.auth.user();
55
+ return c.json({ message: `Hello from the backend, ${user.name}!` });
56
+ });
57
+ ```
58
+
59
+ Outside Canopy hosting — a local Node script, a CI job — there is no session
60
+ and no service binding. Mint a dev token (`canopy dev` does this for you, or
61
+ `POST /control/v1/projects/:slug/dev-token`) and pass it with the platform's
62
+ API host:
63
+
64
+ ```ts
65
+ const canopy = createCanopy({
66
+ baseUrl: "https://api.canopy.website",
67
+ token: process.env.CANOPY_TOKEN, // a project-scoped cnpy_ token
68
+ });
69
+ ```
70
+
71
+ ## One instance, shared
72
+
73
+ In the browser, create the client once and import it everywhere. The scaffolds
74
+ do this in `src/canopy.ts`:
75
+
76
+ ```ts
77
+ // src/canopy.ts
78
+ import { createCanopy } from "@mediamonks/canopy";
79
+
80
+ export const canopy = createCanopy();
81
+ ```
82
+
83
+ ## Error handling
84
+
85
+ Any call that fails throws a `CanopyError` (exported from the package). It
86
+ extends `Error` with the HTTP `status` of the failed request; `message` carries
87
+ the response body when the platform sent one:
88
+
89
+ ```ts
90
+ import { CanopyError } from "@mediamonks/canopy";
91
+
92
+ try {
93
+ await canopy.db.collection("todos").insert({ title: "hi", done: false });
94
+ } catch (err) {
95
+ if (err instanceof CanopyError && err.status === 403) {
96
+ // e.g. a viewer hitting a write — show a friendly message
97
+ }
98
+ throw err;
99
+ }
100
+ ```
101
+
102
+ Two read calls are deliberately soft instead: `collection.get(id)` and
103
+ `blob.get(key)` resolve to `null` for a missing document or object rather than
104
+ throwing.
105
+
106
+ ## Where it runs
107
+
108
+ The client is plain `fetch` underneath, so behavior is identical across
109
+ environments:
110
+
111
+ - **Browser, deployed** — calls go to same-origin `/_api`; the platform
112
+ authenticated the visitor before your page loaded, and the session cookie
113
+ rides along automatically.
114
+ - **Browser, `canopy dev`** — the dev server proxies `/_api` to the platform
115
+ with your CLI login, so local code talks to your project's real data.
116
+ - **Backend worker** — pass the `CANOPY` service binding's fetch (shown
117
+ above); the incoming request's identity carries through, so `auth.user()`
118
+ and per-project budgets behave exactly as in the browser.
119
+
120
+ ## Modules
121
+
122
+ - [Auth](https://docs.canopy.website/sdk/auth) — who is signed in, and ending sessions.
123
+ - [Database](https://docs.canopy.website/sdk/database) — document collections, plus a raw SQL escape hatch.
124
+ - [Files](https://docs.canopy.website/sdk/files) — private blob storage under the project's prefix.
125
+ - [AI](https://docs.canopy.website/sdk/ai) — metered, budget-capped LLM access.
126
+ - [Services](https://docs.canopy.website/sdk/services) — curated internal data services.
@@ -0,0 +1,106 @@
1
+ ---
2
+ title: Staging & promoting
3
+ description: Deploy to a safe copy of your project first — own database cloned from live — then promote the exact same version.
4
+ ---
5
+
6
+ Every project can have a **staging channel**: a second, private copy of the
7
+ app with its own database (cloned from live), its own files, and its own
8
+ backend, served at
9
+
10
+ ```
11
+ https://staging-dot-<slug>.canopy.website
12
+ ```
13
+
14
+ behind the same sign-in as the live site. Deploy there first, click around
15
+ against real-shaped data, then **promote** — the exact version you tested
16
+ becomes live, nothing is rebuilt.
17
+
18
+ You don't set anything up. The first staged deploy creates the channel and
19
+ copies the live database into it.
20
+
21
+ ## The workflow
22
+
23
+ ```bash
24
+ canopy deploy --staging # build + deploy to staging (created on first use)
25
+ canopy open --staging # check it in the browser
26
+ canopy promote # happy? make that version live
27
+ ```
28
+
29
+ `canopy deploy --staging` is the normal deploy in every way — same build, same
30
+ versioning — except the result serves on the staging URL and the live site is
31
+ untouched. Versions share one sequence: if live is at v7 and you stage v8,
32
+ promoting makes that same v8 live.
33
+
34
+ `canopy promote` is not a redeploy: the platform re-uses the exact files and
35
+ backend bundle you staged. If migrations were rehearsed on staging, they replay
36
+ against the live database first (with an automatic snapshot), then the pointer
37
+ flips.
38
+
39
+ ## Testing schema migrations
40
+
41
+ This is the staging channel's superpower. Because the staging database starts
42
+ as a copy of live, a migration that works there will behave the same way on
43
+ the real thing:
44
+
45
+ ```bash
46
+ canopy deploy --staging # pending migrations apply to the STAGING database
47
+ # … verify the app against the migrated copy …
48
+ canopy promote # the same migrations replay against live, then v goes live
49
+ ```
50
+
51
+ A migration that fails on staging costs you nothing — live never saw it. See
52
+ [Schema migrations](https://docs.canopy.website/guides/migrations) for the migration system itself.
53
+
54
+ ## Staging an older version
55
+
56
+ Any version in the deploy history can be pointed at the staging channel —
57
+ from the dashboard's deploy list (the **Stage** action, next to Rollback) or
58
+ with `canopy staging use v3`. Like rollback, it's a pointer move: nothing is
59
+ rebuilt, and live is untouched. Handy for comparing an old build against the
60
+ staging data, or re-testing a version before promoting it.
61
+
62
+ ## Keeping staging fresh
63
+
64
+ Staging data drifts as you test. Re-clone it from live anytime:
65
+
66
+ ```bash
67
+ canopy staging sync # replace staging data with a fresh copy of live
68
+ canopy staging status # what's staged vs live
69
+ ```
70
+
71
+ Writes on staging never flow back to live — promote moves code and schema, not
72
+ data.
73
+
74
+ ## What's shared, what's separate
75
+
76
+ Separate per channel: the database, uploaded files, and the backend worker.
77
+ Shared with live: members, secrets, the client password, schedules (they run
78
+ against live only), and the AI budget — staging traffic counts against the
79
+ same cap.
80
+
81
+ ## Developing against staging data
82
+
83
+ Remote dev can point at the staging database instead of live — useful while a
84
+ schema is in flux:
85
+
86
+ ```bash
87
+ canopy dev --staging
88
+ ```
89
+
90
+ ## Cleaning up
91
+
92
+ ```bash
93
+ canopy staging destroy # removes the staging database, files and backend
94
+ ```
95
+
96
+ Live is untouched, and the next `canopy deploy --staging` recreates the channel
97
+ from a fresh copy. The dashboard's Settings tab has the same controls, the
98
+ Overview tab shows what's staged with a Promote button, and a Live/Staging
99
+ toggle above the Data and Files tabs browses either channel's data.
100
+
101
+ ## Staging vs version previews
102
+
103
+ Version preview URLs (`v12-dot-<slug>`) show an old **frontend** against the
104
+ **live** data — good for comparing builds. The staging channel is the full
105
+ package: candidate frontend, candidate backend, and its own database. Use
106
+ previews to look backwards, staging to look forwards.
@@ -0,0 +1,323 @@
1
+ ---
2
+ title: How static files are served
3
+ description: The full path a browser request takes to a deployed file.
4
+ ---
5
+
6
+ This explains the full path a browser request takes to receive a deployed
7
+ project's `index.html` and its assets (JS/CSS/images/fonts), and reviews that
8
+ path from a **security** and a **performance** angle.
9
+
10
+ Everything a visitor sees is a _deployed_ build stored in R2 — the platform
11
+ serves pre-built static files. (When a project has a backend worker, `/api/*`
12
+ requests dispatch to it — Workers for Platforms on Cloudflare, Cloud Run on
13
+ GCP; everything else is still static serving.)
14
+
15
+ Asset serving is the hottest **data-plane** path (see
16
+ [`docs/09-plane-split.md`](../09-plane-split.md)): it lives at
17
+ `/data/v1/assets/*`, and the URL carries no project slug — the gateway stamps
18
+ which project the request is for.
19
+
20
+ ## The request path
21
+
22
+ A request to `https://<slug>.canopy.<domain>/some/path` flows through two
23
+ Workers and one R2 bucket:
24
+
25
+ ```
26
+ Browser
27
+ │ GET https://dashboard.canopy.example.com/assets/app-a1b2.js
28
+ ▼
29
+ canopy-gateway (apps/gateway/src/index.ts)
30
+ │ 1. Parse <slug> (and optional pinned version) from the hostname
31
+ │ 2. Enforce auth — OneLogin session cookie, or client-password cookie
32
+ │ 3. Rewrite path → /data/v1/assets/<path> (+ ?v=<n> if pinned),
33
+ │ stamping X-Canopy-Project: <slug> + an internal JWT vouching for it
34
+ ▼ env.API.fetch(...) (service binding, no public hop)
35
+ canopy-api (apps/api/src/routes/assets.ts)
36
+ │ 4. Resolve the project from the stamp + which version is live
37
+ │ 5. Sanitize the asset path; directory index, else SPA fallback
38
+ │ 6. R2 GET deploys/<project-id>/<version>/<path>
39
+ ▼
40
+ R2 bucket "canopy-assets"
41
+ ```
42
+
43
+ ### 1–3. Gateway: hostname, auth, path rewrite
44
+
45
+ [`apps/gateway/src/index.ts`](../../apps/gateway/src/index.ts) is the front door
46
+ for every `*.canopy.<domain>` request.
47
+
48
+ - **Hostname → slug + version.** `parseHost()`
49
+ ([`index.ts`](../../apps/gateway/src/index.ts)) splits the first DNS label.
50
+ A plain `dashboard` resolves to that project's _live_ version; a
51
+ `v12-dot-dashboard` prefix pins version 12 of the **frontend only** — auth,
52
+ client access, and the data plane still resolve to the real project, so an old
53
+ build previews against _current_ data rather than forking it.
54
+ - **Auth before any project bytes.** No asset is served unauthenticated. A
55
+ request must carry either a valid OneLogin session cookie (verified via a
56
+ shared `SESSION_SECRET`) or — on client-enabled projects — a scope-limited
57
+ client-password cookie. Missing both: client-enabled projects get the password
58
+ page ([`client.ts`](../../apps/gateway/src/client.ts)), everything else
59
+ redirects to OneLogin. Collaborators-only projects additionally turn
60
+ non-members away with a members-only page before any dispatch.
61
+ - **Path rewrite + project stamp.** Once authorized, the gateway rewrites the
62
+ request to the data plane and forwards it over a **service binding**
63
+ (`env.API.fetch`), never a public URL
64
+ ([`index.ts`](../../apps/gateway/src/index.ts)). The URL carries no slug —
65
+ the project travels as a header the gateway vouches for:
66
+
67
+ ```ts
68
+ const assetUrl = new URL(request.url);
69
+ assetUrl.pathname = `/data/v1/assets${url.pathname}`;
70
+ if (version !== null) assetUrl.searchParams.set("v", String(version));
71
+ const assetRequest = new Request(assetUrl, request);
72
+ assetRequest.headers.delete("x-canopy-project");
73
+ assetRequest.headers.set("x-canopy-project", slug);
74
+ assetRequest.headers.set("x-canopy-internal", await createInternalJwt(env.SESSION_SECRET));
75
+ return env.API.fetch(assetRequest);
76
+ ```
77
+
78
+ The caller's cookie travels along, so canopy-api re-verifies it independently,
79
+ and canopy-api trusts the `X-Canopy-Project` stamp only when the accompanying
80
+ internal JWT verifies.
81
+
82
+ `/_api/*` requests are handled separately, forwarded to the API before the
83
+ session wall so the SDK gets JSON 401s instead of login HTML. The plane is the
84
+ path: `/_api/control/v1/*` forwards untouched, `/_api/data/v1/*` gets the same
85
+ project stamp as asset requests.
86
+
87
+ ### 4–6. API: version resolution, path sanitizing, R2 read
88
+
89
+ The serving handler is `GET /data/v1/assets/*` in
90
+ [`apps/api/src/routes/assets.ts`](../../apps/api/src/routes/assets.ts).
91
+
92
+ - **Project + status.** Resolves the project from the gateway's vouched stamp
93
+ only (`vouchedProject` — never a query string), via a cached
94
+ `ProjectSnapshot` from the `ProjectDirectory`
95
+ ([`src/directory.ts`](../../apps/api/src/directory.ts)) — the data plane
96
+ never queries the control database directly. Unknown → 404 placeholder;
97
+ paused (`status !== "active"`) → a "paused" placeholder page. Viewability is
98
+ company-wide by default; projects marked **collaborators-only**
99
+ (`members_only`) turn non-members away with a 403 placeholder
100
+ (`checkAppAccess`, [`src/access.ts`](../../apps/api/src/access.ts)).
101
+ - **Which version.** `?v=<n>` pins an exact version (must exist in the
102
+ project's deploy history, else 404 — it never silently falls back to live);
103
+ otherwise the snapshot's `liveAssetVersion` is served, which falls back to
104
+ the max deployed version for legacy rows with a NULL pointer.
105
+ - **Path → R2 key.** The tail after `/assets/` is sanitized (see Security)
106
+ and mapped to the R2 key `deploys/<project-id>/<version>/<path>`; the root
107
+ path serves `index.html`.
108
+ - **Directory index + SPA fallback**: the exact asset is read first. On a
109
+ miss, an extensionless path is probed for its own directory index — `/feeds/`
110
+ serves `feeds/index.html` (multi-page static exports), and the slash-less
111
+ `/feeds` 301-redirects to `/feeds/` so the page's relative URLs resolve.
112
+ Only when no directory index exists does the request fall back to the root
113
+ `index.html`, so client-side routes (`/settings`, `/projects/42`) still all
114
+ resolve to the SPA entry point. Both the fallback `index.html` and the
115
+ per-path probe results are served from per-version caches (see
116
+ [Performance](#performance-review)), so a deep-linked route doesn't pay a
117
+ second R2 read on every request.
118
+
119
+ ### How assets are stored & versioned
120
+
121
+ `canopy deploy` uses a two-step signed upload
122
+ ([`docs/09-plane-split.md`](../09-plane-split.md) D-d): the **control plane**
123
+ authorizes the deploy (`POST /control/v1/projects/:slug/deploys`,
124
+ [`deploys.ts`](../../apps/api/src/routes/deploys.ts)) and issues a short-lived
125
+ signed grant; the CLI PUTs each file to the **data plane**
126
+ (`PUT /data/v1/uploads/:grant`,
127
+ [`data-plane.ts`](../../apps/api/src/routes/data-plane.ts)); a completion call
128
+ (`POST …/deploys/:version/complete`) validates the staged bundle and records
129
+ the version. Bundle bytes never transit the control plane.
130
+
131
+ - Each file is written to R2 at `deploys/<project-id>/<version>/<path>` with its
132
+ `contentType` stored as R2 `httpMetadata` by the upload endpoint.
133
+ - The bucket is `canopy-assets`, bound as `ASSETS`
134
+ ([`apps/api/wrangler.main.jsonc`](../../apps/api/wrangler.main.jsonc)).
135
+ - **Immutable, append-only versioning.** A deploy bumps the version and writes a
136
+ new prefix; the `deployments` row commits at completion _before_
137
+ `live_asset_version` moves, so a half-uploaded deploy is never served (an
138
+ abandoned grant leaves only unreferenced staged bytes). Rollback
139
+ (`POST …/deploys/:version/rollback`,
140
+ [`deploys.ts`](../../apps/api/src/routes/deploys.ts)) just repoints
141
+ `live_asset_version` at an existing version — instant, nothing copied, nothing
142
+ deleted, and any version can be made live again. Old versions keep their bytes
143
+ and their identity, which is what makes `v<n>-dot-` preview hosts work.
144
+ - Project deletion purges the whole `deploys/<project-id>/` prefix (plus blobs
145
+ and snapshots) so no R2 residue remains
146
+ ([`projects.ts`](../../apps/api/src/routes/projects.ts)).
147
+
148
+ The browser SDK itself is served by the platform, not from a deploy bundle:
149
+ `GET /_api/data/v1/sdk.js` returns an inlined bundle
150
+ ([`data-plane.ts`](../../apps/api/src/routes/data-plane.ts)) so scaffolded
151
+ projects need no `npm install`.
152
+
153
+ ---
154
+
155
+ ## Security review
156
+
157
+ ### What's solid
158
+
159
+ - **Nothing is public.** Every asset request passes the gateway auth wall
160
+ (OneLogin session or client-password cookie) before any project bytes are
161
+ read, and the gateway→API hop is a private service binding, not an internet
162
+ URL. This is the north-star "never public URLs" guarantee.
163
+ - **Path-traversal defense.** `safeAssetPath()`
164
+ ([`assets.ts`](../../apps/api/src/routes/assets.ts)) rejects on both
165
+ ingest (the upload endpoint) and serve: strips a leading `./` or `/`, then
166
+ rejects empty paths, paths over 512 chars, and any path containing `\`, `//`,
167
+ `\0`, or a `.`/`..`/empty segment. A malicious key can't escape the
168
+ `deploys/<project-id>/<version>/` prefix, and one project can never read
169
+ another's bytes because the prefix is keyed by the resolved project id.
170
+ - **Tenant isolation by key prefix and by stamp.** The R2 key always embeds the
171
+ resolved `project.id`, so cross-project reads are structurally impossible even
172
+ if a path slipped through. Serving resolves its project from the gateway's
173
+ vouched `X-Canopy-Project` stamp alone — never a query parameter — so a
174
+ crafted query string can't render one project's pages under another's host.
175
+ - **Content-Type is fixed at deploy time**, from an allowlist keyed by extension
176
+ (`contentTypeFor()`, [`assets.ts`](../../apps/api/src/routes/assets.ts)),
177
+ and stored as R2 metadata by the upload endpoint. Serving echoes that stored
178
+ type; unknown extensions become `application/octet-stream` (download, not
179
+ execute). The type is never derived from request input at serve time.
180
+ - **Deploy bundle limits.** Max 500 files and 25 MB total
181
+ ([`storage-keys.ts`](../../apps/api/src/storage-keys.ts)), enforced per file
182
+ at upload and re-checked over the staged inventory at completion; a bundle
183
+ must contain `index.html` or completion is rejected — bounds the blast radius
184
+ of a bad upload. Upload grants are HMAC-signed, project- and version-bound,
185
+ and expire after 15 minutes ([`upload-grant.ts`](../../apps/api/src/upload-grant.ts)).
186
+ - **Password page escapes untrusted input.** `escapeHtml()` and the same-site
187
+ `safeLocalPath()`/`safeReturnTo()` guards prevent reflected-XSS and open-redirect
188
+ via the `returnTo` parameter
189
+ ([`client.ts`](../../apps/gateway/src/client.ts),
190
+ [`index.ts`](../../apps/gateway/src/index.ts)).
191
+ - **Version pinning can't leak data.** A `v<n>-dot-` host pins only _which
192
+ frontend build_ is served; auth, client-access, and all `/_api` data-plane
193
+ calls resolve to the real project (the stamp names the real slug),
194
+ so an old build cannot be used to read a different project's data.
195
+
196
+ ### Gaps / things to weigh
197
+
198
+ These are observations, not known incidents — worth a conscious decision:
199
+
200
+ 1. **No hardening response headers.** Served assets carry `content-type`, `etag`,
201
+ and `cache-control` only ([`assets.ts`](../../apps/api/src/routes/assets.ts)).
202
+ There is **no** `X-Content-Type-Options: nosniff`, no `Content-Security-Policy`,
203
+ no `X-Frame-Options`/`frame-ancestors`, no `Referrer-Policy`, and no HSTS. For
204
+ an app platform serving arbitrary user HTML/JS on shared `*.canopy.<domain>`
205
+ subdomains, the most impactful additions are:
206
+ - `X-Content-Type-Options: nosniff` — cheap, prevents MIME-sniffing of e.g. a
207
+ user-uploaded file into script.
208
+ - `frame-ancestors`/`X-Frame-Options` — clickjacking, and stops one canopy
209
+ subdomain from framing another.
210
+ - A default `Content-Security-Policy` (even a permissive one) as a baseline.
211
+ Note projects run against a shared cookie domain, so cross-subdomain
212
+ concerns (cookie scoping, framing) matter more here than on an isolated
213
+ site.
214
+ 2. **`text/html` is served for deployed HTML with the session cookie present.**
215
+ That's by design (auth-gated apps), but it means any XSS in a deployed project
216
+ runs on a `*.canopy.<domain>` origin that shares the session-cookie domain.
217
+ The session cookie's flags (HttpOnly/SameSite/Secure) in
218
+ [`session.ts`](../../apps/gateway/src/session.ts) are the mitigation to keep an
219
+ eye on; per-project origin isolation would be the stronger fix.
220
+ 3. **SPA fallback returns `index.html` with 200 for unknown paths**, including
221
+ things that look like missing assets (`/assets/typo.js` → HTML). That's normal
222
+ SPA behavior but can mask 404s and, combined with #1, is another reason
223
+ `nosniff` matters (an HTML body served where a script was expected).
224
+ 4. **Serving is company-wide readable by default.** Any authenticated monk can
225
+ view any active project unless it opts into collaborators-only
226
+ (`members_only`, enforced in [`assets.ts`](../../apps/api/src/routes/assets.ts)
227
+ via `checkAppAccess`). The default is an intentional product decision (see
228
+ docs/02-decisions.md), not a bug — but it means "authenticated" ≠
229
+ "authorized for this specific project" on the read path unless the project
230
+ tightens it.
231
+
232
+ ---
233
+
234
+ ## Performance review
235
+
236
+ ### What helps
237
+
238
+ - **Split caching policy by type** ([`assets.ts`](../../apps/api/src/routes/assets.ts)):
239
+
240
+ ```ts
241
+ "cache-control": type.startsWith("text/html")
242
+ ? "no-cache" // HTML always revalidates → new deploys show up
243
+ : "private, max-age=3600", // hashed subresources cache for 1h
244
+ ```
245
+
246
+ HTML revalidates so a fresh deploy is picked up immediately; hashed JS/CSS/font
247
+ assets cache in the browser for an hour. This is the right shape for a
248
+ content-hashed SPA build.
249
+
250
+ - **ETags** (`"v<version>-<path>"`, [`assets.ts`](../../apps/api/src/routes/assets.ts))
251
+ give a stable validator per version+path, so revalidation of `no-cache` HTML
252
+ can come back as a cheap `304`. Because the etag encodes the version, a rollback
253
+ or new deploy changes the validator and busts caches correctly.
254
+ - **Instant deploys and rollbacks.** Serving reads a pointer
255
+ (`live_asset_version`); deploy/rollback move the pointer without copying bytes,
256
+ so there's no propagation delay and no rebuild.
257
+ - **Service-binding hop.** Gateway→API is an in-Cloudflare service binding, not a
258
+ public fetch, so there's no extra internet round-trip or TLS handshake between
259
+ the two Workers.
260
+
261
+ ### Bottlenecks / opportunities
262
+
263
+ 1. **`max-age=3600` is modest for hashed assets.** Content-hashed filenames (the
264
+ Vite default) can safely use `public, max-age=31536000, immutable`. The current
265
+ value is `private` (see #2) and only an hour, so returning visitors refetch
266
+ more than necessary. Consider keying on filename hash pattern or a deploy-time
267
+ flag to mark truly immutable assets.
268
+ 2. **`private` prevents any shared/edge caching.** Because assets are auth-gated
269
+ and served `private`, they can't be cached by Cloudflare's CDN or any shared
270
+ proxy — every cache miss goes Worker→R2. For an auth-walled platform that's a
271
+ deliberate trade (privacy over edge-cache hit rate), but it means R2 latency is
272
+ on the critical path for every uncached request. If per-project caching is ever
273
+ wanted, the Cache API keyed by (project, version, path) inside the Worker would
274
+ let you cache without making anything public.
275
+ 3. **No compression / `Content-Encoding`.** The handler streams R2 bytes as-is
276
+ with no gzip/brotli negotiation and ignores `Accept-Encoding`. Cloudflare may
277
+ apply compression at the edge for eligible content types, but nothing is stored
278
+ pre-compressed and the Worker doesn't negotiate it. For large JS bundles,
279
+ storing brotli-precompressed variants (or relying on and verifying edge
280
+ compression) is worth measuring.
281
+ 4. **Range requests / streaming media.** The handler returns the full R2 object
282
+ body and doesn't honor `Range`. Fine for a bundled SPA; if projects ever serve
283
+ large video/audio, add `Range` support (R2 supports ranged `get`).
284
+
285
+ ### Addressed — in-worker caches
286
+
287
+ Two former bottlenecks are now handled by best-effort, per-isolate caches
288
+ (a performance layer only — never a source of truth; a cold isolate just reads
289
+ D1/R2):
290
+
291
+ - **SPA fallback no longer double-reads R2.** The serve path reads the exact
292
+ asset once; on a miss it falls back to `index.html` served from a per-`(project,
293
+ version)` cache ([`asset-cache.ts`](../../apps/api/src/asset-cache.ts)). Because a
294
+ deployed version's bytes are immutable, that entry is cached indefinitely
295
+ (count-bounded), so the second R2 read is paid **once per version**, not once
296
+ per deep-linked route. Verified by a test that serves three client routes and
297
+ asserts a single `index.html` read.
298
+ - **Project state is cached per slug** as a `ProjectSnapshot` in the
299
+ `ProjectDirectory` ([`src/directory.ts`](../../apps/api/src/directory.ts) —
300
+ the generalization of the old metadata cache), sparing the hot path its
301
+ control-DB queries. Since this data _does_ change, deploy, rollback, and
302
+ delete call `invalidateSnapshot()` so a write is reflected immediately on the
303
+ same isolate; a short TTL (5 s) is only a backstop for a write that lands on
304
+ a different isolate than the next read.
305
+
306
+ ---
307
+
308
+ ## Quick reference
309
+
310
+ | Concern | Where | Notes |
311
+ | --------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
312
+ | Front door / auth / routing | [`apps/gateway/src/index.ts`](../../apps/gateway/src/index.ts) | Hostname→slug, auth wall, `/data/v1/assets` rewrite + project stamp |
313
+ | Client-password page | [`apps/gateway/src/client.ts`](../../apps/gateway/src/client.ts) | HTML-escaped, same-site returnTo |
314
+ | Asset serve handler | [`apps/api/src/routes/assets.ts`](../../apps/api/src/routes/assets.ts) | Version resolve, path sanitize, SPA fallback, headers |
315
+ | Project snapshots | [`apps/api/src/directory.ts`](../../apps/api/src/directory.ts) | `ProjectDirectory` cache (5 s TTL), invalidated on write |
316
+ | index.html cache | [`apps/api/src/asset-cache.ts`](../../apps/api/src/asset-cache.ts) | Per-version, immutable bytes, count-bounded |
317
+ | Path sanitizing | [`assets.ts`](../../apps/api/src/routes/assets.ts) | `safeAssetPath()` — traversal defense, shared with the upload endpoint |
318
+ | Content-Type allowlist | [`assets.ts`](../../apps/api/src/routes/assets.ts) | Fixed at deploy, stored in R2 metadata |
319
+ | Deploy authorize / complete | [`apps/api/src/routes/deploys.ts`](../../apps/api/src/routes/deploys.ts) | Signed grant, 500 files / 25 MB, pointer moves last |
320
+ | Upload bytes | [`apps/api/src/routes/data-plane.ts`](../../apps/api/src/routes/data-plane.ts) | `PUT /data/v1/uploads/:grant`, grant is the credential |
321
+ | Rollback | [`deploys.ts`](../../apps/api/src/routes/deploys.ts) | Repoints `live_asset_version` |
322
+ | R2 bucket binding | [`apps/api/wrangler.main.jsonc`](../../apps/api/wrangler.main.jsonc) | `ASSETS` → `canopy-assets` |
323
+ | SDK serving | [`apps/api/src/routes/data-plane.ts`](../../apps/api/src/routes/data-plane.ts) | `/_api/data/v1/sdk.js`, `public, max-age=300` |
@@ -0,0 +1,102 @@
1
+ ---
2
+ title: Troubleshooting
3
+ description: Common errors and how to resolve them.
4
+ ---
5
+
6
+ ## `command not found: canopy`
7
+
8
+ The CLI isn't installed or isn't on your PATH. Install it:
9
+
10
+ ```bash
11
+ npm install -g @mediamonks/canopy-cli
12
+ ```
13
+
14
+ If it installs but still isn't found, your npm global bin directory isn't on your
15
+ PATH. Run `npm bin -g` to find it and add that directory to your PATH.
16
+
17
+ ## `Not logged in — run canopy login`
18
+
19
+ You have no valid CLI token. Run `canopy login` and finish the sign-in in the
20
+ browser. Check status with `canopy whoami`.
21
+
22
+ ## `Session expired — run canopy login again`
23
+
24
+ Your token expired. Just run `canopy login` again.
25
+
26
+ ## The browser didn't open during login
27
+
28
+ `canopy login` prints the sign-in URL as a fallback — open it manually in a
29
+ browser on the **same machine** as the CLI (login uses a local loopback
30
+ redirect, so it must be the same machine).
31
+
32
+ ## `Port 8976 is in use`
33
+
34
+ A previous login attempt is still holding the callback port. Close it (or wait a
35
+ moment) and retry `canopy login`.
36
+
37
+ ## Login hangs and never completes
38
+
39
+ Login only works when the CLI runs on the machine whose browser you're using
40
+ (the redirect goes to `127.0.0.1`). It cannot complete on a remote/headless box.
41
+ Run it on your own laptop.
42
+
43
+ ## `Deploy token rejected`
44
+
45
+ The `CANOPY_DEPLOY_TOKEN` was revoked or is wrong. Create a new one with
46
+ `canopy token create` (or in the dashboard) and update your CI secret.
47
+
48
+ ## `not_supported_on_target` (501)
49
+
50
+ Some features are platform-target-specific: a site whose platform target
51
+ doesn't support a feature (for example per-project worker backends, dashboard
52
+ schedules, or secret injection) answers this error, and the dashboard hides
53
+ those panels. It's not a failure of your project — the feature doesn't exist
54
+ on the target your platform runs on.
55
+
56
+ ## `ai_budget_exceeded` (429)
57
+
58
+ The project hit its daily AI budget cap. The cap is enforced before every AI
59
+ call; it resets the next day, or a maintainer can raise the project's budget in
60
+ the dashboard.
61
+
62
+ ## `egress_denied` (403)
63
+
64
+ An outbound `fetch` from your worker was blocked by the platform's egress
65
+ guard: `host <hostname> is not on the project allowlist`. Outbound requests
66
+ are deny-by-default — only platform infrastructure and the hosts on your
67
+ project's own allowlist get through. Add the host and retry (running workers
68
+ pick the change up without a deploy):
69
+
70
+ ```bash
71
+ canopy egress allow api.example.com
72
+ ```
73
+
74
+ or use dashboard Settings → Outbound hosts. Details and entry rules:
75
+ [Outbound requests](https://docs.canopy.website/platform/egress). Calls made through the SDK /
76
+ `env.CANOPY.fetch` don't count as egress and are unaffected.
77
+
78
+ ## The worker returns errors and you can't see why
79
+
80
+ Read its console output instead of adding diagnostic endpoints:
81
+
82
+ ```bash
83
+ canopy logs --since 15m
84
+ ```
85
+
86
+ `console.log` / `console.error` from your deployed worker, straight in the
87
+ terminal; `--follow` tails it live while you reproduce the problem. See
88
+ [canopy logs](https://docs.canopy.website/cli/logs). (On sites without worker log capture the command
89
+ answers `not_supported_on_target`.)
90
+
91
+ ## Docs are stale
92
+
93
+ `canopy docs` caches the platform's docs. Force a refresh:
94
+
95
+ ```bash
96
+ canopy docs <topic> --refresh
97
+ ```
98
+
99
+ ## Still stuck?
100
+
101
+ Run the failing command again and share its full output. `canopy whoami` and
102
+ `canopy --version` are useful to include.
@@ -1,7 +1,7 @@
1
- import { t as version } from "./package-CDLufecy.mjs";
1
+ import { t as version } from "./package-vJXq4RNN.mjs";
2
2
  import { n as siteConfig } from "./site-BUlQR117.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
- import { n as reportErrors } from "./ui-2mrJcjYC.mjs";
4
+ import { n as reportErrors } from "./ui-B1Px5y7q.mjs";
5
5
  import { defineCommand } from "citty";
6
6
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";