@mediamonks/canopy-cli 0.7.22 → 0.7.24
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-jmYaum8F.mjs +251 -0
- package/dist/{deploy-DCggsLD7.mjs → deploy-CYKiBnrg.mjs} +3 -3
- package/dist/dev-C5BmSJo1.mjs +2 -0
- package/dist/{dev-CNqx_53I.mjs → dev-DVUEJ80c.mjs} +6 -6
- package/dist/{dev-worker-Bij9OUMk.mjs → dev-worker-CWU6XFe_.mjs} +35 -1
- package/dist/docs/ci.md +8 -1
- package/dist/docs/cli-egress.md +2 -1
- package/dist/docs/cli-login.md +2 -0
- package/dist/docs/cli-logout.md +9 -6
- package/dist/docs/cli-token.md +4 -2
- package/dist/docs/index.json +7 -2
- package/dist/docs/platform-ai-usage.md +36 -10
- package/dist/docs/platform-client-access.md +14 -2
- package/dist/docs/platform-egress.md +5 -3
- package/dist/docs/platform-members-and-access.md +29 -6
- package/dist/docs/platform-project-data.md +1 -0
- package/dist/docs/platform-security-headers.md +53 -0
- package/dist/docs/platform-slack.md +3 -1
- package/dist/docs/sdk-ai.md +102 -11
- package/dist/docs/sdk-database.md +30 -0
- package/dist/docs/sdk-files.md +6 -0
- package/dist/{docs-c0CdeBxh.mjs → docs-BLWio_65.mjs} +3 -3
- package/dist/{egress-BhbQfjjV.mjs → egress-BjN-7vil.mjs} +6 -3
- package/dist/{feedback-QhD8k6Ph.mjs → feedback-Bnmi1Ukv.mjs} +3 -3
- package/dist/index.mjs +26 -26
- package/dist/{local-CsSP4Vpt.mjs → local-CNPBZ89U.mjs} +38 -9
- package/dist/{login-BrEDKrb3.mjs → login-COZ7Xcm0.mjs} +57 -6
- package/dist/{logs-Beqz--Df.mjs → logs-B_YoflCq.mjs} +2 -2
- package/dist/{migrations-Bg9FEGFh.mjs → migrations-Btzr9_q7.mjs} +2 -2
- package/dist/{open-D7f1wNn3.mjs → open-D2hH933K.mjs} +2 -2
- package/dist/{package-BuwV_mje.mjs → package-DTm0EzxR.mjs} +1 -1
- package/dist/{projects-1r5387ui.mjs → projects-CLs6MEF7.mjs} +2 -2
- package/dist/{schedule-DK_Cv6Xf.mjs → schedule-B8zA8WlG.mjs} +2 -2
- package/dist/{site-BFaWfSVu.mjs → site-DM2J10CB.mjs} +1 -1
- package/dist/{skill-BvoYCIKk.mjs → skill-Dt49jdUT.mjs} +2 -2
- package/dist/{src-BKKAEkbH.mjs → src-BK0Hiwsn.mjs} +95 -3
- package/dist/{staging-CdpV2YHB.mjs → staging-pZpdZuj_.mjs} +2 -2
- package/dist/{token-C8Bmzwfl.mjs → token-BKwGa10J.mjs} +8 -4
- package/dist/{ui--atbZ-TM.mjs → ui-CUNkis89.mjs} +1 -1
- package/dist/{upgrade-0lmdjvom.mjs → upgrade-Cf81feok.mjs} +2 -2
- package/dist/{usage-C1-cvS2-.mjs → usage-UmzU6sGp.mjs} +2 -2
- package/package.json +3 -3
- package/dist/db-TaSLpQZ5.mjs +0 -204
- package/dist/dev-D7v7WK9O.mjs +0 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { c as resolveProject, o as projectFlagArgs } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, d as withContext, i as requireTty, l as apiRequest, r as reportErrors, s as apiDataRequest } from "./ui-CUNkis89.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Btzr9_q7.mjs";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
//#region src/db.ts
|
|
8
|
+
/**
|
|
9
|
+
* canopy db pull | snapshot | snapshots | restore — data safety from the
|
|
10
|
+
* terminal (docs/04-roadmap.md Phase 2 item 4, pulled forward).
|
|
11
|
+
*
|
|
12
|
+
* pull download a fresh SQL dump of the project database
|
|
13
|
+
* snapshot store a dump server-side (restorable from CLI or dashboard)
|
|
14
|
+
* snapshots list stored snapshots
|
|
15
|
+
* restore replace ALL project data from a snapshot or a local .sql file
|
|
16
|
+
* migrate apply pending schema migrations (docs/13-migrations-and-drizzle.md)
|
|
17
|
+
* access show or set what the app's pages may do with the database (docs/35)
|
|
18
|
+
*
|
|
19
|
+
* The project comes from canopy.json in the current directory, or
|
|
20
|
+
* --project <slug>.
|
|
21
|
+
*/
|
|
22
|
+
/** Shared flag: operate on the staging channel's database (docs/14). */
|
|
23
|
+
const stagingFlagArgs = { staging: {
|
|
24
|
+
type: "boolean",
|
|
25
|
+
default: false,
|
|
26
|
+
description: "Target the staging channel's database instead of live"
|
|
27
|
+
} };
|
|
28
|
+
const channelOf = (args) => args.staging ? "staging" : "live";
|
|
29
|
+
const channelNote = (args) => args.staging ? " [staging]" : "";
|
|
30
|
+
const formatSize = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} kB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
31
|
+
const pullCommand = defineCommand({
|
|
32
|
+
meta: {
|
|
33
|
+
name: "pull",
|
|
34
|
+
description: "Download a fresh SQL dump of the project database"
|
|
35
|
+
},
|
|
36
|
+
args: {
|
|
37
|
+
...projectFlagArgs,
|
|
38
|
+
...stagingFlagArgs,
|
|
39
|
+
out: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Output file (defaults to <slug>-<timestamp>.sql)"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
run: reportErrors("db pull", async ({ args }) => {
|
|
45
|
+
const { slug } = resolveProject(args);
|
|
46
|
+
const out = args.out ?? `${slug}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replaceAll(/[:T]/g, "-")}.sql`;
|
|
47
|
+
const spin = p.spinner();
|
|
48
|
+
spin.start(`Exporting ${slug}${channelNote(args)}…`);
|
|
49
|
+
try {
|
|
50
|
+
const { url } = await apiDataRequest("POST", "/db/export", slug, void 0, void 0, channelOf(args));
|
|
51
|
+
const download = await fetch(url);
|
|
52
|
+
if (!download.ok) throw new Error(`Download failed (HTTP ${download.status}).`);
|
|
53
|
+
const sql = Buffer.from(await download.arrayBuffer());
|
|
54
|
+
writeFileSync(out, sql);
|
|
55
|
+
spin.stop(`Wrote ${out} (${formatSize(sql.byteLength)}).`);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
spin.error("Export failed.");
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
const snapshotCommand = defineCommand({
|
|
63
|
+
meta: {
|
|
64
|
+
name: "snapshot",
|
|
65
|
+
description: "Store a dump server-side (restorable anytime)"
|
|
66
|
+
},
|
|
67
|
+
args: {
|
|
68
|
+
...projectFlagArgs,
|
|
69
|
+
...stagingFlagArgs
|
|
70
|
+
},
|
|
71
|
+
run: reportErrors("db snapshot", async ({ args }) => {
|
|
72
|
+
const { slug } = resolveProject(args);
|
|
73
|
+
const spin = p.spinner();
|
|
74
|
+
spin.start(`Snapshotting ${slug}${channelNote(args)}…`);
|
|
75
|
+
let created;
|
|
76
|
+
try {
|
|
77
|
+
({snapshot: created} = await apiDataRequest("POST", "/db/snapshots", slug, void 0, void 0, channelOf(args)));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
spin.error("Snapshot failed.");
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
spin.stop(`Stored ${created.key} (${formatSize(created.size)}).`);
|
|
83
|
+
console.log(`Restore anytime with \`canopy db restore --snapshot ${created.key}\`.`);
|
|
84
|
+
})
|
|
85
|
+
});
|
|
86
|
+
const snapshotsCommand = defineCommand({
|
|
87
|
+
meta: {
|
|
88
|
+
name: "snapshots",
|
|
89
|
+
description: "List stored snapshots"
|
|
90
|
+
},
|
|
91
|
+
args: {
|
|
92
|
+
...projectFlagArgs,
|
|
93
|
+
...stagingFlagArgs
|
|
94
|
+
},
|
|
95
|
+
run: reportErrors("db snapshots", async ({ args }) => {
|
|
96
|
+
const { slug } = resolveProject(args);
|
|
97
|
+
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
98
|
+
if (snapshots.length === 0) {
|
|
99
|
+
console.log(`No snapshots for ${slug} yet — run \`canopy db snapshot\`.`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const snapshot of snapshots) console.log(`${snapshot.key} ${formatSize(snapshot.size).padStart(9)} ${snapshot.createdAt}`);
|
|
103
|
+
})
|
|
104
|
+
});
|
|
105
|
+
const restoreCommand = defineCommand({
|
|
106
|
+
meta: {
|
|
107
|
+
name: "restore",
|
|
108
|
+
description: "Replace ALL project data from a snapshot or a local .sql file"
|
|
109
|
+
},
|
|
110
|
+
args: {
|
|
111
|
+
...projectFlagArgs,
|
|
112
|
+
...stagingFlagArgs,
|
|
113
|
+
file: {
|
|
114
|
+
type: "positional",
|
|
115
|
+
description: "Local .sql dump to restore from",
|
|
116
|
+
required: false
|
|
117
|
+
},
|
|
118
|
+
snapshot: {
|
|
119
|
+
type: "string",
|
|
120
|
+
description: "Server-side snapshot key to restore from"
|
|
121
|
+
},
|
|
122
|
+
yes: {
|
|
123
|
+
type: "boolean",
|
|
124
|
+
default: false,
|
|
125
|
+
description: "Skip the type-the-slug confirmation"
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
run: reportErrors("db restore", async ({ args }) => {
|
|
129
|
+
const { slug } = resolveProject(args);
|
|
130
|
+
const { file } = args;
|
|
131
|
+
let snapshotKey = args.snapshot;
|
|
132
|
+
if (!snapshotKey && !file) {
|
|
133
|
+
requireTty("pass a local .sql dump or --snapshot <key>.");
|
|
134
|
+
const { snapshots } = await apiDataRequest("GET", "/db/snapshots", slug, void 0, void 0, channelOf(args));
|
|
135
|
+
if (snapshots.length === 0) throw new Error(`No snapshots for ${slug} — run \`canopy db snapshot\` first, or pass a local .sql dump.`);
|
|
136
|
+
snapshotKey = unwrap(await p.select({
|
|
137
|
+
message: `Snapshot to restore into ${slug}`,
|
|
138
|
+
options: snapshots.map((snapshot) => ({
|
|
139
|
+
value: snapshot.key,
|
|
140
|
+
label: snapshot.key,
|
|
141
|
+
hint: `${formatSize(snapshot.size)} · ${snapshot.createdAt}`
|
|
142
|
+
}))
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
if (!args.yes) {
|
|
146
|
+
requireTty("pass --yes to restore without confirmation.");
|
|
147
|
+
if (unwrap(await p.text({ message: `Restore will REPLACE all current data in "${slug}"${channelNote(args)} with ${snapshotKey ?? file}. Type the slug to confirm.` })).trim() !== slug) {
|
|
148
|
+
p.cancel("Aborted.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const restoreContext = { action: `restore "${slug}" from ${snapshotKey ?? file}` };
|
|
153
|
+
if (snapshotKey) await withContext(apiDataRequest("POST", "/db/restore", slug, { snapshot: snapshotKey }, void 0, channelOf(args)), restoreContext);
|
|
154
|
+
else if (file) await withContext(apiDataRequest("POST", "/db/restore", slug, readFileSync(file, "utf8"), "application/sql", channelOf(args)), restoreContext);
|
|
155
|
+
p.log.success(`Restored ${slug}${channelNote(args)}.`);
|
|
156
|
+
})
|
|
157
|
+
});
|
|
158
|
+
const migrateCommand = defineCommand({
|
|
159
|
+
meta: {
|
|
160
|
+
name: "migrate",
|
|
161
|
+
description: "Apply pending schema migrations to the project database"
|
|
162
|
+
},
|
|
163
|
+
args: {
|
|
164
|
+
...projectFlagArgs,
|
|
165
|
+
...stagingFlagArgs,
|
|
166
|
+
plan: {
|
|
167
|
+
type: "boolean",
|
|
168
|
+
default: false,
|
|
169
|
+
description: "Dry run: list pending migrations without applying anything"
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
run: reportErrors("db migrate", async ({ args }) => {
|
|
173
|
+
const { dir, slug } = resolveProject(args);
|
|
174
|
+
const files = readMigrationFiles(dir);
|
|
175
|
+
if (files === void 0) throw new Error("No migrations directory configured — set \"migrations\" in canopy.json (e.g. {\"migrations\": \"./migrations\"}, pointing at your drizzle-kit output).");
|
|
176
|
+
if (args.plan) {
|
|
177
|
+
const result = await withContext(migrateProject(slug, files, true, channelOf(args)), { action: `plan migrations for "${slug}"${channelNote(args)}` });
|
|
178
|
+
if (result.pending.length === 0) console.log(`Nothing pending (${result.applied.length} applied previously).`);
|
|
179
|
+
else for (const name of result.pending) console.log(`pending ${name}`);
|
|
180
|
+
for (const line of describeMigrateResult(result)) console.log(line);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const spin = p.spinner();
|
|
184
|
+
spin.start(`Migrating ${slug}${channelNote(args)}…`);
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await withContext(migrateProject(slug, files, false, channelOf(args)), { action: `migrate "${slug}"${channelNote(args)}` });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
spin.error("Migrate failed.");
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
spin.stop(result.appliedNow.length === 0 ? `Nothing pending for ${slug}.` : `Applied ${result.appliedNow.length} migration(s) to ${slug}.`);
|
|
193
|
+
for (const line of describeMigrateResult(result)) console.log(` ${line}`);
|
|
194
|
+
})
|
|
195
|
+
});
|
|
196
|
+
const LEVEL_MEANING = {
|
|
197
|
+
full: "pages can read and write the database (collections and raw SQL)",
|
|
198
|
+
readonly: "pages can read; SQL that writes and collection writes are refused",
|
|
199
|
+
off: "pages cannot touch the database — only the worker and the CLI can"
|
|
200
|
+
};
|
|
201
|
+
function printAccess(slug, access) {
|
|
202
|
+
console.log(`${slug}: browser access is ${access.level} — ${LEVEL_MEANING[access.level]}.`);
|
|
203
|
+
console.log("Tables and collections named private_… are worker-only at every level.");
|
|
204
|
+
if (access.clientAccess && access.level !== "off") {
|
|
205
|
+
console.log("");
|
|
206
|
+
console.log(` ! Client access is on: visitors who know the password can ${access.level === "full" ? "read and change" : "read"} this data from their browser.`);
|
|
207
|
+
console.log(" Name tables private_… to keep them worker-only, or `canopy db access off`.");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const dbCommand = defineCommand({
|
|
211
|
+
meta: {
|
|
212
|
+
name: "db",
|
|
213
|
+
description: "Data tools: pull, snapshot, snapshots, restore, migrate, access"
|
|
214
|
+
},
|
|
215
|
+
subCommands: {
|
|
216
|
+
pull: pullCommand,
|
|
217
|
+
snapshot: snapshotCommand,
|
|
218
|
+
snapshots: snapshotsCommand,
|
|
219
|
+
restore: restoreCommand,
|
|
220
|
+
migrate: migrateCommand,
|
|
221
|
+
access: defineCommand({
|
|
222
|
+
meta: {
|
|
223
|
+
name: "access",
|
|
224
|
+
description: "Show or set what the app's own pages may do with the database (full | readonly | off)"
|
|
225
|
+
},
|
|
226
|
+
args: {
|
|
227
|
+
...projectFlagArgs,
|
|
228
|
+
level: {
|
|
229
|
+
type: "positional",
|
|
230
|
+
description: "full | readonly | off (omit to show)",
|
|
231
|
+
required: false
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
run: reportErrors("db access", async ({ args }) => {
|
|
235
|
+
const { slug } = resolveProject(args);
|
|
236
|
+
const wanted = args.level?.trim().toLowerCase();
|
|
237
|
+
if (!wanted) {
|
|
238
|
+
printAccess(slug, await apiRequest("GET", `/projects/${slug}/db-access`));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (wanted !== "full" && wanted !== "readonly" && wanted !== "off") {
|
|
242
|
+
console.error(`unknown level "${wanted}" — expected full, readonly or off.`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
printAccess(slug, await apiRequest("PUT", `/projects/${slug}/db-access`, { level: wanted }));
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
//#endregion
|
|
251
|
+
export { dbCommand };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-
|
|
2
|
-
import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui
|
|
3
|
-
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, l as stagingUrl, n as siteConfig, o as projectFlagArgs, s as projectUrl, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { a as unwrap, c as apiDownload, d as withContext, l as apiRequest, r as reportErrors, s as apiDataRequest, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
|
+
import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Btzr9_q7.mjs";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-
|
|
2
|
-
import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui
|
|
3
|
-
import { r as readMigrationFiles } from "./migrations-
|
|
1
|
+
import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
|
|
2
|
+
import { d as withContext, l as apiRequest, o as ApiError, r as reportErrors, t as formatDateTime } from "./ui-CUNkis89.mjs";
|
|
3
|
+
import { r as readMigrationFiles } from "./migrations-Btzr9_q7.mjs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { defineCommand } from "citty";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
@@ -37,7 +37,7 @@ async function workerPlugin(flags, slug) {
|
|
|
37
37
|
console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
|
-
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-
|
|
40
|
+
const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CWU6XFe_.mjs");
|
|
41
41
|
const secrets = readDevVars(flags.dir);
|
|
42
42
|
const email = siteConfig().email;
|
|
43
43
|
const plugin = workerDevPlugin({
|
|
@@ -165,7 +165,7 @@ async function devRemote(flags, slug) {
|
|
|
165
165
|
async function devLocal(flags, slug) {
|
|
166
166
|
let dump;
|
|
167
167
|
if (flags.pull) {
|
|
168
|
-
const { fetchProjectDump } = await import("./local-
|
|
168
|
+
const { fetchProjectDump } = await import("./local-CNPBZ89U.mjs");
|
|
169
169
|
console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
|
|
170
170
|
dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
|
|
171
171
|
}
|
|
@@ -180,7 +180,7 @@ async function devLocal(flags, slug) {
|
|
|
180
180
|
const reason = err instanceof ApiError ? err.explain().why : String(err);
|
|
181
181
|
console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
|
|
182
182
|
}
|
|
183
|
-
const { createSandbox } = await import("./local-
|
|
183
|
+
const { createSandbox } = await import("./local-CNPBZ89U.mjs");
|
|
184
184
|
const sandbox = await createSandbox({
|
|
185
185
|
slug,
|
|
186
186
|
persistDir: join(flags.dir, ".canopy", "local"),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "./src-
|
|
1
|
+
import "./src-BK0Hiwsn.mjs";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { relative, resolve, sep } from "node:path";
|
|
4
4
|
//#region ../data-plane/src/worker-request.ts
|
|
@@ -25,6 +25,39 @@ const IDENTITY_STAMP_HEADERS = [
|
|
|
25
25
|
"x-canopy-user-email",
|
|
26
26
|
"x-canopy-user-name"
|
|
27
27
|
];
|
|
28
|
+
/** Cookies that authenticate to Canopy itself — the monk session and the client-page session. */
|
|
29
|
+
const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set(["canopy_session", "canopy_client"]);
|
|
30
|
+
/**
|
|
31
|
+
* Strip Canopy's own credentials from a request before untrusted project code
|
|
32
|
+
* sees it (R-01).
|
|
33
|
+
*
|
|
34
|
+
* The visitor's session cookie arrives with every request the gateway
|
|
35
|
+
* dispatches, and a bundle that can read it can replay it against the platform
|
|
36
|
+
* as that person — against the control plane, and against other projects' data
|
|
37
|
+
* on the api host. It is a credential, so the host keeps it in closure scope
|
|
38
|
+
* like the raw DB and blob handles, and hands the bundle a redacted request.
|
|
39
|
+
*
|
|
40
|
+
* Redaction is surgical on purpose: an app's own cookies and its own
|
|
41
|
+
* Authorization scheme survive, because those belong to the app. Only
|
|
42
|
+
* `canopy_session` / `canopy_client` and a `cnpy_` bearer are removed.
|
|
43
|
+
*
|
|
44
|
+
* `env.CANOPY.fetch` still authenticates as the visitor — the host passes it
|
|
45
|
+
* the unredacted headers — so nothing about the platform-facing behaviour
|
|
46
|
+
* changes; the credential simply stops being reachable from bundle code.
|
|
47
|
+
*/
|
|
48
|
+
function redactCanopyCredentials(headers) {
|
|
49
|
+
const cookie = headers.get("cookie");
|
|
50
|
+
if (cookie !== null) {
|
|
51
|
+
const kept = cookie.split(";").filter((part) => {
|
|
52
|
+
const name = part.split("=")[0]?.trim() ?? "";
|
|
53
|
+
return name !== "" && !CANOPY_COOKIE_NAMES.has(name);
|
|
54
|
+
}).join(";").trim();
|
|
55
|
+
if (kept === "") headers.delete("cookie");
|
|
56
|
+
else headers.set("cookie", kept);
|
|
57
|
+
}
|
|
58
|
+
if (headers.get("authorization")?.startsWith("Bearer cnpy_")) headers.delete("authorization");
|
|
59
|
+
return headers;
|
|
60
|
+
}
|
|
28
61
|
/**
|
|
29
62
|
* Strip what must never travel upstream and stamp who is calling. Common to
|
|
30
63
|
* every host; says nothing about credentials.
|
|
@@ -331,6 +364,7 @@ function workerDevPlugin(options) {
|
|
|
331
364
|
}));
|
|
332
365
|
return;
|
|
333
366
|
}
|
|
367
|
+
redactCanopyCredentials(request.headers);
|
|
334
368
|
await sendResponse(res, await worker.fetch(request, env));
|
|
335
369
|
})().catch((err) => {
|
|
336
370
|
if (err instanceof Error) server.ssrFixStacktrace(err);
|
package/dist/docs/ci.md
CHANGED
|
@@ -9,7 +9,8 @@ can deploy that one project and nothing else.
|
|
|
9
9
|
|
|
10
10
|
## Create a token
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Only the project owner can create a deploy token (any collaborator can list
|
|
13
|
+
and revoke them). From inside the project directory:
|
|
13
14
|
|
|
14
15
|
```bash
|
|
15
16
|
canopy token create --name "GitHub Actions"
|
|
@@ -47,4 +48,10 @@ No `canopy login` step is needed — the token is the credential.
|
|
|
47
48
|
- A deploy token can only deploy its own project — it reaches that project's
|
|
48
49
|
deploy endpoints and upload grants and nothing else. If it strays to another
|
|
49
50
|
project's resources, the platform rejects it.
|
|
51
|
+
- The token belongs to the project, not to the person who created it. It keeps
|
|
52
|
+
working when they leave the project or the company; deploys it makes show up
|
|
53
|
+
in the history under the token's name.
|
|
54
|
+
- A token that goes 90 days without a deploy stops working. Active pipelines
|
|
55
|
+
never notice; a forgotten one dies on its own. The token list shows when each
|
|
56
|
+
was last used.
|
|
50
57
|
- Revoke a token the moment it might be exposed; create a fresh one to replace it.
|
package/dist/docs/cli-egress.md
CHANGED
|
@@ -32,7 +32,8 @@ target directory, or `--project <slug>` to override — and honor the global
|
|
|
32
32
|
- `mode [allowlist|open]` — show or switch the
|
|
33
33
|
[egress mode](https://docs.canopy.website/platform/egress#open-mode). Switching to `open` prints a risk
|
|
34
34
|
summary and asks you to type the project slug; pass `--accept-risk` to
|
|
35
|
-
acknowledge it non-interactively.
|
|
35
|
+
acknowledge it non-interactively. Only the project owner can switch to
|
|
36
|
+
`open`; switching back needs no ceremony and any member can do it.
|
|
36
37
|
- `log` — print the [outbound traffic log](https://docs.canopy.website/platform/egress#the-outbound-traffic-log):
|
|
37
38
|
hosts the worker actually fetched, with counts and last-seen times.
|
|
38
39
|
|
package/dist/docs/cli-login.md
CHANGED
|
@@ -24,6 +24,8 @@ canopy login --json # machine-readable status for agents/CI
|
|
|
24
24
|
2. You finish signing in **in the browser** — your password never passes through the CLI. When OneLogin hands control back, the browser shows a small "You're signed in" page and you can return to the terminal. The sign-in waits up to 5 minutes; the hand-back uses local port `8976`, so if another program holds that port the login fails with a clear message.
|
|
25
25
|
3. The CLI stores a token for **every** site (`main`, `staging`, `gcp-pilot`), not just the one you picked, in `~/.canopy/config.json` — readable only by your user account.
|
|
26
26
|
|
|
27
|
+
Each token is labelled with your machine's hostname and expires after 30 days. The dashboard's **Account** page (your avatar → Account) lists every machine you have signed in from and lets you revoke any of them; `canopy logout` revokes this machine's tokens for you.
|
|
28
|
+
|
|
27
29
|
One login therefore covers all sites; `--profile` only decides which one commands target by default. If minting fails for a non-active site you get a warning but login still succeeds; if it fails for your **active** profile the command errors and asks you to re-run.
|
|
28
30
|
|
|
29
31
|
## Examples
|
package/dist/docs/cli-logout.md
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: canopy logout
|
|
3
|
-
description: Sign out of Canopy
|
|
3
|
+
description: Sign out of Canopy — revoke your CLI tokens on every site and forget them locally.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
`canopy logout` signs you out on this machine
|
|
6
|
+
`canopy logout` signs you out on this machine: it revokes the CLI token stored for every site on the platform, then forgets them in `~/.canopy/config.json`. Use it on a shared machine, or when you want a clean slate before logging in as someone else.
|
|
7
7
|
|
|
8
8
|
## Usage
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
canopy logout
|
|
12
|
+
canopy logout --local # forget the tokens here, but leave them valid on the platform
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
## Arguments & flags
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
- `--local` — only remove the tokens from this machine; nothing is called on the server. The tokens stay valid until they expire (30 days after login), and you can still revoke them from the dashboard's **Account** page.
|
|
18
|
+
|
|
19
|
+
Logout is deliberately all-or-nothing: it signs out of **every** site (`main`, `staging`, `gcp-pilot`) at once — there is no per-profile logout, so the global `--profile` flag has no effect here.
|
|
17
20
|
|
|
18
21
|
## Details
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
For each site with a stored token, the command asks the platform to revoke that token, so it stops working everywhere immediately — not just on this machine. Then the file is cleaned up. If a site cannot be reached, the local copy is still removed and the command tells you which tokens it could not revoke; those keep working until they expire, or until you revoke them from the dashboard's **Account** page (your avatar → Account), which lists every machine you have signed in from.
|
|
21
24
|
|
|
22
|
-
If no token was stored to begin with, it prints `Not logged in.` and exits successfully.
|
|
25
|
+
Your active-site choice stays in the file, so the next `canopy login` picks up where you left off. If no token was stored to begin with, it prints `Not logged in.` and exits successfully.
|
|
23
26
|
|
|
24
27
|
## Examples
|
|
25
28
|
|
|
@@ -27,6 +30,6 @@ If no token was stored to begin with, it prints `Not logged in.` and exits succe
|
|
|
27
30
|
canopy logout
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
Revokes all site tokens on the platform and removes them from `~/.canopy/config.json`; subsequent commands that need the API will ask you to run `canopy login`.
|
|
31
34
|
|
|
32
35
|
See also [canopy login](https://docs.canopy.website/cli/login) and [canopy whoami](https://docs.canopy.website/cli/whoami).
|
package/dist/docs/cli-token.md
CHANGED
|
@@ -17,15 +17,17 @@ canopy token revoke <id>
|
|
|
17
17
|
|
|
18
18
|
All subcommands resolve the project the usual way — `canopy.json` in the target directory, or `--project <slug>` to override — and honor the global `--profile <site>` / `CANOPY_PROFILE` for which site the token belongs to.
|
|
19
19
|
|
|
20
|
-
- `create` — mint a new deploy token for the project.
|
|
20
|
+
- `create` — mint a new deploy token for the project. Owner only: collaborators get a clear error and should ask the owner.
|
|
21
21
|
- `--name <label>` — a label shown in the dashboard, e.g. `"GitHub Actions"`. Optional but recommended so you can tell tokens apart later.
|
|
22
|
-
- `list` — show the project's deploy tokens: id, name, creation time,
|
|
22
|
+
- `list` — show the project's deploy tokens: id, name, creation time, who created them, and when each was last used. Token values are never shown here.
|
|
23
23
|
- `revoke <id>` — delete a token by its id (from `canopy token list`). Deploys using it stop working immediately.
|
|
24
24
|
|
|
25
25
|
## How it works
|
|
26
26
|
|
|
27
27
|
`create` prints the token plaintext **exactly once** — the platform stores only a hash, so copy it right away. The token is scoped to a single project on a single site: it can deploy that project and nothing else, which makes it safe to hand to a CI system.
|
|
28
28
|
|
|
29
|
+
The token belongs to the project rather than to whoever created it, so CI keeps working when that person leaves. It has no fixed expiry but stops working after 90 days without a deploy; `list` shows when each token was last used.
|
|
30
|
+
|
|
29
31
|
To use it in CI, put the value in the `CANOPY_DEPLOY_TOKEN` environment variable — the CLI checks it before the login token, so `canopy deploy` works on a runner with no `canopy login`. For GitHub Actions, add it as the repo secret `CANOPY_DEPLOY_TOKEN` (`gh secret set CANOPY_DEPLOY_TOKEN`, or Settings → Secrets); pushes to main then deploy via the project's `canopy-deploy.yml` workflow.
|
|
30
32
|
|
|
31
33
|
If a deploy fails with a rejected token, it was likely revoked — create a fresh one here or in the dashboard (Settings → Deploy tokens) and update the secret. Losing a token is never a problem: revoke the old id and mint a new one.
|
package/dist/docs/index.json
CHANGED
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
{
|
|
118
118
|
"slug": "platform-ai-usage",
|
|
119
119
|
"title": "AI access & usage",
|
|
120
|
-
"summary": "Projects talk to AI models through the platform — no API keys to manage,
|
|
120
|
+
"summary": "Projects talk to AI models through the platform — no API keys to manage, hard per-project budgets, and full visibility of spend."
|
|
121
121
|
},
|
|
122
122
|
{
|
|
123
123
|
"slug": "platform-google",
|
|
@@ -154,6 +154,11 @@
|
|
|
154
154
|
"title": "Client access",
|
|
155
155
|
"summary": "Share a project with clients who have no Monks account — a shared password unlocks that one project's URL and nothing else."
|
|
156
156
|
},
|
|
157
|
+
{
|
|
158
|
+
"slug": "platform-security-headers",
|
|
159
|
+
"title": "Browser security",
|
|
160
|
+
"summary": "The security headers the platform adds to your app's pages and files, what they block, and the two switches in Settings."
|
|
161
|
+
},
|
|
157
162
|
{
|
|
158
163
|
"slug": "platform-sites-and-profiles",
|
|
159
164
|
"title": "Sites & profiles",
|
|
@@ -184,7 +189,7 @@
|
|
|
184
189
|
{
|
|
185
190
|
"slug": "cli-logout",
|
|
186
191
|
"title": "canopy logout",
|
|
187
|
-
"summary": "Sign out of Canopy
|
|
192
|
+
"summary": "Sign out of Canopy — revoke your CLI tokens on every site and forget them locally."
|
|
188
193
|
},
|
|
189
194
|
{
|
|
190
195
|
"slug": "cli-create",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: AI access & usage
|
|
3
|
-
description: Projects talk to AI models through the platform — no API keys to manage,
|
|
3
|
+
description: Projects talk to AI models through the platform — no API keys to manage, hard per-project budgets, and full visibility of spend.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Canopy projects can call AI models without you ever handling an API key. The
|
|
7
7
|
platform routes every request through its own metered gateway, tracks what
|
|
8
|
-
each project spends, and enforces
|
|
8
|
+
each project spends, and enforces hard budgets — so a runaway loop in
|
|
9
9
|
a vibecoded app becomes a capped bill and a visible chart, not a surprise
|
|
10
10
|
invoice.
|
|
11
11
|
|
|
@@ -26,9 +26,26 @@ const reply = await canopy.ai.chat({
|
|
|
26
26
|
default — ask for a model outside the list and the API tells you what _is_
|
|
27
27
|
available.
|
|
28
28
|
- **Streaming** via `canopy.ai.chatStream()` for chat-style UIs.
|
|
29
|
+
- **Documents and images.** Messages can attach files — reference something
|
|
30
|
+
in the project's [blob storage](https://docs.canopy.website/sdk/blob) by key, or inline base64 — see
|
|
31
|
+
[the AI SDK page](https://docs.canopy.website/sdk/ai). Models that read PDFs and images get the real
|
|
32
|
+
document, not a text extraction.
|
|
29
33
|
- **An OpenAI-compatible endpoint.** `canopy.ai.endpoint()` returns a base
|
|
30
34
|
URL and model list you can plug into ecosystem tools (like the Vercel AI
|
|
31
35
|
SDK) — same key handling, same budget, same tracking.
|
|
36
|
+
- **Provider-native endpoints.** For provider-specific capabilities —
|
|
37
|
+
Anthropic prompt caching and PDF citations, OpenAI's Responses API — point
|
|
38
|
+
an official SDK at the native base URLs from `canopy.ai.endpoint()`. Same
|
|
39
|
+
budget and tracking as everything else; see [the AI SDK page](https://docs.canopy.website/sdk/ai).
|
|
40
|
+
|
|
41
|
+
## Request size
|
|
42
|
+
|
|
43
|
+
A single chat request — prompt, inline base64 attachments, and referenced
|
|
44
|
+
blobs together — can be up to **8 MB**. Base64 makes files about a third
|
|
45
|
+
larger than they are on disk, so that fits roughly a 6 MB document per
|
|
46
|
+
request. Bigger requests answer with a `chat_too_large` error (HTTP 413)
|
|
47
|
+
before anything is sent to a model. Providers also have their own per-file
|
|
48
|
+
limits, which are typically above this.
|
|
32
49
|
|
|
33
50
|
## Your data and the model providers
|
|
34
51
|
|
|
@@ -41,16 +58,25 @@ This applies to every way your project reaches AI — `canopy.ai.chat()`,
|
|
|
41
58
|
streaming, and the OpenAI-compatible endpoint all go through the same gateway
|
|
42
59
|
to the same accounts.
|
|
43
60
|
|
|
44
|
-
##
|
|
61
|
+
## Budgets: a daily limit and a monthly cap
|
|
62
|
+
|
|
63
|
+
Every project has two AI budgets in USD, enforced from the same spend ledger
|
|
64
|
+
the dashboard displays — so what you see and what's enforced can never
|
|
65
|
+
disagree:
|
|
66
|
+
|
|
67
|
+
- **A daily limit** (default $5) catches runaway loops. Cross it and AI
|
|
68
|
+
calls answer `ai_budget_exceeded` until midnight UTC; everything resets
|
|
69
|
+
the next day.
|
|
70
|
+
- **A monthly cap** (default $20) is the real spending ceiling. Cross it and
|
|
71
|
+
AI calls pause until the calendar month rolls over.
|
|
45
72
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
the
|
|
50
|
-
disagree.
|
|
73
|
+
The error names which one tripped. The Usage tab shows spend against both.
|
|
74
|
+
If a project genuinely needs more headroom, a Canopy admin can raise either
|
|
75
|
+
budget from that same tab — ask in the project's feedback or reach out to
|
|
76
|
+
the platform team.
|
|
51
77
|
|
|
52
|
-
> **Note:** Hitting
|
|
53
|
-
> calls pause until the
|
|
78
|
+
> **Note:** Hitting a budget doesn't break the rest of your app — pages, data, and files keep working. Only AI
|
|
79
|
+
> calls pause until the window rolls over.
|
|
54
80
|
|
|
55
81
|
## The Usage tab
|
|
56
82
|
|
|
@@ -36,8 +36,11 @@ it** card:
|
|
|
36
36
|
2. Send the client the project URL and the password.
|
|
37
37
|
|
|
38
38
|
To change the password, set a new one — previously handed-out passwords stop
|
|
39
|
-
working
|
|
40
|
-
|
|
39
|
+
working, and every browser that unlocked the project with the old one is
|
|
40
|
+
signed out on its next visit and asked for the new password. To turn client
|
|
41
|
+
access off entirely, remove the password from the same card, or switch the
|
|
42
|
+
mode back to Monks only; either way, visitors are back to Monks-only
|
|
43
|
+
immediately, unlocked browsers included.
|
|
41
44
|
|
|
42
45
|
> **Warning:** A shared password is deliberately lightweight protection — right for work-in-progress previews,
|
|
43
46
|
> wrong for anything sensitive. Anyone who has it can view the app and share it further, so rotate
|
|
@@ -51,4 +54,13 @@ the app down for Monks outside the team while a client with the password
|
|
|
51
54
|
still gets in. That combination — "the client and the project team, nobody
|
|
52
55
|
else" — is a common setup during a review phase.
|
|
53
56
|
|
|
57
|
+
The password opens the app's data the same way it opens the app. Whatever a
|
|
58
|
+
page may do with the project database under **Browser access** on the Data
|
|
59
|
+
tab — read and write by default — a visitor who knows the password may do
|
|
60
|
+
too, from their own browser. The dashboard points this out wherever the two
|
|
61
|
+
settings meet. If the project has tables the client should not see or change,
|
|
62
|
+
name them `private_…` so only the worker can reach them, or set browser
|
|
63
|
+
access to read only or off. See
|
|
64
|
+
[Who can call this from the browser](https://docs.canopy.website/sdk/database#who-can-call-this-from-the-browser).
|
|
65
|
+
|
|
54
66
|
- [Members & access](https://docs.canopy.website/platform/members-and-access) — Who can view and who can manage a project, and how to tighten it.
|
|
@@ -102,9 +102,11 @@ allowlist:
|
|
|
102
102
|
Because open mode means _users of your app can decide where it connects_,
|
|
103
103
|
enabling it is a deliberate act: the dashboard asks you to type the project
|
|
104
104
|
slug under a plain-language risk summary, and the CLI wants an interactive
|
|
105
|
-
confirmation or the explicit `--accept-risk` flag.
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
confirmation or the explicit `--accept-risk` flag. Only the **project owner**
|
|
106
|
+
can switch open mode on; other members can still manage the allowlist and
|
|
107
|
+
switch back. Extra care applies if the project has
|
|
108
|
+
[client access](https://docs.canopy.website/platform/client-access) enabled — then untrusted visitors
|
|
109
|
+
drive the fetches.
|
|
108
110
|
|
|
109
111
|
Switching back is one command (`canopy egress mode allowlist`), and the
|
|
110
112
|
dashboard offers to **seed the allowlist from the observed hosts** so
|