@mediamonks/canopy-cli 0.7.34 → 0.7.36

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.
@@ -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-DE37PKho.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,28 +34,30 @@ 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, remoteUser) {
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-JbLEXmVg.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;
47
+ const user = await remoteUser?.() ?? (email ? {
48
+ sub: "local-dev",
49
+ email,
50
+ name: "Local Dev"
51
+ } : null);
46
52
  const plugin = workerDevPlugin({
47
53
  dir: flags.dir,
48
54
  entry,
49
55
  slug,
50
56
  channel: flags.staging ? "staging" : "live",
51
57
  secrets,
52
- user: email ? {
53
- sub: "local-dev",
54
- email,
55
- name: "Local Dev"
56
- } : null,
57
- dialect: workerDialect(flags)
58
+ user,
59
+ dialect: workerDialect(flags),
60
+ workerAttest
58
61
  });
59
62
  const notes = [` /api/* is served by ${entry} (edit and refresh; no build step)`];
60
63
  if (Object.keys(secrets).length > 0) notes.push(` worker secrets from .dev.vars: ${Object.keys(secrets).sort().join(", ")}`);
@@ -154,6 +157,26 @@ function devProxyHeaders(token, slug, channel) {
154
157
  return Object.fromEntries(headers);
155
158
  }
156
159
  /**
160
+ * The signed-in person as the platform sees them — what auth.user() answers
161
+ * in the browser, so env.USER in a live-data dev worker matches it.
162
+ * Undefined on any failure: dev still starts, with the local stub.
163
+ */
164
+ async function fetchDevUser(apiUrl, headers) {
165
+ try {
166
+ const res = await fetch(`${apiUrl}/data/v1/auth/me`, { headers });
167
+ if (!res.ok) return void 0;
168
+ const me = await res.json();
169
+ if (!me.id) return void 0;
170
+ return {
171
+ sub: me.id,
172
+ email: me.email ?? "",
173
+ name: me.name ?? ""
174
+ };
175
+ } catch {
176
+ return;
177
+ }
178
+ }
179
+ /**
157
180
  * One request from Vite's /_api proxy into the --local sandbox. The response
158
181
  * body is streamed, not buffered — realtime subscriptions (docs/48) are
159
182
  * long-lived SSE responses — and a client that goes away aborts the sandbox
@@ -168,6 +191,8 @@ async function bridgeToSandbox(sandbox, req, res) {
168
191
  const headers = new Headers();
169
192
  if (req.headers["content-type"]) headers.set("content-type", req.headers["content-type"]);
170
193
  if (req.headers["accept"]) headers.set("accept", req.headers["accept"]);
194
+ const attest = req.headers[WORKER_ATTEST_HEADER];
195
+ if (typeof attest === "string") headers.set(WORKER_ATTEST_HEADER, attest);
171
196
  const response = await sandbox.handle(new Request(`http://canopy.local${req.url ?? ""}`, {
172
197
  method: req.method,
173
198
  headers,
@@ -198,11 +223,12 @@ async function bridgeToSandbox(sandbox, req, res) {
198
223
  async function devRemote(flags, slug) {
199
224
  const { token, expires_at } = await withContext(apiRequest("POST", `/projects/${slug}/dev-token`), devTokenContext(slug));
200
225
  const { apiUrl } = siteConfig();
201
- const worker = await workerPlugin(flags, slug);
226
+ const channel = flags.staging ? "staging" : "live";
227
+ const worker = await workerPlugin(flags, slug, void 0, () => fetchDevUser(apiUrl, devProxyHeaders(token, slug, channel)));
202
228
  const server = await startVite(flags, { proxy: { "/_api": {
203
229
  target: apiUrl,
204
230
  changeOrigin: true,
205
- headers: devProxyHeaders(token, slug, flags.staging ? "staging" : "live"),
231
+ headers: devProxyHeaders(token, slug, channel),
206
232
  rewrite: rewriteApiPath
207
233
  } } }, worker ? [worker.plugin] : []);
208
234
  if (flags.staging) {
@@ -225,7 +251,7 @@ async function devRemote(flags, slug) {
225
251
  async function devLocal(flags, slug) {
226
252
  let dump;
227
253
  if (flags.pull) {
228
- const { fetchProjectDump } = await import("./local-qR8CBseB.mjs");
254
+ const { fetchProjectDump } = await import("./local-Cyl39_Zu.mjs");
229
255
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
230
256
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
231
257
  }
@@ -240,12 +266,14 @@ async function devLocal(flags, slug) {
240
266
  const reason = err instanceof ApiError ? err.explain().why : String(err);
241
267
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
242
268
  }
243
- const { createSandbox } = await import("./local-qR8CBseB.mjs");
269
+ const workerAttest = randomBytes(24).toString("base64url");
270
+ const { createSandbox } = await import("./local-Cyl39_Zu.mjs");
244
271
  const sandbox = await createSandbox({
245
272
  slug,
246
273
  persistDir: join(flags.dir, ".canopy", "local"),
247
274
  email: siteConfig().email,
248
- dialect: flags.dialect
275
+ dialect: flags.dialect,
276
+ workerAttest
249
277
  });
250
278
  if (dump !== void 0) {
251
279
  await sandbox.applyDump(dump);
@@ -276,7 +304,7 @@ async function devLocal(flags, slug) {
276
304
  headers: { authorization: `Bearer ${remote.token}` },
277
305
  rewrite: rewriteApiPath
278
306
  };
279
- const worker = await workerPlugin(flags, slug);
307
+ const worker = await workerPlugin(flags, slug, workerAttest);
280
308
  const server = await startVite(flags, { proxy: {
281
309
  ...remoteEntry && {
282
310
  "/_api/data/v1/ai": remoteEntry,
@@ -348,4 +376,4 @@ const devCommand = defineCommand({
348
376
  })
349
377
  });
350
378
  //#endregion
351
- export { rewriteApiPath as i, devCommand as n, devProxyHeaders as r, bridgeToSandbox as t };
379
+ export { rewriteApiPath as a, fetchDevUser as i, devCommand as n, devProxyHeaders as r, bridgeToSandbox as t };
@@ -0,0 +1,2 @@
1
+ import { n as devCommand } from "./dev-BdXslHoA.mjs";
2
+ export { devCommand };
@@ -1,4 +1,4 @@
1
- import { n as redactCanopyCredentials, r as stampWorkerHeaders, t as CALLER_HEADER } from "./src-DE37PKho.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) {
@@ -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
 
@@ -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-Doigafvd.mjs";
1
+ import { t as version } from "./package-lYhex2Gp.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-Doigafvd.mjs";
1
+ import { t as version } from "./package-lYhex2Gp.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-Doigafvd.mjs";
2
+ import { t as version } from "./package-lYhex2Gp.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";
@@ -70,7 +70,7 @@ const main = defineCommand({
70
70
  init: () => import("./projects-DQAhCZm8.mjs").then((m) => m.initCommand),
71
71
  projects: () => import("./projects-DQAhCZm8.mjs").then((m) => m.projectsCommand),
72
72
  delete: () => import("./projects-DQAhCZm8.mjs").then((m) => m.deleteCommand),
73
- dev: () => import("./dev-DO6Eso4L.mjs").then((m) => m.devCommand),
73
+ dev: () => import("./dev-BeFX_wc5.mjs").then((m) => m.devCommand),
74
74
  deploy: () => import("./deploy-Ka2lHkyy.mjs").then((m) => m.deployCommand),
75
75
  promote: () => import("./deploy-Ka2lHkyy.mjs").then((m) => m.promoteCommand),
76
76
  staging: () => import("./staging-BZVty57b.mjs").then((m) => m.stagingCommand),
@@ -80,17 +80,17 @@ const main = defineCommand({
80
80
  egress: () => import("./egress-If_Gsx_M.mjs").then((m) => m.egressCommand),
81
81
  secrets: () => import("./secrets-BSEjZnxg.mjs").then((m) => m.secretsCommand),
82
82
  schedule: () => import("./schedule-C5lMf3ny.mjs").then((m) => m.scheduleCommand),
83
- feedback: () => import("./feedback-vvysW6iw.mjs").then((m) => m.feedbackCommand),
83
+ feedback: () => import("./feedback-DzEKQlP6.mjs").then((m) => m.feedbackCommand),
84
84
  db: () => import("./db-BB6O6085.mjs").then((m) => m.dbCommand),
85
85
  usage: () => import("./usage-CUYrWxv0.mjs").then((m) => m.usageCommand),
86
86
  ai: () => import("./ai-DHLnyqfq.mjs").then((m) => m.aiCommand),
87
87
  open: () => import("./open-DcaQKi1v.mjs").then((m) => m.openCommand),
88
88
  browse: () => import("./browse-Dycwj0V_.mjs").then((m) => m.browseCommand),
89
- docs: () => import("./docs-Cdoh5sMW.mjs").then((m) => m.docsCommand),
90
- skill: () => import("./skill-DfSAZ2cW.mjs").then((m) => m.skillCommand),
91
- upgrade: () => import("./upgrade-DCQ3NzoL.mjs").then((m) => m.upgradeCommand),
89
+ docs: () => import("./docs-Bb9hSk4h.mjs").then((m) => m.docsCommand),
90
+ skill: () => import("./skill-BUA2uRR2.mjs").then((m) => m.skillCommand),
91
+ upgrade: () => import("./upgrade-ZSb56qBJ.mjs").then((m) => m.upgradeCommand),
92
92
  logs: () => import("./logs-DQQOawOG.mjs").then((m) => m.logsCommand),
93
- mcp: () => import("./mcp-OR_ashGC.mjs").then((m) => m.mcpCommand),
93
+ mcp: () => import("./mcp-pNp-sbfJ.mjs").then((m) => m.mcpCommand),
94
94
  repo: () => import("./repo-1RQ1vBMv.mjs").then((m) => m.repoCommand)
95
95
  }
96
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-DE37PKho.mjs";
3
- import { i as rewriteApiPath } from "./dev-CGSIY1-y.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 { a as rewriteApiPath } from "./dev-BdXslHoA.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-Doigafvd.mjs";
1
+ import { t as version } from "./package-lYhex2Gp.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.34";
2
+ var version = "0.7.36";
3
3
  //#endregion
4
4
  export { version as t };
@@ -1,4 +1,4 @@
1
- import { t as version } from "./package-Doigafvd.mjs";
1
+ import { t as version } from "./package-lYhex2Gp.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.
@@ -907,4 +928,4 @@ function stampWorkerHeaders(headers, ctx) {
907
928
  return headers;
908
929
  }
909
930
  //#endregion
910
- 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-DfSAZ2cW.mjs");
115
+ const { skillInstalled } = await import("./skill-BUA2uRR2.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.34",
3
+ "version": "0.7.36",
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-CGSIY1-y.mjs";
2
- export { devCommand };