@mediamonks/canopy-cli 0.7.33 → 0.7.35

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.
@@ -0,0 +1,89 @@
1
+ import { d as tryResolveProject, n as siteConfig } from "./site-DM2J10CB.mjs";
2
+ import { i as apiRequest, t as ApiError } from "./api-C4rMhE7i.mjs";
3
+ import { defineCommand } from "citty";
4
+ //#region src/browse.ts
5
+ /**
6
+ * canopy browse — a one-time sign-in link to a project for an agent's
7
+ * browser (agent-browse-sessions). Opening it within a minute gives that
8
+ * browser an hour on this one project and channel, as the signed-in Monk;
9
+ * nothing else on Canopy. The link is the only thing printed: no cookie, no
10
+ * token, nothing written to disk, and no browser opened (that is `canopy open`).
11
+ */
12
+ var BrowseError = class extends Error {
13
+ name = "BrowseError";
14
+ };
15
+ /** The control plane's refusal, in words that say what to do about it. */
16
+ function explainBrowseRefusal(code, slug, detail) {
17
+ switch (code) {
18
+ case "members_only": return `${slug} is limited to its collaborators — ask the owner to add you.`;
19
+ case "project_not_found": return `There is no project ${slug} on this site — check the slug and --profile.`;
20
+ case "project_paused": return `${slug} is paused.`;
21
+ case "project_moved": return `${slug} has moved${detail ? ` (${detail})` : ""}.`;
22
+ case "staging_not_enabled": return `${slug} has no staging channel yet — drop --staging, or create it with \`canopy deploy --staging\`.`;
23
+ case "invalid_path": return "--path must be a page on the project, starting with / (for example /settings).";
24
+ case "cli_session_required": return "Browse links need your own CLI sign-in, not a deploy token — run `canopy login`.";
25
+ case "not_configured": return "This site has no public project hostnames, so it can't hand out browse links.";
26
+ default: return detail ? `${code}: ${detail}` : code;
27
+ }
28
+ }
29
+ const browseCommand = defineCommand({
30
+ meta: {
31
+ name: "browse",
32
+ description: "Print a one-time sign-in link to a project for an agent's browser"
33
+ },
34
+ args: {
35
+ slug: {
36
+ type: "positional",
37
+ description: "Project slug (defaults to canopy.json in the current directory)",
38
+ required: false
39
+ },
40
+ staging: {
41
+ type: "boolean",
42
+ default: false,
43
+ description: "Link to the staging channel instead of live"
44
+ },
45
+ path: {
46
+ type: "string",
47
+ description: "Page to land on after signing in (default /)"
48
+ },
49
+ json: {
50
+ type: "boolean",
51
+ default: false,
52
+ description: "Print only { url, expires_at } as JSON"
53
+ }
54
+ },
55
+ async run({ args }) {
56
+ try {
57
+ const slug = tryResolveProject({ project: args.slug }).slug;
58
+ if (!slug) throw new BrowseError("No project named — pass a slug (`canopy browse <slug>`) or run from a project directory.");
59
+ if (process.env["CANOPY_DEPLOY_TOKEN"]) throw new BrowseError("Browse links use your own sign-in, not a deploy token — unset CANOPY_DEPLOY_TOKEN and run `canopy login`.");
60
+ if (!siteConfig().token) throw new BrowseError("Not signed in to this site — run `canopy login` first.");
61
+ let link;
62
+ try {
63
+ link = await apiRequest("POST", `/projects/${slug}/browse-ticket`, {
64
+ channel: args.staging ? "staging" : "live",
65
+ ...args.path !== void 0 && { path: args.path }
66
+ });
67
+ } catch (err) {
68
+ if (err instanceof ApiError && err.status !== 401) throw new BrowseError(explainBrowseRefusal(err.code, slug, err.detail));
69
+ throw err;
70
+ }
71
+ if (args.json) {
72
+ process.stdout.write(`${JSON.stringify({
73
+ url: link.url,
74
+ expires_at: link.expires_at
75
+ })}\n`);
76
+ return;
77
+ }
78
+ console.log(`\n ${link.url}\n`);
79
+ console.log(" Open it within a minute; it works once. The browser that opens it is signed in to");
80
+ console.log(` ${slug}${args.staging ? " (staging)" : ""} as you for an hour, and to nothing else on Canopy.\n`);
81
+ } catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ process.stderr.write(`canopy browse: ${message}\n`);
84
+ process.exitCode = 1;
85
+ }
86
+ }
87
+ });
88
+ //#endregion
89
+ export { browseCommand };
@@ -0,0 +1,2 @@
1
+ import { n as devCommand } from "./dev-oS4nYMSn.mjs";
2
+ export { devCommand };
@@ -2,12 +2,13 @@ import { a as projectDirArgs, c as resolveProject, i as projectDialect, n as sit
2
2
  import { i as apiRequest, o as withContext, t as ApiError } from "./api-C4rMhE7i.mjs";
3
3
  import { r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
4
4
  import { r as readMigrationFiles } from "./migrations-Czlcddyz.mjs";
5
- import { r as stampWorkerHeaders } from "./src-BkACMkol.mjs";
5
+ import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER } from "./src-CxzGOEhP.mjs";
6
6
  import { n as writeDevServerRecord } from "./dev-server-file-Dk9pk4AR.mjs";
7
7
  import { createRequire } from "node:module";
8
8
  import { defineCommand } from "citty";
9
9
  import { existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
+ import { randomBytes } from "node:crypto";
11
12
  import { pathToFileURL } from "node:url";
12
13
  //#region src/dev.ts
13
14
  /**
@@ -33,14 +34,14 @@ function workerDialect(flags) {
33
34
  * gateway serves it in production (dev-worker.ts). Returns the plugin plus
34
35
  * the notice lines to print, or undefined when the project has no worker.
35
36
  */
36
- async function workerPlugin(flags, slug) {
37
+ async function workerPlugin(flags, slug, workerAttest) {
37
38
  const entry = tryReadCanopyJson(flags.dir)?.worker;
38
39
  if (entry === void 0) return void 0;
39
40
  if (!existsSync(join(flags.dir, entry))) {
40
41
  console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
41
42
  return;
42
43
  }
43
- const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CaCsGBkn.mjs");
44
+ const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CpSB_Okh.mjs");
44
45
  const secrets = readDevVars(flags.dir);
45
46
  const email = siteConfig().email;
46
47
  const plugin = workerDevPlugin({
@@ -54,7 +55,8 @@ async function workerPlugin(flags, slug) {
54
55
  email,
55
56
  name: "Local Dev"
56
57
  } : null,
57
- dialect: workerDialect(flags)
58
+ dialect: workerDialect(flags),
59
+ workerAttest
58
60
  });
59
61
  const notes = [` /api/* is served by ${entry} (edit and refresh; no build step)`];
60
62
  if (Object.keys(secrets).length > 0) notes.push(` worker secrets from .dev.vars: ${Object.keys(secrets).sort().join(", ")}`);
@@ -168,6 +170,8 @@ async function bridgeToSandbox(sandbox, req, res) {
168
170
  const headers = new Headers();
169
171
  if (req.headers["content-type"]) headers.set("content-type", req.headers["content-type"]);
170
172
  if (req.headers["accept"]) headers.set("accept", req.headers["accept"]);
173
+ const attest = req.headers[WORKER_ATTEST_HEADER];
174
+ if (typeof attest === "string") headers.set(WORKER_ATTEST_HEADER, attest);
171
175
  const response = await sandbox.handle(new Request(`http://canopy.local${req.url ?? ""}`, {
172
176
  method: req.method,
173
177
  headers,
@@ -225,7 +229,7 @@ async function devRemote(flags, slug) {
225
229
  async function devLocal(flags, slug) {
226
230
  let dump;
227
231
  if (flags.pull) {
228
- const { fetchProjectDump } = await import("./local-DAYCNUyz.mjs");
232
+ const { fetchProjectDump } = await import("./local-Cc8H3NUw.mjs");
229
233
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
230
234
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
231
235
  }
@@ -240,12 +244,14 @@ async function devLocal(flags, slug) {
240
244
  const reason = err instanceof ApiError ? err.explain().why : String(err);
241
245
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
242
246
  }
243
- const { createSandbox } = await import("./local-DAYCNUyz.mjs");
247
+ const workerAttest = randomBytes(24).toString("base64url");
248
+ const { createSandbox } = await import("./local-Cc8H3NUw.mjs");
244
249
  const sandbox = await createSandbox({
245
250
  slug,
246
251
  persistDir: join(flags.dir, ".canopy", "local"),
247
252
  email: siteConfig().email,
248
- dialect: flags.dialect
253
+ dialect: flags.dialect,
254
+ workerAttest
249
255
  });
250
256
  if (dump !== void 0) {
251
257
  await sandbox.applyDump(dump);
@@ -276,7 +282,7 @@ async function devLocal(flags, slug) {
276
282
  headers: { authorization: `Bearer ${remote.token}` },
277
283
  rewrite: rewriteApiPath
278
284
  };
279
- const worker = await workerPlugin(flags, slug);
285
+ const worker = await workerPlugin(flags, slug, workerAttest);
280
286
  const server = await startVite(flags, { proxy: {
281
287
  ...remoteEntry && {
282
288
  "/_api/data/v1/ai": remoteEntry,
@@ -1,4 +1,4 @@
1
- import { n as redactCanopyCredentials, r as stampWorkerHeaders, t as CALLER_HEADER } from "./src-BkACMkol.mjs";
1
+ import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER, r as redactCanopyCredentials, t as CALLER_HEADER } from "./src-CxzGOEhP.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, resolve, sep } from "node:path";
4
4
  //#region src/dev-worker.ts
@@ -97,7 +97,7 @@ function rewriteCanopyUrl(raw, base) {
97
97
  * dev server's `/_api` mount so the running mode decides what answers.
98
98
  * Credentials stay in the proxy; nothing here carries a token.
99
99
  */
100
- function createCanopyFetch(apiBase, slug, channel, fetchImpl = fetch) {
100
+ function createCanopyFetch(apiBase, slug, channel, fetchImpl = fetch, workerAttest) {
101
101
  return async (input, init) => {
102
102
  const url = rewriteCanopyUrl(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, apiBase());
103
103
  const headers = stampWorkerHeaders(new Headers(input instanceof Request ? input.headers : init?.headers), {
@@ -106,6 +106,8 @@ function createCanopyFetch(apiBase, slug, channel, fetchImpl = fetch) {
106
106
  });
107
107
  headers.delete("authorization");
108
108
  headers.delete("cookie");
109
+ if (workerAttest) headers.set(WORKER_ATTEST_HEADER, workerAttest);
110
+ else headers.delete(WORKER_ATTEST_HEADER);
109
111
  const method = (input instanceof Request ? input.method : init?.method) ?? "GET";
110
112
  const body = input instanceof Request ? input.body : init?.body;
111
113
  const outbound = {
@@ -263,7 +265,7 @@ async function sendResponse(res, response) {
263
265
  function workerDevPlugin(options) {
264
266
  const moduleId = entryModuleId(options.dir, options.entry);
265
267
  let apiBase = "";
266
- const canopyFetch = createCanopyFetch(() => apiBase, options.slug, options.channel);
268
+ const canopyFetch = createCanopyFetch(() => apiBase, options.slug, options.channel, fetch, options.workerAttest);
267
269
  return {
268
270
  name: "canopy-project-worker",
269
271
  configureServer(server) {
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: Let your agent browse a project
3
+ description: Give an AI agent's browser a short sign-in to one project, so it can look at and click through your app as you.
4
+ ---
5
+
6
+ Coding agents often need to see the app they're working on: check that a page renders, click through a flow, or read what a screen shows. Every Canopy project sits behind the Monks sign-in, which an agent can't get through on its own.
7
+
8
+ `canopy browse` gives the agent a way in that doesn't involve handing over your own browser session:
9
+
10
+ ```bash
11
+ canopy browse my-app --json
12
+ ```
13
+
14
+ It prints a one-time link. Whatever browser opens it is signed in to **my-app**, as you, for **an hour**, and to nothing else on Canopy.
15
+
16
+ ## What the agent gets
17
+
18
+ - **One project, one channel.** The live site, or staging with `--staging`. The browser is not signed in to your other projects, or to the dashboard.
19
+ - **You.** The app sees you as the visitor, exactly as when you open it yourself. Your backend's `env.USER` is you, and data you can see, the agent can see. On a collaborators-only project you have to be a collaborator.
20
+ - **An hour.** After that the agent meets the sign-in page and can run `canopy browse` again.
21
+ - **Marked as an agent.** Your backend can tell the request came from an agent: `mcpCaller(request, env).agent` from `@mediamonks/canopy/mcp` is `true`.
22
+
23
+ The link itself works **once**, within **a minute**. Opening it a second time shows a page saying to run `canopy browse` again.
24
+
25
+ ## Using it from different tools
26
+
27
+ **Playwright, or a browser tool in your agent:** open the URL. The browser is redirected to the page and stays signed in.
28
+
29
+ ```ts
30
+ await page.goto(url); // lands on the project, signed in
31
+ ```
32
+
33
+ **curl:** keep the cookie in a jar and follow the redirect.
34
+
35
+ ```bash
36
+ curl -c jar.txt -L "$(canopy browse my-app --json | jq -r .url)"
37
+ curl -b jar.txt https://my-app.canopy.website/api/notes
38
+ ```
39
+
40
+ **A specific page:** `canopy browse my-app --path /settings` lands there directly.
41
+
42
+ ## Stopping it
43
+
44
+ The agent's session ends when the hour is up. It also ends early if you:
45
+
46
+ - run `canopy logout`,
47
+ - revoke that CLI sign-in on your Account page in the dashboard, or
48
+ - sign out everywhere.
49
+
50
+ If you lose access to a collaborators-only project, the agent loses it too.
51
+
52
+ ## Good to know
53
+
54
+ - Your project's backend needs a redeploy after this feature shipped before an agent can call its `/api/*` routes. Until then those calls answer "redeploy required", while pages still load.
55
+ - The feedback widget doesn't appear for agents.
56
+ - Never give an agent your own browser session cookie. It opens every project and the dashboard, as you.
57
+
58
+ See also [canopy browse](https://docs.canopy.website/cli/browse), and [Agent tools](https://docs.canopy.website/guides/agent-tools) for letting agents call your backend directly.
@@ -0,0 +1,48 @@
1
+ ---
2
+ title: canopy browse
3
+ description: Print a one-time sign-in link that lets an agent's browser open a project as you.
4
+ ---
5
+
6
+ `canopy browse` prints a link for an AI agent's browser: Playwright, a browser tool in Claude or Cursor, or plain `curl`. Opening the link signs that browser in to **one project**, as you, for **one hour**. It reaches nothing else on Canopy: no other projects, and not the dashboard.
7
+
8
+ Read [Let your agent browse a project](https://docs.canopy.website/guides/agent-browsing) for the full picture.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ canopy browse [slug] [--staging] [--path <page>] [--json]
14
+ ```
15
+
16
+ ## Arguments & flags
17
+
18
+ - `[slug]` — the project. Defaults to the `canopy.json` in the current directory.
19
+ - `--staging` — link to the project's [staging channel](https://docs.canopy.website/guides/staging) instead of the live site.
20
+ - `--path <page>` — the page to land on after signing in, such as `/settings`. Defaults to `/`.
21
+ - `--json` — print only `{ "url", "expires_at" }`, for an agent to read.
22
+
23
+ The site comes from the active profile, as for every command (`--profile staging` links to the project on the staging site).
24
+
25
+ ## How it works
26
+
27
+ The command uses your `canopy login` sign-in to ask Canopy for a link to the project, under the same rules as opening it yourself. Collaborators-only projects only give links to collaborators.
28
+
29
+ - The link **works once** and **expires after a minute**. Run the command again for a new one.
30
+ - The browser that opens it is signed in to that project and channel for an hour. After that it sees the sign-in page again.
31
+ - Running `canopy logout`, revoking the CLI sign-in on your Account page, or signing out everywhere also signs the agent out.
32
+ - The command never opens a browser itself (that's [canopy open](https://docs.canopy.website/cli/open)), never prints a cookie, and saves nothing.
33
+
34
+ ## Examples
35
+
36
+ ```bash
37
+ canopy browse my-app --json
38
+ ```
39
+
40
+ Prints `{"url":"https://my-app.…/__canopy/agent-login?ticket=…","expires_at":"…"}`. Hand the URL to your agent's browser.
41
+
42
+ ```bash
43
+ canopy browse --staging --path /admin
44
+ ```
45
+
46
+ A link to the staging channel of the project in this folder, landing on `/admin`.
47
+
48
+ See also [canopy mcp](https://docs.canopy.website/cli/mcp), which lets agents call your project's backend as tools instead of using its pages.
@@ -25,9 +25,9 @@ canopy dev --local --pull # sandbox hydrated from a fresh prod export
25
25
 
26
26
  ## How it works
27
27
 
28
- **Remote-first (default).** The CLI mints a short-lived, project-scoped dev token from the platform, then starts Vite with a proxy: every `/_api/*` request is forwarded to the real platform API with that token attached. The token stays inside the proxy — browser code never holds a credential. When the token expires (the expiry is printed at startup), restart `canopy dev`. Because you're on live data, the startup banner suggests `canopy db snapshot` before risky changes, or switching to `--local --pull`.
28
+ **Remote-first (default).** The CLI mints a short-lived, project-scoped dev token from the platform, then starts Vite with a proxy: every `/_api/*` request is forwarded to the real platform API with that token attached. The token stays inside the proxy — browser code never holds a credential. When the token expires (the expiry is printed at startup), restart `canopy dev`. Because every call from your browser carries that token, the `private_` rule doesn't apply to your own pages in this mode; use `--local` to test it. Because you're on live data, the startup banner suggests `canopy db snapshot` before risky changes, or switching to `--local --pull`.
29
29
 
30
- **Local sandbox (`--local`).** Database, file, and auth requests are answered entirely on your machine, from a sandbox stored under `.canopy/local/` in the project directory — no database to install, and writes never reach the platform. The sandbox behaves the same as the real API, so code that works locally works deployed. Two things still go remote: `ai` and `services` calls go through to the real platform (metered as usual) when you're logged in — fully offline, they return errors until you log in and restart.
30
+ **Local sandbox (`--local`).** Database, file, and auth requests are answered entirely on your machine, from a sandbox stored under `.canopy/local/` in the project directory — no database to install, and writes never reach the platform. The sandbox behaves the same as the real API, so code that works locally works deployed. That includes [`private_` collections and tables](https://docs.canopy.website/sdk/database): your pages get the same refusal they would get deployed, while your worker keeps full access. The one setting it doesn't copy is **Browser access** on the Data tab: locally, pages always get read and write. Two things still go remote: `ai` and `services` calls go through to the real platform (metered as usual) when you're logged in — fully offline, they return errors until you log in and restart.
31
31
 
32
32
  **Matching your site's SQL dialect.** The document API (`canopy.db.collection(…)`) behaves identically everywhere, but raw `canopy.db.sql()` speaks your site's dialect — SQLite or Postgres (see [dialect()](https://docs.canopy.website/guides/data)). The sandbox defaults to Postgres. If your project uses raw SQL written for a SQLite site, lock the dialect in `canopy.json`:
33
33
 
@@ -77,6 +77,11 @@
77
77
  "title": "Agent tools (MCP)",
78
78
  "summary": "Let AI agents like Claude Code and Cursor use your project's backend as tools — as the person running them."
79
79
  },
80
+ {
81
+ "slug": "agent-browsing",
82
+ "title": "Let your agent browse a project",
83
+ "summary": "Give an AI agent's browser a short sign-in to one project, so it can look at and click through your app as you."
84
+ },
80
85
  {
81
86
  "slug": "porting-nextjs",
82
87
  "title": "Porting a Next.js app",
@@ -311,6 +316,11 @@
311
316
  "title": "canopy open",
312
317
  "summary": "Open the deployed project in your browser."
313
318
  },
319
+ {
320
+ "slug": "cli-browse",
321
+ "title": "canopy browse",
322
+ "summary": "Print a one-time sign-in link that lets an agent's browser open a project as you."
323
+ },
314
324
  {
315
325
  "slug": "cli-docs",
316
326
  "title": "canopy docs",
@@ -210,6 +210,14 @@ Disconnecting also tells Google the grant is over, so access stops immediately
210
210
  rather than lingering. Visitors can drop their own connection with
211
211
  `canopy.google.user().disconnect()`.
212
212
 
213
+ ## Daily limit
214
+
215
+ Each project can make 10,000 Google calls a day. Calls Google turns down count
216
+ too. Once a project reaches its limit, further calls fail until the count starts
217
+ over at midnight UTC. The **Usage** tab shows today's count, the limit, and when
218
+ it resets. If your project needs more, ask the Canopy team; they can raise the
219
+ limit for your project.
220
+
213
221
  ## Local development
214
222
 
215
223
  `canopy dev` uses the real connections, so Google works the same on your
@@ -141,6 +141,9 @@ the dashboard (or `canopy db access` from the terminal):
141
141
  `collection("private_notes")` get a 403. Raw SQL cannot be checked table by
142
142
  table, so while your database holds any `private_` name, `db.sql()` from a
143
143
  page is refused too; use collections there, or run the SQL in the worker.
144
+ `canopy dev --local` applies the same rule, so you can test it on your
145
+ laptop. Plain `canopy dev` doesn't: your browser shares the dev server's
146
+ token there.
144
147
 
145
148
  Everyone who opens the app gets the same rule — colleagues signed in with
146
149
  OneLogin and, if you turned on [client access](https://docs.canopy.website/platform/client-access),
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CQIPmdcO.mjs";
1
+ import { t as version } from "./package-BOXNJrWP.mjs";
2
2
  import { n as siteConfig } from "./site-DM2J10CB.mjs";
3
3
  import { i as latestKnownVersion, r as compareSemver } from "./version-check-CcsA1QjI.mjs";
4
4
  import { r as reportErrors } from "./ui-C_jSc1Kz.mjs";
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CQIPmdcO.mjs";
1
+ import { t as version } from "./package-BOXNJrWP.mjs";
2
2
  import { a as projectDirArgs, c as resolveProject, d as tryResolveProject, n as siteConfig, u as tryReadCanopyJson } from "./site-DM2J10CB.mjs";
3
3
  import { a as apiUpload, i as apiRequest, r as apiDownload } from "./api-C4rMhE7i.mjs";
4
4
  import { r as reportErrors, t as formatDateTime } from "./ui-C_jSc1Kz.mjs";
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as version } from "./package-CQIPmdcO.mjs";
2
+ import { t as version } from "./package-BOXNJrWP.mjs";
3
3
  import { r as siteHeaderLine, t as setInvocationProfile } from "./site-DM2J10CB.mjs";
4
4
  import { a as notifyOfUpdate, o as runRefresh } from "./version-check-CcsA1QjI.mjs";
5
5
  import { defineCommand, runMain } from "citty";
@@ -20,6 +20,7 @@ import { defineCommand, runMain } from "citty";
20
20
  * ai models the site's AI models and auto/* aliases
21
21
  * mcp add|serve a project's tools for AI agents (docs/49)
22
22
  * open open the deployed project / dashboard [phase 1]
23
+ * browse one-time sign-in link for an agent's browser
23
24
  */
24
25
  if (process.env["CANOPY_UPDATE_REFRESH"] === "1") {
25
26
  await runRefresh();
@@ -69,7 +70,7 @@ const main = defineCommand({
69
70
  init: () => import("./projects-DQAhCZm8.mjs").then((m) => m.initCommand),
70
71
  projects: () => import("./projects-DQAhCZm8.mjs").then((m) => m.projectsCommand),
71
72
  delete: () => import("./projects-DQAhCZm8.mjs").then((m) => m.deleteCommand),
72
- dev: () => import("./dev-MoHHjQh1.mjs").then((m) => m.devCommand),
73
+ dev: () => import("./dev-CID2LCES.mjs").then((m) => m.devCommand),
73
74
  deploy: () => import("./deploy-Ka2lHkyy.mjs").then((m) => m.deployCommand),
74
75
  promote: () => import("./deploy-Ka2lHkyy.mjs").then((m) => m.promoteCommand),
75
76
  staging: () => import("./staging-BZVty57b.mjs").then((m) => m.stagingCommand),
@@ -79,16 +80,17 @@ const main = defineCommand({
79
80
  egress: () => import("./egress-If_Gsx_M.mjs").then((m) => m.egressCommand),
80
81
  secrets: () => import("./secrets-BSEjZnxg.mjs").then((m) => m.secretsCommand),
81
82
  schedule: () => import("./schedule-C5lMf3ny.mjs").then((m) => m.scheduleCommand),
82
- feedback: () => import("./feedback-5oPSxAz0.mjs").then((m) => m.feedbackCommand),
83
+ feedback: () => import("./feedback-CrmDfqmZ.mjs").then((m) => m.feedbackCommand),
83
84
  db: () => import("./db-BB6O6085.mjs").then((m) => m.dbCommand),
84
85
  usage: () => import("./usage-CUYrWxv0.mjs").then((m) => m.usageCommand),
85
86
  ai: () => import("./ai-DHLnyqfq.mjs").then((m) => m.aiCommand),
86
87
  open: () => import("./open-DcaQKi1v.mjs").then((m) => m.openCommand),
87
- docs: () => import("./docs-BP5jmD10.mjs").then((m) => m.docsCommand),
88
- skill: () => import("./skill-Dg7dQF0k.mjs").then((m) => m.skillCommand),
89
- upgrade: () => import("./upgrade-huJDjhrV.mjs").then((m) => m.upgradeCommand),
88
+ browse: () => import("./browse-Dycwj0V_.mjs").then((m) => m.browseCommand),
89
+ docs: () => import("./docs-CGrXLl7G.mjs").then((m) => m.docsCommand),
90
+ skill: () => import("./skill-GPJrYL61.mjs").then((m) => m.skillCommand),
91
+ upgrade: () => import("./upgrade-DSTg2cgu.mjs").then((m) => m.upgradeCommand),
90
92
  logs: () => import("./logs-DQQOawOG.mjs").then((m) => m.logsCommand),
91
- mcp: () => import("./mcp-DXfTYVN_.mjs").then((m) => m.mcpCommand),
93
+ mcp: () => import("./mcp-DAaO4SQc.mjs").then((m) => m.mcpCommand),
92
94
  repo: () => import("./repo-1RQ1vBMv.mjs").then((m) => m.repoCommand)
93
95
  }
94
96
  });
@@ -1,10 +1,54 @@
1
1
  import { n as apiDataRequest } from "./api-C4rMhE7i.mjs";
2
- import { a as dbRouter, i as realtimeRouter, o as META_COLUMNS, s as blobRouter } from "./src-BkACMkol.mjs";
3
- import { i as rewriteApiPath } from "./dev-CkhBnSlv.mjs";
2
+ import { a as realtimeRouter, c as META_COLUMNS, l as blobRouter, n as WORKER_ATTEST_HEADER, o as dbRouter, s as hasPrivateNames } from "./src-CxzGOEhP.mjs";
3
+ import { i as rewriteApiPath } from "./dev-oS4nYMSn.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";
7
7
  import { Hono } from "hono";
8
+ //#region ../data-plane/src/db-access.ts
9
+ /** The worker-only marker: `private_` at the start of a collection or table name. */
10
+ const PRIVATE_PREFIX = "private_";
11
+ const isPrivateName = (name) => name.startsWith(PRIVATE_PREFIX);
12
+ const CHANGE_HINT = "Change it under Data in the dashboard, or move this call into the worker.";
13
+ /** The policy matrix. Only meaningful for callers where isBrowserOnProject() is true. */
14
+ function decideBrowserDbAccess(level, op) {
15
+ if (level === "off") return {
16
+ allow: false,
17
+ error: "browser_db_access",
18
+ detail: `Browser access to this project's database is off. ${CHANGE_HINT}`
19
+ };
20
+ if ((op.kind === "read" || op.kind === "write") && isPrivateName(op.collection)) return {
21
+ allow: false,
22
+ error: "private_collection",
23
+ detail: `"${op.collection}" is a private_ collection — only the project's worker can use it.`
24
+ };
25
+ if (op.kind === "sql") {
26
+ if (op.privateTables) return {
27
+ allow: false,
28
+ error: "private_tables_present",
29
+ detail: "This project has private_ tables or collections, so raw SQL is not available from the browser. Use collections here, or run the SQL in the worker."
30
+ };
31
+ if (level === "readonly") {
32
+ if (op.readonly === false) return {
33
+ allow: false,
34
+ error: "browser_db_access",
35
+ detail: `Browser access to this project's database is read-only. ${CHANGE_HINT}`
36
+ };
37
+ return {
38
+ allow: true,
39
+ forceReadonly: true
40
+ };
41
+ }
42
+ return { allow: true };
43
+ }
44
+ if (op.kind === "write" && level === "readonly") return {
45
+ allow: false,
46
+ error: "browser_db_access",
47
+ detail: `Browser access to this project's database is read-only. ${CHANGE_HINT}`
48
+ };
49
+ return { allow: true };
50
+ }
51
+ //#endregion
8
52
  //#region ../data-plane/src/dialect-postgres.ts
9
53
  /**
10
54
  * Postgres doc-store dialect (Cloud SQL, PGlite). Same table shape as SQLite
@@ -778,6 +822,7 @@ async function createPgliteDb(persistDir) {
778
822
  const tables = (await db.query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`)).rows;
779
823
  for (const { tablename } of tables) await db.exec(`DROP TABLE IF EXISTS "${tablename.replaceAll("\"", "\"\"")}" CASCADE`);
780
824
  },
825
+ withoutForeignKeys: (fn) => fn(),
781
826
  close: () => db.close()
782
827
  };
783
828
  }
@@ -811,6 +856,14 @@ async function createSqliteDb(persistDir) {
811
856
  for (const name of listTables(`sql LIKE 'CREATE VIRTUAL TABLE%'`)) db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
812
857
  for (const name of listTables(`name NOT LIKE 'sqlite_%'`)) db.exec(`DROP TABLE IF EXISTS "${name.replaceAll("\"", "\"\"")}"`);
813
858
  },
859
+ withoutForeignKeys: async (fn) => {
860
+ db.exec("PRAGMA foreign_keys = OFF");
861
+ try {
862
+ return await fn();
863
+ } finally {
864
+ db.exec("PRAGMA foreign_keys = ON");
865
+ }
866
+ },
814
867
  close: async () => {
815
868
  db.close();
816
869
  }
@@ -827,10 +880,25 @@ async function createSandbox(options) {
827
880
  const requested = c.req.header("x-canopy-project") ?? c.req.query("project");
828
881
  return requested === void 0 || requested === options.slug ? null : c.json({ error: "project_not_found" }, 404);
829
882
  };
830
- app.route("/data/v1", dbRouter({ store: async (c) => ownProject(c) ?? {
831
- store,
832
- dialect
833
- } }));
883
+ app.route("/data/v1", dbRouter({
884
+ store: async (c) => ownProject(c) ?? {
885
+ store,
886
+ dialect
887
+ },
888
+ guard: async (c, op, resolved) => {
889
+ const attest = c.req.header(WORKER_ATTEST_HEADER);
890
+ if (options.workerAttest && attest === options.workerAttest) return void 0;
891
+ const decision = decideBrowserDbAccess("full", op.kind === "sql" ? {
892
+ kind: "sql",
893
+ readonly: op.readonly,
894
+ privateTables: await hasPrivateNames(resolved.store, resolved.dialect.name, PRIVATE_PREFIX)
895
+ } : op);
896
+ return decision.allow ? void 0 : c.json({
897
+ error: decision.error,
898
+ detail: decision.detail
899
+ }, 403);
900
+ }
901
+ }));
834
902
  app.route("/data/v1", blobRouter({
835
903
  store: async (c) => ownProject(c) ?? {
836
904
  slug: options.slug,
@@ -900,15 +968,17 @@ async function createSandbox(options) {
900
968
  return app.fetch(new Request(`http://sandbox${pathname}${url.search}`, request));
901
969
  }
902
970
  async function applyDump(sql) {
903
- await db.reset();
904
- for (const statement of splitSqlStatements(sql)) {
905
- if (/^(BEGIN|COMMIT|ROLLBACK|SET\s+search_path|PRAGMA)\b/i.test(statement)) continue;
906
- try {
907
- await db.exec(statement);
908
- } catch (err) {
909
- throw new Error(`Dump statement failed: ${err instanceof Error ? err.message : String(err)}\n in: ${statement.slice(0, 120)}${statement.length > 120 ? "…" : ""}`);
971
+ await db.withoutForeignKeys(async () => {
972
+ await db.reset();
973
+ for (const statement of splitSqlStatements(sql)) {
974
+ if (/^(BEGIN|COMMIT|ROLLBACK|SET\s+search_path|PRAGMA)\b/i.test(statement)) continue;
975
+ try {
976
+ await db.exec(statement);
977
+ } catch (err) {
978
+ throw new Error(`Dump statement failed: ${err instanceof Error ? err.message : String(err)}\n in: ${statement.slice(0, 120)}${statement.length > 120 ? "…" : ""}`);
979
+ }
910
980
  }
911
- }
981
+ });
912
982
  await rebuildFtsIndex(store, dialect);
913
983
  }
914
984
  return {
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CQIPmdcO.mjs";
1
+ import { t as version } from "./package-BOXNJrWP.mjs";
2
2
  import { d as tryResolveProject, l as stagingUrl, n as siteConfig, s as projectUrl } from "./site-DM2J10CB.mjs";
3
3
  import { i as apiRequest, t as ApiError } from "./api-C4rMhE7i.mjs";
4
4
  import { t as liveDevServer } from "./dev-server-file-Dk9pk4AR.mjs";
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.7.33";
2
+ var version = "0.7.35";
3
3
  //#endregion
4
4
  export { version as t };
@@ -97,6 +97,12 @@ and never go stale.
97
97
  (own database cloned from live; `canopy staging status | sync | destroy`)
98
98
  - Roll back: `canopy rollback`
99
99
  - Open the live site: `canopy open`
100
+ - **Look at a deployed project in your own browser** (Playwright, a browser
101
+ tool, curl): `canopy browse <slug> --json` prints a one-time sign-in link
102
+ (`--staging`, `--path /page`). Open it within a minute; that browser is
103
+ then signed in to that one project, as the user, for an hour. On a sign-in
104
+ page later, run it again. Never ask the user for their session cookie
105
+ (`canopy docs agent-browsing`).
100
106
  - Manage data: `canopy db pull | snapshot | snapshots | restore | migrate`
101
107
  - Use AI (`canopy.ai`): run `canopy ai models` before naming a model in code.
102
108
  Prefer the aliases `auto/low` (default) / `auto/mid` / `auto/high` over
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-CQIPmdcO.mjs";
1
+ import { t as version } from "./package-BOXNJrWP.mjs";
2
2
  import { r as reportErrors } from "./ui-C_jSc1Kz.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { cpSync, existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -292,6 +292,21 @@ async function introspectSchema(store, dialect) {
292
292
  tables
293
293
  };
294
294
  }
295
+ /**
296
+ * Does the database hold any table or doc collection whose name starts with
297
+ * `prefix`? Two cheap probes — the browser-SQL guard (canopy-api, docs/35)
298
+ * asks this on every page-originated raw-SQL call, so it must not walk the
299
+ * schema. `prefix` is interpolated as a LIKE pattern; callers pass a fixed
300
+ * identifier fragment (`private_`), never user input.
301
+ */
302
+ async function hasPrivateNames(store, dialect, prefix) {
303
+ const pattern = lit(`${prefix.replaceAll("_", "\\_")}%`);
304
+ const [table, collection] = await Promise.all([store.query(dialect === "postgres" ? `SELECT 1 AS found FROM information_schema.tables
305
+ WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'
306
+ AND table_name LIKE ${pattern} ESCAPE '\\' LIMIT 1` : `SELECT 1 AS found FROM sqlite_master
307
+ WHERE type = 'table' AND name LIKE ${pattern} ESCAPE '\\' LIMIT 1`).then((r) => r.results.length > 0), store.query(`SELECT 1 AS found FROM _canopy_docs WHERE collection LIKE ${pattern} ESCAPE '\\' LIMIT 1`).then((r) => r.results.length > 0).catch(() => false)]);
308
+ return table || collection;
309
+ }
295
310
  //#endregion
296
311
  //#region ../data-plane/src/db.ts
297
312
  /**
@@ -844,6 +859,12 @@ const IDENTITY_STAMP_HEADERS = [
844
859
  "x-canopy-user-name"
845
860
  ];
846
861
  /**
862
+ * Header carrying the project's worker token as runtime-origin proof (docs/35).
863
+ * The data plane verifies it to exempt the worker from browser DB access even
864
+ * when the worker is acting as a visitor (forwarding their cookie).
865
+ */
866
+ const WORKER_ATTEST_HEADER = "x-canopy-worker-attest";
867
+ /**
847
868
  * Header the gateway attaches to a project tools call (`/__canopy/mcp`,
848
869
  * docs/49): the caller's agent token. A credential like the session cookie —
849
870
  * the host keeps it for `env.CANOPY.fetch` and redacts it from the bundle.
@@ -857,7 +878,11 @@ const AGENT_CREDENTIAL_HEADER = "x-canopy-agent-credential";
857
878
  */
858
879
  const CALLER_HEADER = "x-canopy-caller";
859
880
  /** Cookies that authenticate to Canopy itself — the monk session and the client-page session. */
860
- const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set(["canopy_session", "canopy_client"]);
881
+ const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set([
882
+ "canopy_session",
883
+ "canopy_client",
884
+ "canopy_agent"
885
+ ]);
861
886
  /**
862
887
  * Strip Canopy's own credentials from a request before untrusted project code
863
888
  * sees it (R-01).
@@ -870,7 +895,7 @@ const CANOPY_COOKIE_NAMES = /* @__PURE__ */ new Set(["canopy_session", "canopy_c
870
895
  *
871
896
  * Redaction is surgical on purpose: an app's own cookies and its own
872
897
  * Authorization scheme survive, because those belong to the app. Only
873
- * `canopy_session` / `canopy_client` and a `cnpy_` bearer are removed.
898
+ * `canopy_session` / `canopy_client` / `canopy_agent` and a `cnpy_` bearer are removed.
874
899
  *
875
900
  * `env.CANOPY.fetch` still authenticates as the visitor — the host passes it
876
901
  * the unredacted headers — so nothing about the platform-facing behaviour
@@ -903,4 +928,4 @@ function stampWorkerHeaders(headers, ctx) {
903
928
  return headers;
904
929
  }
905
930
  //#endregion
906
- export { dbRouter as a, realtimeRouter as i, redactCanopyCredentials as n, META_COLUMNS as o, stampWorkerHeaders as r, blobRouter as s, CALLER_HEADER as t };
931
+ export { realtimeRouter as a, META_COLUMNS as c, stampWorkerHeaders as i, blobRouter as l, WORKER_ATTEST_HEADER as n, dbRouter as o, redactCanopyCredentials as r, hasPrivateNames as s, CALLER_HEADER as t };
@@ -112,7 +112,7 @@ const upgradeCommand = defineCommand({
112
112
  const spec = `${PACKAGE_NAME}@${args.version ?? "latest"}`;
113
113
  const command = pmCommand(pm);
114
114
  const cmd = installArgs(pm, spec);
115
- const { skillInstalled } = await import("./skill-Dg7dQF0k.mjs");
115
+ const { skillInstalled } = await import("./skill-GPJrYL61.mjs");
116
116
  const { homedir } = await import("node:os");
117
117
  const refreshGlobal = skillInstalled(homedir());
118
118
  const refreshRepo = process.cwd() !== homedir() && skillInstalled(process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mediamonks/canopy-cli",
3
- "version": "0.7.33",
3
+ "version": "0.7.35",
4
4
  "description": "Canopy CLI — create, develop and deploy Canopy projects.",
5
5
  "bin": {
6
6
  "canopy": "./dist/index.mjs"
@@ -1,2 +0,0 @@
1
- import { n as devCommand } from "./dev-CkhBnSlv.mjs";
2
- export { devCommand };