@mediamonks/canopy-cli 0.5.1 → 0.7.0
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.
- package/dist/{db-BW1ZolWn.mjs → db-BkUmbGgx.mjs} +71 -15
- package/dist/{deploy-alC2--pZ.mjs → deploy-BcczXlSQ.mjs} +106 -8
- package/dist/dev-CvtoGFW0.mjs +2 -0
- package/dist/{dev-CQQhMOoD.mjs → dev-DvyKHe2d.mjs} +44 -14
- package/dist/docs/ci.md +1 -3
- package/dist/docs/cli.md +3 -4
- package/dist/docs/data.md +5 -4
- package/dist/docs/deploy.md +16 -4
- package/dist/docs/dev.md +13 -11
- package/dist/docs/getting-started.md +1 -3
- package/dist/docs/infrastructure.md +27 -15
- package/dist/docs/migrations.md +183 -0
- package/dist/docs/porting-nextjs.md +232 -0
- package/dist/docs/sdk-ai.md +103 -0
- package/dist/docs/sdk-auth.md +57 -0
- package/dist/docs/sdk-database.md +126 -0
- package/dist/docs/sdk-files.md +82 -0
- package/dist/docs/sdk-services.md +58 -0
- package/dist/docs/sdk.md +113 -0
- package/dist/docs/staging.md +98 -0
- package/dist/docs/static-serving.md +4 -1
- package/dist/docs/troubleshooting.md +1 -3
- package/dist/{docs-DvSPpSIR.mjs → docs-2cUEug69.mjs} +23 -10
- package/dist/index.mjs +22 -20
- package/dist/{local-C5-GWJcx.mjs → local-C47tmhl7.mjs} +365 -41
- package/dist/{login-BO6abf40.mjs → login-ByD0hcxu.mjs} +5 -3
- package/dist/migrations-I1adj0z2.mjs +50 -0
- package/dist/{open-DnlYDqrU.mjs → open-DD9oa-Ho.mjs} +11 -4
- package/dist/{package-CHoZKzaw.mjs → package-CDB-uxy3.mjs} +1 -1
- package/dist/{projects-ijfe7Ffx.mjs → projects-CSH7KsPi.mjs} +17 -9
- package/dist/{site-BJjoePQS.mjs → site-BUlQR117.mjs} +14 -1
- package/dist/skill/canopy/SKILL.md +24 -1
- package/dist/{skill-CW0QSbAJ.mjs → skill-DrWzmUMv.mjs} +2 -2
- package/dist/staging-CfF2-pHs.mjs +113 -0
- package/dist/templates/spa/AGENTS.md +4 -0
- package/dist/templates/static/AGENTS.md +4 -0
- package/dist/templates/worker/AGENTS.md +7 -0
- package/dist/{token-BeF9zczg.mjs → token-nieJV1w7.mjs} +2 -2
- package/dist/{ui-DThlyHgo.mjs → ui-D8HxX0Ng.mjs} +3 -2
- package/dist/{upgrade-CGz1yzsW.mjs → upgrade-CH_jHkc7.mjs} +2 -2
- package/dist/{usage-DQpH7A3X.mjs → usage-qqPtkDtT.mjs} +2 -2
- package/package.json +2 -2
- package/dist/dev-BJ5xEwqW.mjs +0 -2
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
title: Infrastructure as code
|
|
3
|
+
description: Terraform for the account-level Cloudflare pieces Canopy needs.
|
|
4
|
+
---
|
|
2
5
|
|
|
3
6
|
Canopy's Cloudflare footprint is small on purpose: almost everything
|
|
4
7
|
per-project is provisioned at runtime by canopy-api (`apps/api/src/cf.ts`,
|
|
@@ -19,8 +22,8 @@ Scope of a fresh stand-up:
|
|
|
19
22
|
|
|
20
23
|
1. the **platform workers** — `canopy-api` and `canopy-gateway` — deployed and
|
|
21
24
|
healthy (these aren't optional; nothing works without them), and
|
|
22
|
-
2. the **
|
|
23
|
-
and deployed _on_ the platform like any other project.
|
|
25
|
+
2. the **four core projects** — `dashboard`, `www`, `docs`, `kitchen-sink` —
|
|
26
|
+
re-created and deployed _on_ the platform like any other project.
|
|
24
27
|
|
|
25
28
|
Everyone else's projects are **not** carried over — a new account starts with
|
|
26
29
|
an empty control-plane registry (see [Non-goals](#non-goals)).
|
|
@@ -39,8 +42,8 @@ config file per Cloudflare site (same names as CLI profiles). Deployed with
|
|
|
39
42
|
is local `wrangler dev` only.
|
|
40
43
|
|
|
41
44
|
**`canopy deploy`** (`canopy.json` in each project): the core projects
|
|
42
|
-
(`dashboard`, `www`, `kitchen-sink`) — ordinary platform projects
|
|
43
|
-
dogfood the product, not infrastructure.
|
|
45
|
+
(`dashboard`, `www`, `docs`, `kitchen-sink`) — ordinary platform projects
|
|
46
|
+
that dogfood the product, not infrastructure.
|
|
44
47
|
|
|
45
48
|
**Bootstrap scripts** (`infra/scripts/`): runtime API tokens, worker secrets,
|
|
46
49
|
AI Gateway BYOK keys + spend rule.
|
|
@@ -53,9 +56,9 @@ enable paid Workers products. Plus OneLogin: redirect URIs for the new host
|
|
|
53
56
|
## Non-goals
|
|
54
57
|
|
|
55
58
|
- **No project-data migration.** Per-project D1s, R2 blobs, and deploy history
|
|
56
|
-
stay in the old account. The new control-plane D1 is empty; the
|
|
57
|
-
projects are re-created from scratch (they hold no precious data — `www
|
|
58
|
-
`dashboard` are stateless, `kitchen-sink` is a demo), and everyone else's
|
|
59
|
+
stay in the old account. The new control-plane D1 is empty; the core
|
|
60
|
+
projects are re-created from scratch (they hold no precious data — `www`,
|
|
61
|
+
`dashboard`, and `docs` are stateless, `kitchen-sink` is a demo), and everyone else's
|
|
59
62
|
projects are re-created as needed. (If that ever changes, the export/import
|
|
60
63
|
primitives already exist in `apps/api/src/cf.ts` and `canopy db snapshot|restore`.)
|
|
61
64
|
- **No Tofu management of Workers/secrets/per-project resources** — see the
|
|
@@ -95,10 +98,15 @@ export CLOUDFLARE_ACCOUNT_ID="<account_id>" # required when you belong to >1 a
|
|
|
95
98
|
(You can confirm via API once the bootstrap token exists — an empty zone is
|
|
96
99
|
fine.)
|
|
97
100
|
4. **R2** must work (creating a probe bucket is enough to confirm). **Workers
|
|
98
|
-
Paid
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
Paid + Workers for Platforms** are required for per-project workers
|
|
102
|
+
(Phase 2 item 1, D18): once the subscriptions are active, run
|
|
103
|
+
`infra/scripts/create-dispatch-namespace.sh` to create the
|
|
104
|
+
`canopy-projects` dispatch namespace (the `dispatch_namespaces` bindings in
|
|
105
|
+
`apps/api/wrangler.main.jsonc` and `apps/gateway/wrangler.main.jsonc` fail
|
|
106
|
+
to deploy without it), and make sure `CF_API_TOKEN` carries **Workers
|
|
107
|
+
Scripts Write** (re-mint with `create-api-tokens.sh` if it predates this).
|
|
108
|
+
A site without the binding still runs — workers/schedules/secrets simply
|
|
109
|
+
answer 501. **No Advanced Certificate Manager** — `*.canopy.website`
|
|
102
110
|
is a single-level wildcard covered by free Universal SSL. (ACM would only
|
|
103
111
|
be needed if nesting as `*.canopy.<some-other-domain>`.)
|
|
104
112
|
|
|
@@ -283,8 +291,8 @@ If you already configured these for `canopy.website` before the move, skip.
|
|
|
283
291
|
|
|
284
292
|
### 10. Log in, grant yourself admin, then deploy the core projects
|
|
285
293
|
|
|
286
|
-
The
|
|
287
|
-
|
|
294
|
+
The four core projects are ordinary platform projects that dogfood the product.
|
|
295
|
+
Three of their slugs (`dashboard`, `www`, `docs`) are in `RESERVED_SLUGS`
|
|
288
296
|
(`apps/api/src/routes/projects.ts`), so **only an admin can create them** —
|
|
289
297
|
hence the ordering here.
|
|
290
298
|
|
|
@@ -326,6 +334,10 @@ export CANOPY_PROJECT_PROTOCOL="https"
|
|
|
326
334
|
canopy-dev --profile main create "Canopy" --slug www --no-scaffold
|
|
327
335
|
canopy-dev --profile main deploy
|
|
328
336
|
|
|
337
|
+
cd ../docs
|
|
338
|
+
canopy-dev --profile main create "Canopy Docs" --slug docs --no-scaffold
|
|
339
|
+
canopy-dev --profile main deploy
|
|
340
|
+
|
|
329
341
|
cd ../../examples/kitchen-sink
|
|
330
342
|
canopy-dev --profile main create "Kitchen Sink" --slug kitchen-sink --no-scaffold
|
|
331
343
|
# CF target: capabilities.workers = false — strip worker for this deploy
|
|
@@ -371,7 +383,7 @@ same as the old `thanarie.dev` setup). Client-password mode is the only
|
|
|
371
383
|
anonymous path. Smoke through a real browser session:
|
|
372
384
|
|
|
373
385
|
1. `https://dashboard.canopy.website` → OneLogin login works; the project list
|
|
374
|
-
shows all
|
|
386
|
+
shows all four under **Core projects**.
|
|
375
387
|
2. `https://www.canopy.website` → marketing site + `/changelog` render (after
|
|
376
388
|
SSO).
|
|
377
389
|
3. `https://kitchen-sink.canopy.website` → exercise `db`, `blob`, and `ai.chat`
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Migrations & Drizzle
|
|
3
|
+
description: Evolve your own tables with versioned SQL migrations — generated by Drizzle or written by hand — applied safely by canopy deploy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The document API (`canopy.db.collection(…)`) needs no schema and no migrations —
|
|
7
|
+
if it covers your project, skip this page. But developers who define their own
|
|
8
|
+
tables through raw `canopy.db.sql()` eventually need to _change_ those tables,
|
|
9
|
+
and that's what migrations are for: versioned `.sql` files in your repo,
|
|
10
|
+
applied exactly once each, in order, by the platform.
|
|
11
|
+
|
|
12
|
+
[Drizzle](https://orm.drizzle.team) is the recommended way to produce those
|
|
13
|
+
files (a typed schema plus generated SQL), but the mechanism is ORM-agnostic —
|
|
14
|
+
hand-written `.sql` files work identically.
|
|
15
|
+
|
|
16
|
+
## How it works
|
|
17
|
+
|
|
18
|
+
1. Your `canopy.json` names a migrations directory:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"slug": "my-app",
|
|
23
|
+
"dialect": "sqlite",
|
|
24
|
+
"migrations": "./migrations"
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
2. The directory holds ordered `.sql` files (`0000_init.sql`, `0001_add_email.sql`, …),
|
|
29
|
+
applied in filename order.
|
|
30
|
+
|
|
31
|
+
3. The platform journals each applied file in the project database, so a file
|
|
32
|
+
applies **exactly once**, and takes an automatic **pre-migrate snapshot**
|
|
33
|
+
before applying anything — a bad migration is one
|
|
34
|
+
`canopy db restore` away.
|
|
35
|
+
|
|
36
|
+
4. Three surfaces apply them:
|
|
37
|
+
- **`canopy deploy`** — the main path. Pending migrations apply after the new
|
|
38
|
+
bundle is staged and before it goes live, so schema and code ship
|
|
39
|
+
together and the new code never sees the old schema. A failure aborts the
|
|
40
|
+
deploy with the old version still live.
|
|
41
|
+
- **`canopy dev --local`** — the sandbox applies pending migrations on boot,
|
|
42
|
+
so local dev always matches the repo.
|
|
43
|
+
- **`canopy db migrate [--plan]`** — apply (or dry-run) without deploying.
|
|
44
|
+
|
|
45
|
+
Remote-first `canopy dev` never applies migrations — it points at the
|
|
46
|
+
production database, and schema changes belong to deploys. While a schema
|
|
47
|
+
change is in flux, develop against `--local` (add `--pull` for a copy of real
|
|
48
|
+
data).
|
|
49
|
+
|
|
50
|
+
## The rules
|
|
51
|
+
|
|
52
|
+
- **Never edit an applied migration.** The platform hashes each file and
|
|
53
|
+
refuses edited history — write a new migration instead. (This is how every
|
|
54
|
+
mainstream migration tool works.)
|
|
55
|
+
- **Prefer additive changes** (new tables, new nullable columns), and do
|
|
56
|
+
removals in a _later_ release once nothing references the old shape. This
|
|
57
|
+
"expand/contract" habit makes every edge case boring.
|
|
58
|
+
- **Don't touch `_canopy_*` tables.** They're the platform's (the document
|
|
59
|
+
store, the migration journal) — the platform rejects migrations that
|
|
60
|
+
reference them.
|
|
61
|
+
- **There are no down-migrations.** Roll forward with a new migration, or
|
|
62
|
+
restore the automatic pre-migrate snapshot.
|
|
63
|
+
|
|
64
|
+
## Setting up Drizzle
|
|
65
|
+
|
|
66
|
+
Pin your dialect first — a Drizzle schema is written for one SQL flavor. Check
|
|
67
|
+
what your project's site runs on (`await canopy.db.dialect()` or the
|
|
68
|
+
[data guide](https://docs.canopy.website/guides/data)) and set `"dialect"` in `canopy.json`: `"sqlite"`
|
|
69
|
+
on Cloudflare-backed sites, `"postgres"` on GCP-backed ones. That same field
|
|
70
|
+
makes `canopy dev --local` run a matching local engine.
|
|
71
|
+
|
|
72
|
+
Install the tools and describe your schema:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm install drizzle-orm
|
|
76
|
+
npm install -D drizzle-kit
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```ts title="src/schema.ts"
|
|
80
|
+
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
81
|
+
|
|
82
|
+
export const users = sqliteTable("users", {
|
|
83
|
+
id: text("id").primaryKey(),
|
|
84
|
+
email: text("email").notNull(),
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ts title="drizzle.config.ts"
|
|
89
|
+
import { defineConfig } from "drizzle-kit";
|
|
90
|
+
|
|
91
|
+
export default defineConfig({
|
|
92
|
+
dialect: "sqlite", // match canopy.json "dialect" ("postgresql" for postgres)
|
|
93
|
+
schema: "./src/schema.ts",
|
|
94
|
+
out: "./migrations",
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
(On a postgres site, import from `drizzle-orm/pg-core` and set
|
|
99
|
+
`dialect: "postgresql"` — everything else is identical.)
|
|
100
|
+
|
|
101
|
+
Generate a migration whenever the schema changes:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npx drizzle-kit generate
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This is fully local — it diffs your schema files and writes `.sql` into
|
|
108
|
+
`./migrations`. **No database connection is involved, ever.** Then
|
|
109
|
+
`canopy deploy` (or `canopy db migrate`) applies it.
|
|
110
|
+
|
|
111
|
+
## Querying with Drizzle
|
|
112
|
+
|
|
113
|
+
Canopy never hands out connection strings — Drizzle talks to your data through
|
|
114
|
+
the handles Canopy already provides.
|
|
115
|
+
|
|
116
|
+
**In a project worker on a Cloudflare-backed site**, `env.DB` is a real D1
|
|
117
|
+
handle:
|
|
118
|
+
|
|
119
|
+
```ts title="worker/index.ts"
|
|
120
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
121
|
+
import { users } from "../src/schema.ts";
|
|
122
|
+
|
|
123
|
+
export default {
|
|
124
|
+
async fetch(request, env) {
|
|
125
|
+
const db = drizzle(env.DB);
|
|
126
|
+
const all = await db.select().from(users);
|
|
127
|
+
return Response.json(all);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**In the browser (or anywhere the SDK runs)**, wrap `canopy.db.sql()` with
|
|
133
|
+
Drizzle's proxy driver:
|
|
134
|
+
|
|
135
|
+
```ts title="src/db.ts"
|
|
136
|
+
import { drizzle } from "drizzle-orm/sqlite-proxy";
|
|
137
|
+
import { canopy } from "./canopy.ts";
|
|
138
|
+
|
|
139
|
+
export const db = drizzle(async (sql, params, method) => {
|
|
140
|
+
const rows = await canopy.db.sql<Record<string, unknown>>(sql, params);
|
|
141
|
+
const values = rows.map((row) => Object.values(row));
|
|
142
|
+
return { rows: method === "get" ? (values[0] ?? []) : values };
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
(On a postgres site use `drizzle-orm/pg-proxy` the same way; in a GCP project
|
|
147
|
+
worker, `env.DB.query(text, params)` fits `pg-proxy` directly.)
|
|
148
|
+
|
|
149
|
+
## Rehearsing on staging
|
|
150
|
+
|
|
151
|
+
The safest way to try a migration is on the project's staging channel — a
|
|
152
|
+
private copy of the app whose database starts as a clone of live:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
canopy deploy --staging # pending migrations apply to the STAGING database
|
|
156
|
+
# … verify against the migrated copy …
|
|
157
|
+
canopy promote # the same migrations replay against live, then the version flips
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A migration that fails on staging costs nothing; at promote time the live
|
|
161
|
+
database gets its own pre-promote snapshot before anything applies.
|
|
162
|
+
`canopy db migrate --staging` (and `--plan`) target the staging database
|
|
163
|
+
directly, and `canopy staging sync` re-clones live when you want to rehearse
|
|
164
|
+
again on fresh data. See [Staging & promoting](https://docs.canopy.website/guides/staging).
|
|
165
|
+
|
|
166
|
+
## What deliberately doesn't work
|
|
167
|
+
|
|
168
|
+
- **`drizzle-kit push` / `migrate` / `studio` / `introspect`** — these need a
|
|
169
|
+
database connection, which Canopy never exposes. Generating is local;
|
|
170
|
+
applying is Canopy's job. Don't invent a `DATABASE_URL` — there isn't one.
|
|
171
|
+
For browsing data, use the dashboard's data browser or `canopy db pull`.
|
|
172
|
+
- **Drizzle's runtime `migrate()`** — the platform journals migrations in its
|
|
173
|
+
own `_canopy_migrations` table; running Drizzle's applier would double-apply.
|
|
174
|
+
- **Cross-dialect portability.** A project with a Drizzle schema is pinned to
|
|
175
|
+
its dialect. The document API remains the portable path.
|
|
176
|
+
|
|
177
|
+
## Coexistence
|
|
178
|
+
|
|
179
|
+
Your Drizzle tables live alongside the document store in the same project
|
|
180
|
+
database. Snapshots, restore, `canopy db pull`, and the graduation export all
|
|
181
|
+
operate on the whole database, so they cover your tables automatically — and
|
|
182
|
+
since your schema history is in the repo and the journal travels with the
|
|
183
|
+
data, a graduated project takes its full migration story with it.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Porting a Next.js app
|
|
3
|
+
description: How to restructure a Next.js (App Router) project so it runs on Canopy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Canopy can host a project that started life as a Next.js app — but not by
|
|
7
|
+
running Next.js's server. This guide explains why, and walks through the
|
|
8
|
+
restructuring: a **static frontend** plus an optional **backend worker on
|
|
9
|
+
`/api/*`**, with the platform taking over auth, data, files, and AI.
|
|
10
|
+
|
|
11
|
+
If your app barely uses Next's server side, the port is a day or two of work.
|
|
12
|
+
If it leans heavily on server components, server actions, or ISR, read
|
|
13
|
+
[When not to port](#when-not-to-port) first.
|
|
14
|
+
|
|
15
|
+
## Why a lift-and-shift doesn't work
|
|
16
|
+
|
|
17
|
+
Canopy deliberately hosts no arbitrary servers — that's what keeps a thousand
|
|
18
|
+
idle projects free to run and safe to operate. Three consequences matter for
|
|
19
|
+
Next.js:
|
|
20
|
+
|
|
21
|
+
- **Every page is a static file.** The platform serves your deployed build
|
|
22
|
+
from storage; requests to `/`, `/about`, or any other page route never reach
|
|
23
|
+
project code (see [How static files are served](https://docs.canopy.website/guides/static-serving)).
|
|
24
|
+
Server-side rendering, React Server Components resolved per request,
|
|
25
|
+
and Incremental Static Regeneration have nowhere to run.
|
|
26
|
+
- **The backend is one worker, reachable only under `/api/*`.** A project can
|
|
27
|
+
ship a single bundled module exporting `fetch()` (and optionally
|
|
28
|
+
`scheduled()`), and the platform routes `/api/*` requests to it. Server
|
|
29
|
+
actions — which POST to page routes — never reach it.
|
|
30
|
+
- **Auth belongs to the platform.** Every request is already behind OneLogin
|
|
31
|
+
(or a project password) before your code runs. NextAuth, auth middleware,
|
|
32
|
+
and session handling come out of the ported app entirely.
|
|
33
|
+
|
|
34
|
+
So the port is a restructure: the frontend becomes a static build, and
|
|
35
|
+
whatever genuinely needs a server moves into the worker.
|
|
36
|
+
|
|
37
|
+
## What a ported app looks like
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
my-app/
|
|
41
|
+
├── canopy.json ← slug, build command, dist folder, worker entry
|
|
42
|
+
├── src/ ← frontend (static build)
|
|
43
|
+
├── worker/
|
|
44
|
+
│ └── index.ts ← backend: fetch handler for /api/*
|
|
45
|
+
└── dist/ ← build output that gets deployed
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"slug": "my-app",
|
|
51
|
+
"build": "npm run build",
|
|
52
|
+
"dist": "dist",
|
|
53
|
+
"worker": "worker/index.ts"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
One `canopy deploy` builds and ships both; frontend and backend always move
|
|
58
|
+
together as a single version, with rollback.
|
|
59
|
+
|
|
60
|
+
## Step 1 — take inventory
|
|
61
|
+
|
|
62
|
+
List everything in the app that runs on the server. Each item lands in one of
|
|
63
|
+
three buckets:
|
|
64
|
+
|
|
65
|
+
| Next.js feature | Where it goes on Canopy |
|
|
66
|
+
| ----------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
67
|
+
| Pages, layouts, client components | Static build (unchanged in spirit) |
|
|
68
|
+
| Server components fetching data at request time | Client-side fetching, or a worker endpoint |
|
|
69
|
+
| Route handlers (`app/api/*/route.ts`) | Worker endpoints under `/api/*` |
|
|
70
|
+
| Server actions | Explicit worker endpoints, called with `fetch` from the client |
|
|
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) |
|
|
73
|
+
| Database (Prisma, Postgres, …) | `canopy.db` — per-project database, document API + SQL escape hatch |
|
|
74
|
+
| File uploads (S3, …) | `canopy.blob` — per-project file storage with signed downloads |
|
|
75
|
+
| AI SDK / OpenAI client | `canopy.ai.chat()`, or point the Vercel AI SDK at `canopy.ai.endpoint()` |
|
|
76
|
+
| `NEXT_PUBLIC_*` env vars | Build-time env vars in your CI — the platform doesn't inject build config |
|
|
77
|
+
| Server-side env vars / secrets | Project secrets (dashboard Settings) — injected into the worker's `env` |
|
|
78
|
+
| Cron (`vercel.json` crons) | [Schedules](https://docs.canopy.website/platform/schedules) — delivered to the worker's `scheduled()` |
|
|
79
|
+
| ISR / `revalidate` / caching | Dropped — at internal-tool scale, fetch fresh or cache in the client |
|
|
80
|
+
| `next/image` optimization | Plain `<img>`, or optimize at build time |
|
|
81
|
+
| Custom domain | Not available — projects live at `<slug>.canopy.<domain>` behind sign-in |
|
|
82
|
+
| Queues / background jobs | Not supported — restructure around request/response plus schedules |
|
|
83
|
+
|
|
84
|
+
If the last two rows are deal-breakers, stop here: see
|
|
85
|
+
[When not to port](#when-not-to-port).
|
|
86
|
+
|
|
87
|
+
## Step 2 — make the frontend static
|
|
88
|
+
|
|
89
|
+
Two routes, pick one:
|
|
90
|
+
|
|
91
|
+
**Static export (quickest).** Set `output: 'export'` in `next.config.js` and
|
|
92
|
+
fix what breaks. You keep the App Router file layout and components, but lose
|
|
93
|
+
everything request-time: no route handlers, no server actions, no dynamic
|
|
94
|
+
route segments without `generateStaticParams`, no request-time server
|
|
95
|
+
components (they still run — but once, at build time). `next build` writes a
|
|
96
|
+
fully static site to `out/`; point `dist` at it. Set
|
|
97
|
+
`images: { unoptimized: true }` since there's no image optimizer serving
|
|
98
|
+
requests.
|
|
99
|
+
|
|
100
|
+
**Rebuild as a Vite SPA (more idiomatic).** Canopy's templates and tooling
|
|
101
|
+
assume a single-page app: one `index.html`, client-side routing, `canopy dev`
|
|
102
|
+
for local development against real project data. If you're porting an app
|
|
103
|
+
you'll keep evolving on Canopy — especially with an AI agent doing the work —
|
|
104
|
+
converting pages to a React + Vite SPA usually pays for itself quickly, and
|
|
105
|
+
agents handle this conversion well. Page routes become client routes (the
|
|
106
|
+
platform serves `index.html` for unknown paths, so deep links work).
|
|
107
|
+
|
|
108
|
+
Either way, data that pages used to fetch on the server now arrives one of two
|
|
109
|
+
ways: fetched from the client through the SDK (`canopy.db`, `canopy.blob` —
|
|
110
|
+
fine for most internal tools), or from your worker via `/api/*` when it needs
|
|
111
|
+
server-side logic or secrets.
|
|
112
|
+
|
|
113
|
+
## Step 3 — move the backend into a worker
|
|
114
|
+
|
|
115
|
+
Route handlers and server actions become endpoints in one fetch handler.
|
|
116
|
+
[Hono](https://hono.dev) is a natural fit and bundles cleanly:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// worker/index.ts
|
|
120
|
+
import { createCanopy } from "@mediamonks/canopy";
|
|
121
|
+
import { Hono } from "hono";
|
|
122
|
+
|
|
123
|
+
type Env = {
|
|
124
|
+
CANOPY: { fetch: typeof fetch };
|
|
125
|
+
/** Signed-in monk, verified by the platform; null for cron. */
|
|
126
|
+
USER: { sub: string; email: string; name: string } | null;
|
|
127
|
+
/** Project secrets from dashboard Settings appear as plain keys. */
|
|
128
|
+
MY_API_KEY?: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
132
|
+
|
|
133
|
+
// was: app/api/report/route.ts
|
|
134
|
+
app.post("/api/report", async (c) => {
|
|
135
|
+
const user = c.env.USER;
|
|
136
|
+
if (!user) return c.json({ error: "unauthenticated" }, 401);
|
|
137
|
+
|
|
138
|
+
// The SDK works inside the worker through the CANOPY binding —
|
|
139
|
+
// same database and files the frontend sees, caller's identity attached.
|
|
140
|
+
const canopy = createCanopy({ fetch: c.env.CANOPY.fetch.bind(c.env.CANOPY) });
|
|
141
|
+
const rows = await canopy.db.collection("reports").find({ where: { owner: user.email } });
|
|
142
|
+
return c.json({ rows });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// was: a server action
|
|
146
|
+
app.post("/api/submit", async (c) => {
|
|
147
|
+
const body = await c.req.json();
|
|
148
|
+
// …validate, write via canopy.db, call an external API with c.env.MY_API_KEY…
|
|
149
|
+
return c.json({ ok: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
export default {
|
|
153
|
+
fetch: app.fetch.bind(app),
|
|
154
|
+
// was: vercel.json crons — configure in dashboard Settings → Schedules
|
|
155
|
+
async scheduled(event: { name: string }, env: Env) {
|
|
156
|
+
if (event.name === "nightly-report") {
|
|
157
|
+
// …
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Rules of the road for the worker:
|
|
164
|
+
|
|
165
|
+
- It's **one bundled file** — dependencies are bundled in at deploy time, so
|
|
166
|
+
stick to packages that don't need Node built-ins or native modules.
|
|
167
|
+
- **No filesystem, no long-lived state.** Persist through `canopy.db` and
|
|
168
|
+
`canopy.blob`.
|
|
169
|
+
- **Outbound requests are controlled.** Calls to external APIs pass through a
|
|
170
|
+
platform-managed egress allowlist — if your project needs to call an
|
|
171
|
+
external host, ask the platform team to allow it.
|
|
172
|
+
- Frontend calls are plain `fetch("/api/…")` — same origin, the platform
|
|
173
|
+
session cookie rides along automatically.
|
|
174
|
+
|
|
175
|
+
## Step 4 — swap the data layer
|
|
176
|
+
|
|
177
|
+
Replace the ORM and connection strings with the project's own database:
|
|
178
|
+
|
|
179
|
+
- The paved road is the [document API](https://docs.canopy.website/sdk/database):
|
|
180
|
+
`canopy.db.collection("todos").insert(…)` / `find(…)` — schemaless JSON, no
|
|
181
|
+
migrations. For most internal tools this replaces Prisma models outright.
|
|
182
|
+
- Relational shapes port via the SQL escape hatch, `canopy.db.sql()`, against
|
|
183
|
+
the same per-project database.
|
|
184
|
+
- Existing data can be imported once: export from your old database, then load
|
|
185
|
+
it through the worker or the [SQL console](https://docs.canopy.website/guides/data) in the dashboard.
|
|
186
|
+
|
|
187
|
+
Files move to [`canopy.blob`](https://docs.canopy.website/sdk/files) (uploads, listings, short-lived
|
|
188
|
+
signed download URLs — nothing is ever publicly readable). AI calls move to
|
|
189
|
+
[`canopy.ai`](https://docs.canopy.website/sdk/ai); if the app uses the Vercel AI SDK, point it at
|
|
190
|
+
`canopy.ai.endpoint()` and it keeps working, now metered and budget-capped.
|
|
191
|
+
|
|
192
|
+
## Step 5 — delete the auth
|
|
193
|
+
|
|
194
|
+
This is the satisfying part. OneLogin fronts every request before your code
|
|
195
|
+
runs, so the ported app removes sign-in pages, session handling, and auth
|
|
196
|
+
middleware entirely:
|
|
197
|
+
|
|
198
|
+
- **Client:** `canopy.auth.user()` returns the signed-in monk.
|
|
199
|
+
- **Worker:** `env.USER` carries the verified identity (no round-trip).
|
|
200
|
+
- Client-facing access without OneLogin is a per-project
|
|
201
|
+
[password](https://docs.canopy.website/platform/client-access), configured in Settings — not code.
|
|
202
|
+
|
|
203
|
+
Authorization (who may do what _inside_ the app) stays yours, but it now keys
|
|
204
|
+
off a verified identity instead of a homegrown session.
|
|
205
|
+
|
|
206
|
+
## When not to port
|
|
207
|
+
|
|
208
|
+
Be honest about fit before starting. Dedicated hosting is the better home
|
|
209
|
+
when the app:
|
|
210
|
+
|
|
211
|
+
- depends on **per-request SSR** for real reasons — SEO on public pages,
|
|
212
|
+
personalization at the edge, streaming server components as a core UX;
|
|
213
|
+
- is built around **server actions and RSC data flow** so thoroughly that the
|
|
214
|
+
restructure is a rewrite;
|
|
215
|
+
- needs a **custom domain or anonymous public access** — nothing on Canopy is
|
|
216
|
+
reachable without OneLogin or a project password;
|
|
217
|
+
- needs **queues, background jobs, or long-running work**.
|
|
218
|
+
|
|
219
|
+
That's not a workaround waiting to happen — it's the platform's design. Canopy
|
|
220
|
+
optimizes for small internal tools; an app that genuinely needs a server is
|
|
221
|
+
past greenhouse size (see [Use cases](https://docs.canopy.website/overview/use-cases)).
|
|
222
|
+
|
|
223
|
+
## Checklist
|
|
224
|
+
|
|
225
|
+
- [ ] Inventory server-side surface (RSC fetching, actions, route handlers, middleware, crons)
|
|
226
|
+
- [ ] Frontend: `output: 'export'` or Vite SPA; fix dynamic segments and `next/image`
|
|
227
|
+
- [ ] Worker: route handlers + server actions → `/api/*` endpoints in `worker/index.ts`
|
|
228
|
+
- [ ] Data: ORM → `canopy.db`; uploads → `canopy.blob`; AI clients → `canopy.ai`
|
|
229
|
+
- [ ] Delete NextAuth/middleware; read identity from `canopy.auth.user()` / `env.USER`
|
|
230
|
+
- [ ] Secrets into dashboard Settings; `NEXT_PUBLIC_*` into your CI build
|
|
231
|
+
- [ ] Crons into Settings → Schedules, handled in `scheduled()`
|
|
232
|
+
- [ ] `canopy.json` with `build`, `dist`, `worker` — then `canopy deploy`
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: AI
|
|
3
|
+
description: Metered LLM access through the project's budget-capped gateway key — chat, streaming, and an OpenAI-compatible endpoint.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`canopy.ai` gives your project LLM access without any API keys in your code.
|
|
7
|
+
Every request authenticates as the project and is metered against a hard
|
|
8
|
+
per-project daily budget — spend and limits are covered in
|
|
9
|
+
[AI access & usage](https://docs.canopy.website/platform/ai-usage).
|
|
10
|
+
|
|
11
|
+
## ai.chat(options)
|
|
12
|
+
|
|
13
|
+
One-shot chat completion. Takes a `ChatOptions` object, resolves to a
|
|
14
|
+
`ChatResponse`:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
const res = await canopy.ai.chat({
|
|
18
|
+
messages: [
|
|
19
|
+
{ role: "system", content: "You are terse." },
|
|
20
|
+
{ role: "user", content: "Say hi in five words." },
|
|
21
|
+
],
|
|
22
|
+
model: "anthropic/claude-haiku-4-5", // optional
|
|
23
|
+
temperature: 0.7, // optional, 0–2
|
|
24
|
+
maxTokens: 256, // optional
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
res.message.content; // the assistant's reply
|
|
28
|
+
res.model; // the model that actually answered
|
|
29
|
+
res.usage; // { inputTokens: number, outputTokens: number }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**`ChatOptions`:**
|
|
33
|
+
|
|
34
|
+
- `messages` — required, non-empty array of `{ role, content }`. `role` is
|
|
35
|
+
`"system" | "user" | "assistant"`; `content` is a non-empty string.
|
|
36
|
+
- `model` — optional `"{provider}/{model}"` id. Omit it and the platform's
|
|
37
|
+
default model answers. Ask for a model whose provider isn't enabled and the
|
|
38
|
+
call fails with a message listing what _is_ available; `ai.endpoint()`
|
|
39
|
+
returns the full curated list.
|
|
40
|
+
- `temperature` — optional number between 0 and 2.
|
|
41
|
+
- `maxTokens` — optional integer, at least 1.
|
|
42
|
+
|
|
43
|
+
**`ChatResponse`:** `{ message: ChatMessage, model: string, usage: { inputTokens, outputTokens } }`.
|
|
44
|
+
`message.role` is `"assistant"`.
|
|
45
|
+
|
|
46
|
+
## ai.chatStream(options)
|
|
47
|
+
|
|
48
|
+
Same `ChatOptions`, but resolves to a `ReadableStream<Uint8Array>` of
|
|
49
|
+
server-sent events — the raw SSE bytes of the completion, for chat-style UIs
|
|
50
|
+
that render tokens as they arrive:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const stream = await canopy.ai.chatStream({
|
|
54
|
+
messages: [{ role: "user", content: "Count to five." }],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
let text = "";
|
|
59
|
+
for await (const chunk of stream) {
|
|
60
|
+
text += decoder.decode(chunk, { stream: true });
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each SSE event is a `data: {...}` line carrying an OpenAI-style completion
|
|
65
|
+
chunk. If you'd rather not parse SSE yourself, use `ai.endpoint()` below and
|
|
66
|
+
let an ecosystem client handle streaming for you.
|
|
67
|
+
|
|
68
|
+
## ai.endpoint()
|
|
69
|
+
|
|
70
|
+
Returns the project's OpenAI-compatible endpoint info:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const { baseUrl, models } = await canopy.ai.endpoint();
|
|
74
|
+
// baseUrl — an OpenAI-compatible base URL
|
|
75
|
+
// models — the curated model list, "{provider}/{model}" ids; models[0] is the default
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Point any OpenAI-compatible client — the Vercel AI SDK, an OpenAI SDK, a
|
|
79
|
+
tool-calling loop — at `baseUrl`. It uses the same project key and counts
|
|
80
|
+
against the same budget as `ai.chat()`:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
84
|
+
|
|
85
|
+
const { baseUrl, models } = await canopy.ai.endpoint();
|
|
86
|
+
const openai = createOpenAI({ baseURL: baseUrl, apiKey: "unused" });
|
|
87
|
+
const model = openai(models[0]);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Errors
|
|
91
|
+
|
|
92
|
+
Failed calls throw `CanopyError` with the HTTP `status` and the response body
|
|
93
|
+
as the `message`. Two worth handling:
|
|
94
|
+
|
|
95
|
+
- **Budget exhausted** — status `429`, body contains
|
|
96
|
+
`"ai_budget_exceeded"`. The project hit its daily AI cap; AI calls resume
|
|
97
|
+
the next day (or after a maintainer raises the budget). Everything else in
|
|
98
|
+
your app keeps working.
|
|
99
|
+
- **Invalid request** — status `400`, body contains `"invalid_chat"` and a
|
|
100
|
+
detail explaining which option was rejected.
|
|
101
|
+
|
|
102
|
+
> **Note:** Budgets are enforced before every call, so a runaway loop becomes a capped bill — see [AI access
|
|
103
|
+
> & usage](https://docs.canopy.website/platform/ai-usage) for how spend is tracked and displayed.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Auth
|
|
3
|
+
description: Read who the platform signed in — you never build a login screen.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Authentication happens before your code runs: the platform gates every request
|
|
7
|
+
to your project, so by the time a page loads or a worker route fires, there is
|
|
8
|
+
always a signed-in user. You never build a login screen, store passwords, or
|
|
9
|
+
check tokens — see [Members and access](https://docs.canopy.website/platform/members-and-access) for
|
|
10
|
+
who gets in and how. `canopy.auth` only reports who the platform let in.
|
|
11
|
+
|
|
12
|
+
## auth.user()
|
|
13
|
+
|
|
14
|
+
Returns the authenticated user for the current session as a `CanopyUser`:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
const user = await canopy.auth.user();
|
|
18
|
+
// { id: "…", email: "ada@monks.com", name: "Ada Lovelace", kind: "monk" }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Field | Type | Meaning |
|
|
22
|
+
| ------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `id` | `string` | Stable subject from OneLogin — safe to key user data on. |
|
|
24
|
+
| `email` | `string` | The user's email address. |
|
|
25
|
+
| `name` | `string` | Display name. |
|
|
26
|
+
| `kind` | `"monk" \| "external"` | `"monk"` = signed in through OneLogin; `"external"` = a visitor who got in with the project's client password instead. |
|
|
27
|
+
|
|
28
|
+
`kind: "external"` sessions come from client-password access — clients viewing
|
|
29
|
+
a shared project without a Monks account. Branch on it when externals should
|
|
30
|
+
see a trimmed-down experience:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const user = await canopy.auth.user();
|
|
34
|
+
|
|
35
|
+
document.querySelector("h1")!.textContent = `Welcome, ${user.name}`;
|
|
36
|
+
|
|
37
|
+
if (user.kind === "external") {
|
|
38
|
+
// Client viewer: hide internal tooling, show the presentation view.
|
|
39
|
+
document.querySelector("#admin-panel")?.remove();
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> **Warning:** Branching on `kind` in the browser is presentation, not security — anything your page can fetch, a
|
|
44
|
+
> visitor can fetch. The platform's access rules are what actually protect data.
|
|
45
|
+
|
|
46
|
+
## auth.logout()
|
|
47
|
+
|
|
48
|
+
Ends the platform session for the current visitor. Their next request hits the
|
|
49
|
+
login flow again:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
await canopy.auth.logout();
|
|
53
|
+
location.reload(); // next load re-authenticates
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This signs out the browser session only — it does not touch your `canopy login`
|
|
57
|
+
on the CLI.
|