@mediamonks/canopy-cli 0.7.20 → 0.7.22
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/{dev-DtEwiNYi.mjs → dev-CNqx_53I.mjs} +3 -3
- package/dist/dev-D7v7WK9O.mjs +2 -0
- package/dist/{dev-worker-IWnHvQ1f.mjs → dev-worker-Bij9OUMk.mjs} +1 -1
- package/dist/docs/cli-dev.md +1 -1
- package/dist/docs/cli-logs.md +1 -1
- package/dist/docs/data.md +2 -2
- package/dist/docs/dev.md +1 -1
- package/dist/docs/developers-architecture.md +26 -18
- package/dist/docs/index.json +21 -11
- package/dist/docs/migrations.md +17 -14
- package/dist/docs/platform-assistant.md +50 -0
- package/dist/docs/platform-egress.md +2 -2
- package/dist/docs/platform-feedback.md +3 -0
- package/dist/docs/{notifications.md → platform-notifications.md} +6 -2
- package/dist/docs/platform-project-data.md +3 -3
- package/dist/docs/platform-schedules.md +1 -1
- package/dist/docs/platform-sites-and-profiles.md +1 -1
- package/dist/docs/platform-slack.md +85 -0
- package/dist/docs/sdk-files.md +11 -1
- package/dist/docs/sdk-slack.md +132 -0
- package/dist/docs/static-serving.md +58 -300
- package/dist/{docs-C2D7__Iz.mjs → docs-c0CdeBxh.mjs} +1 -1
- package/dist/{feedback-BvkCjS-g.mjs → feedback-QhD8k6Ph.mjs} +8 -3
- package/dist/index.mjs +10 -10
- package/dist/{local-CKRZur_5.mjs → local-CsSP4Vpt.mjs} +2 -2
- package/dist/login-BrEDKrb3.mjs +285 -0
- package/dist/{package-B-aXVcQu.mjs → package-BuwV_mje.mjs} +1 -1
- package/dist/{skill-CqiSkac0.mjs → skill-BvoYCIKk.mjs} +1 -1
- package/dist/{src-Bu4Ze32I.mjs → src-BKKAEkbH.mjs} +25 -2
- package/dist/templates/spa/package.json +1 -1
- package/dist/templates/worker/AGENTS.md +3 -0
- package/dist/templates/worker/package.json +1 -1
- package/dist/templates/worker/worker/index.ts +20 -3
- package/dist/{upgrade-CpUCVTrN.mjs → upgrade-0lmdjvom.mjs} +1 -1
- package/dist/{usage-CRpVLLi6.mjs → usage-C1-cvS2-.mjs} +13 -1
- package/package.json +2 -2
- package/dist/dev-DjkK-50R.mjs +0 -2
- package/dist/docs/infrastructure.md +0 -406
- package/dist/login-D-Ac_kFW.mjs +0 -285
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Slack
|
|
3
|
+
description: Handle your bot's events and slash commands in your worker, and post to Slack as the bot — no tokens in project code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Once your project has a Slack app ([set up on the Services
|
|
7
|
+
tab](https://docs.canopy.website/platform/slack)), the integration is two halves: inbound events arrive
|
|
8
|
+
at a `slack()` export in your worker, and outbound calls go through
|
|
9
|
+
`canopy.slack`. The bot token and signing secret stay with the platform —
|
|
10
|
+
Slack's signatures are verified before anything reaches your code.
|
|
11
|
+
|
|
12
|
+
## The `slack()` handler
|
|
13
|
+
|
|
14
|
+
Export it next to `fetch` and `scheduled`:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
export default {
|
|
18
|
+
fetch(request, env) {
|
|
19
|
+
/* … */
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async slack(event, env) {
|
|
23
|
+
if (event.kind === "command") {
|
|
24
|
+
return { response_type: "in_channel", text: `Running ${event.payload.command}…` };
|
|
25
|
+
}
|
|
26
|
+
// kind === "event": an @-mention, a DM, …
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**The event object:**
|
|
32
|
+
|
|
33
|
+
- `kind` — `"event"` (Events API: `app_mention`, `message`, …) or
|
|
34
|
+
`"command"` (a slash command).
|
|
35
|
+
- `payload` — Slack's payload, verbatim. For events that's the full
|
|
36
|
+
callback (the interesting part is `payload.event`); for commands it's the
|
|
37
|
+
command form fields (`command`, `text`, `channel_id`, `user_id`, …).
|
|
38
|
+
- `responseUrl` — commands only: where a late reply can still be sent (see
|
|
39
|
+
below).
|
|
40
|
+
- `user` — who triggered this, as Slack tells it: `{ id, name? }`. Present
|
|
41
|
+
whenever Slack names a person.
|
|
42
|
+
- `teamId`, `appId` — which workspace and app this came from.
|
|
43
|
+
- `retry` — set when Slack is redelivering. Delivery is at-least-once, so
|
|
44
|
+
handlers that create things should tolerate seeing an event twice.
|
|
45
|
+
|
|
46
|
+
## Who is talking to the bot
|
|
47
|
+
|
|
48
|
+
Two identities, at two trust levels:
|
|
49
|
+
|
|
50
|
+
- **`env.USER`** is set when the bot lives in the Monks workspace and the
|
|
51
|
+
sender has a Canopy account: the same `{ sub, email, name }` your fetch
|
|
52
|
+
routes see for a signed-in visitor, so user-specific logic works the same
|
|
53
|
+
way. The mapping rides the corporate directory — Slack accounts and Canopy
|
|
54
|
+
sign-ins are the same people there.
|
|
55
|
+
- In any other workspace — a client's, say — `env.USER` is `null` and
|
|
56
|
+
`event.user` is all you get: Slack's own word for who typed, nothing
|
|
57
|
+
Canopy has verified. Fine for personalising a reply; don't hang
|
|
58
|
+
authorization on it.
|
|
59
|
+
|
|
60
|
+
Either way, payload text is written by whoever typed it in Slack — treat it
|
|
61
|
+
as untrusted input. And SDK calls from the handler still run as the project
|
|
62
|
+
itself (`canopy.auth.user()` reports kind `worker`), regardless of
|
|
63
|
+
`env.USER`.
|
|
64
|
+
|
|
65
|
+
## Answering a slash command
|
|
66
|
+
|
|
67
|
+
Slack gives a command three seconds. Return quickly and Canopy relays your
|
|
68
|
+
answer as the command's response:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
async slack(event, env) {
|
|
72
|
+
if (event.kind === "command") {
|
|
73
|
+
return { response_type: "ephemeral", text: "Done." };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Return a Slack message object, or a plain string (sent as ephemeral text).
|
|
79
|
+
Return nothing and the command completes silently.
|
|
80
|
+
|
|
81
|
+
If your handler needs longer — it queries something slow, calls a model —
|
|
82
|
+
the command is acknowledged for you, and you answer afterwards through the
|
|
83
|
+
`responseUrl`, which stays valid for 30 minutes:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
if (event.kind === "command") {
|
|
87
|
+
const answer = await somethingSlow();
|
|
88
|
+
await canopy.slack.respond(event.responseUrl!, {
|
|
89
|
+
response_type: "in_channel",
|
|
90
|
+
text: answer,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Events (`kind: "event"`) have no response channel — react by calling out,
|
|
96
|
+
usually `chat.postMessage` back into the conversation the event names.
|
|
97
|
+
|
|
98
|
+
## slack.api(method, args)
|
|
99
|
+
|
|
100
|
+
Any [Slack Web API method](https://docs.slack.dev/reference/methods), called
|
|
101
|
+
as your project's bot:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
const result = await canopy.slack.api("chat.postMessage", {
|
|
105
|
+
channel: "C0123456789",
|
|
106
|
+
text: "Deploy finished.",
|
|
107
|
+
});
|
|
108
|
+
if (!result.ok) console.warn("slack said no:", result.error);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The response is Slack's own, `ok: false` included — check it for
|
|
112
|
+
method-level outcomes like `channel_not_found` or `not_in_channel` (the bot
|
|
113
|
+
can only post to channels it's been invited to, and to people's DMs).
|
|
114
|
+
Workspace-management method families (`admin.*`, `tooling.*`, `apps.*`,
|
|
115
|
+
`oauth.*`) are refused before Slack is ever contacted; everything else is
|
|
116
|
+
bounded by the permissions the app was installed with.
|
|
117
|
+
|
|
118
|
+
Backend and `canopy dev` only — pages can't call it, so a visitor can never
|
|
119
|
+
act as your bot.
|
|
120
|
+
|
|
121
|
+
## slack.respond(responseUrl, message)
|
|
122
|
+
|
|
123
|
+
The late reply to a slash command, described above. `message` is a Slack
|
|
124
|
+
message object or a plain string.
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
Refusals arrive as thrown errors with stable codes: `slack_not_configured`
|
|
129
|
+
(no app on this project), `slack_not_installed` (created but not installed
|
|
130
|
+
to a workspace), `slack_disabled` (switched off on the Services tab), and
|
|
131
|
+
`slack_method_not_allowed`. A network-level failure reaching Slack is
|
|
132
|
+
`slack_api_error`.
|
|
@@ -1,323 +1,81 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: How static files are served
|
|
3
|
-
description:
|
|
3
|
+
description: Why every page is a file, and what that means for routing, caching, and deploys.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
Everything a visitor sees is a **file from your last deploy**. Canopy stores
|
|
7
|
+
the build you shipped and serves it directly — a request for `/`, `/about`, or
|
|
8
|
+
`/reports/42` never runs your code. If your project has a backend, requests
|
|
9
|
+
under `/api/*` go to it; everything else is a file.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
requests dispatch to it — Workers for Platforms on Cloudflare, Cloud Run on
|
|
13
|
-
GCP; everything else is still static serving.)
|
|
11
|
+
That one fact explains most of how the platform behaves, so it's worth
|
|
12
|
+
understanding before you fight it.
|
|
14
13
|
|
|
15
|
-
|
|
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.
|
|
14
|
+
## Nothing is served to a stranger
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
Every request passes the sign-in wall before a single byte of your project is
|
|
17
|
+
read. A visitor arrives with a company login, or — if you've turned on
|
|
18
|
+
[client access](https://docs.canopy.website/platform/client-access) — the project's password. There is no
|
|
19
|
+
public URL, no "unlisted" link, and no way to opt a single file out. A project
|
|
20
|
+
is readable by any signed-in colleague unless you set it to collaborators only,
|
|
21
|
+
in [members and access](https://docs.canopy.website/platform/members-and-access).
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
Workers and one R2 bucket:
|
|
23
|
+
## Deep links work — routing is yours
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
```
|
|
25
|
+
When a path doesn't match a file, Canopy serves your `index.html` instead of a 404. That's what makes client-side routing work: someone can bookmark
|
|
26
|
+
`/reports/42`, reload it, and your app boots and handles the route.
|
|
42
27
|
|
|
43
|
-
|
|
28
|
+
The trade-off is that a **typo'd asset also gets `index.html`** rather than an
|
|
29
|
+
error. If a script or image comes back as your homepage HTML, you're looking at
|
|
30
|
+
a wrong path, not a broken server.
|
|
44
31
|
|
|
45
|
-
|
|
46
|
-
for every `*.canopy.<domain>` request.
|
|
32
|
+
## Caching, and why a new deploy shows up immediately
|
|
47
33
|
|
|
48
|
-
|
|
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:
|
|
34
|
+
Two different rules, picked automatically:
|
|
66
35
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
```
|
|
36
|
+
- **HTML always revalidates.** Browsers re-check it on every visit, so the
|
|
37
|
+
moment you deploy, people get the new build — no cache-busting dance, no
|
|
38
|
+
waiting.
|
|
39
|
+
- **Assets cache for an hour.** JS, CSS, images, and fonts sit in the visitor's
|
|
40
|
+
browser, which is what keeps repeat visits fast.
|
|
77
41
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
42
|
+
This is the right shape for a normal build tool that puts a content hash in
|
|
43
|
+
asset filenames (Vite does this by default) — the HTML that names them is
|
|
44
|
+
always fresh, so a new deploy points at new files.
|
|
81
45
|
|
|
82
|
-
|
|
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.
|
|
46
|
+
## Deploys and rollbacks are instant
|
|
86
47
|
|
|
87
|
-
|
|
48
|
+
Shipping a version doesn't copy or rebuild anything at serve time — the
|
|
49
|
+
platform just points at the new one. A [rollback](https://docs.canopy.website/platform/deploys-and-rollbacks)
|
|
50
|
+
is the same move in reverse, which is why it takes effect immediately rather
|
|
51
|
+
than propagating for minutes.
|
|
88
52
|
|
|
89
|
-
|
|
90
|
-
|
|
53
|
+
You can also preview an **older frontend against current data** by putting the
|
|
54
|
+
version in the hostname — `v12-dot-<project>` serves build 12 while auth and all
|
|
55
|
+
data still resolve to the real, current project. Handy for "when did this break?"
|
|
56
|
+
without deploying anything.
|
|
91
57
|
|
|
92
|
-
|
|
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.
|
|
58
|
+
## What a deploy may contain
|
|
118
59
|
|
|
119
|
-
|
|
60
|
+
- Up to **500 files** and **25 MB** in total.
|
|
61
|
+
- An `index.html` — a bundle without one is rejected.
|
|
62
|
+
- Each file's type is fixed at deploy time from its extension. Unknown
|
|
63
|
+
extensions are sent as a download rather than run as code.
|
|
120
64
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
65
|
+
If you're over the file count, it's almost always un-bundled assets that a build
|
|
66
|
+
step would combine; over the size limit, it's usually images or fonts that
|
|
67
|
+
belong in [file storage](https://docs.canopy.website/sdk/files) instead of the bundle.
|
|
130
68
|
|
|
131
|
-
|
|
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)).
|
|
69
|
+
## What this rules out
|
|
147
70
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
71
|
+
- **Server-side rendering.** No per-request HTML, no server components
|
|
72
|
+
resolved at request time, no incremental regeneration. Render in the browser,
|
|
73
|
+
or move the work into a backend route under `/api/*`.
|
|
74
|
+
- **Redirects and rewrites as configuration.** There's no routing config file;
|
|
75
|
+
handle it in your app, or in your backend.
|
|
76
|
+
- **Serving from anywhere but your build output.** If you want a file to be
|
|
77
|
+
reachable, deploy it, or put it in [file storage](https://docs.canopy.website/sdk/files) and hand out a
|
|
78
|
+
signed link.
|
|
152
79
|
|
|
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` |
|
|
80
|
+
Porting an app that assumed a server? [Porting a Next.js app](https://docs.canopy.website/guides/porting-nextjs)
|
|
81
|
+
walks through the restructure.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
1
|
+
import { t as version } from "./package-BuwV_mje.mjs";
|
|
2
2
|
import { n as siteConfig } from "./site-BFaWfSVu.mjs";
|
|
3
3
|
import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
|
|
4
4
|
import { r as reportErrors } from "./ui--atbZ-TM.mjs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as version } from "./package-
|
|
1
|
+
import { t as version } from "./package-BuwV_mje.mjs";
|
|
2
2
|
import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-BFaWfSVu.mjs";
|
|
3
3
|
import { c as apiDownload, l as apiRequest, r as reportErrors, t as formatDateTime, u as apiUpload } from "./ui--atbZ-TM.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
@@ -89,6 +89,10 @@ function firstLine(body) {
|
|
|
89
89
|
const line = body.split("\n")[0] ?? "";
|
|
90
90
|
return line.length > 72 ? `${line.slice(0, 71)}…` : line;
|
|
91
91
|
}
|
|
92
|
+
/** List label: the AI-written title when there is one, else the first line. */
|
|
93
|
+
function itemLabel(item) {
|
|
94
|
+
return firstLine(item.title || item.body);
|
|
95
|
+
}
|
|
92
96
|
function printItems(items) {
|
|
93
97
|
if (items.length === 0) {
|
|
94
98
|
console.log("No matching feedback items.");
|
|
@@ -97,7 +101,7 @@ function printItems(items) {
|
|
|
97
101
|
for (const item of items) {
|
|
98
102
|
const comments = item.comment_count ? ` 💬${item.comment_count}` : "";
|
|
99
103
|
const shots = item.attachment_count ? ` 📎${item.attachment_count}` : "";
|
|
100
|
-
console.log(`${item.id} ${item.status} ${item.kind} ${
|
|
104
|
+
console.log(`${item.id} ${item.status} ${item.kind} ${itemLabel(item)}${comments}${shots}`);
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
const listCommand = defineCommand({
|
|
@@ -153,6 +157,7 @@ const showCommand = defineCommand({
|
|
|
153
157
|
return;
|
|
154
158
|
}
|
|
155
159
|
const { item, comments } = detail;
|
|
160
|
+
if (item.title) console.log(item.title);
|
|
156
161
|
console.log(`${item.kind} · ${item.status} · via ${item.source} · ${formatDateTime(item.created_at)}`);
|
|
157
162
|
console.log(`reported by ${item.reporter_name || item.reporter_email || "unknown"}` + (item.deployment_id ? ` on ${item.deployment_id}` : "") + (item.channel === "staging" ? " (staging)" : ""));
|
|
158
163
|
console.log(`\n${item.body}\n`);
|
|
@@ -276,7 +281,7 @@ const mineCommand = defineCommand({
|
|
|
276
281
|
console.log("You haven't filed any reports.");
|
|
277
282
|
return;
|
|
278
283
|
}
|
|
279
|
-
for (const item of items) console.log(`${item.id} ${item.status} ${item.kind} [${item.project_slug}] ${
|
|
284
|
+
for (const item of items) console.log(`${item.id} ${item.status} ${item.kind} [${item.project_slug}] ${itemLabel(item)}`);
|
|
280
285
|
})
|
|
281
286
|
});
|
|
282
287
|
const reportCommand = defineCommand({
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as version } from "./package-
|
|
2
|
+
import { t as version } from "./package-BuwV_mje.mjs";
|
|
3
3
|
import { r as siteHeaderLine, t as setInvocationProfile } from "./site-BFaWfSVu.mjs";
|
|
4
4
|
import { a as notifyOfUpdate, o as runRefresh } from "./version-check-CcsA1QjI.mjs";
|
|
5
5
|
import { defineCommand, runMain } from "citty";
|
|
@@ -60,14 +60,14 @@ const main = defineCommand({
|
|
|
60
60
|
description: "canopy — create and deploy vibecoded projects"
|
|
61
61
|
},
|
|
62
62
|
subCommands: {
|
|
63
|
-
login: () => import("./login-
|
|
64
|
-
whoami: () => import("./login-
|
|
65
|
-
logout: () => import("./login-
|
|
63
|
+
login: () => import("./login-BrEDKrb3.mjs").then((m) => m.loginCommand),
|
|
64
|
+
whoami: () => import("./login-BrEDKrb3.mjs").then((m) => m.whoamiCommand),
|
|
65
|
+
logout: () => import("./login-BrEDKrb3.mjs").then((m) => m.logoutCommand),
|
|
66
66
|
create: () => import("./projects-1r5387ui.mjs").then((m) => m.createCommand),
|
|
67
67
|
init: () => import("./projects-1r5387ui.mjs").then((m) => m.initCommand),
|
|
68
68
|
projects: () => import("./projects-1r5387ui.mjs").then((m) => m.projectsCommand),
|
|
69
69
|
delete: () => import("./projects-1r5387ui.mjs").then((m) => m.deleteCommand),
|
|
70
|
-
dev: () => import("./dev-
|
|
70
|
+
dev: () => import("./dev-D7v7WK9O.mjs").then((m) => m.devCommand),
|
|
71
71
|
deploy: () => import("./deploy-DCggsLD7.mjs").then((m) => m.deployCommand),
|
|
72
72
|
promote: () => import("./deploy-DCggsLD7.mjs").then((m) => m.promoteCommand),
|
|
73
73
|
staging: () => import("./staging-CdpV2YHB.mjs").then((m) => m.stagingCommand),
|
|
@@ -76,13 +76,13 @@ const main = defineCommand({
|
|
|
76
76
|
token: () => import("./token-C8Bmzwfl.mjs").then((m) => m.tokenCommand),
|
|
77
77
|
egress: () => import("./egress-BhbQfjjV.mjs").then((m) => m.egressCommand),
|
|
78
78
|
schedule: () => import("./schedule-DK_Cv6Xf.mjs").then((m) => m.scheduleCommand),
|
|
79
|
-
feedback: () => import("./feedback-
|
|
79
|
+
feedback: () => import("./feedback-QhD8k6Ph.mjs").then((m) => m.feedbackCommand),
|
|
80
80
|
db: () => import("./db-TaSLpQZ5.mjs").then((m) => m.dbCommand),
|
|
81
|
-
usage: () => import("./usage-
|
|
81
|
+
usage: () => import("./usage-C1-cvS2-.mjs").then((m) => m.usageCommand),
|
|
82
82
|
open: () => import("./open-D7f1wNn3.mjs").then((m) => m.openCommand),
|
|
83
|
-
docs: () => import("./docs-
|
|
84
|
-
skill: () => import("./skill-
|
|
85
|
-
upgrade: () => import("./upgrade-
|
|
83
|
+
docs: () => import("./docs-c0CdeBxh.mjs").then((m) => m.docsCommand),
|
|
84
|
+
skill: () => import("./skill-BvoYCIKk.mjs").then((m) => m.skillCommand),
|
|
85
|
+
upgrade: () => import("./upgrade-0lmdjvom.mjs").then((m) => m.upgradeCommand),
|
|
86
86
|
logs: () => import("./logs-Beqz--Df.mjs").then((m) => m.logsCommand)
|
|
87
87
|
}
|
|
88
88
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { s as apiDataRequest } from "./ui--atbZ-TM.mjs";
|
|
2
|
-
import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-
|
|
3
|
-
import { n as rewriteApiPath } from "./dev-
|
|
2
|
+
import { n as META_COLUMNS, r as blobRouter, t as dbRouter } from "./src-BKKAEkbH.mjs";
|
|
3
|
+
import { n as rewriteApiPath } from "./dev-CNqx_53I.mjs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join, relative, sep } from "node:path";
|