@mediamonks/canopy-cli 0.7.34 → 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.
- package/dist/dev-CID2LCES.mjs +2 -0
- package/dist/{dev-CGSIY1-y.mjs → dev-oS4nYMSn.mjs} +14 -8
- package/dist/{dev-worker-JbLEXmVg.mjs → dev-worker-CpSB_Okh.mjs} +5 -3
- package/dist/docs/cli-dev.md +2 -2
- package/dist/docs/platform-google.md +8 -0
- package/dist/docs/sdk-database.md +3 -0
- package/dist/{docs-Cdoh5sMW.mjs → docs-CGrXLl7G.mjs} +1 -1
- package/dist/{feedback-vvysW6iw.mjs → feedback-CrmDfqmZ.mjs} +1 -1
- package/dist/index.mjs +7 -7
- package/dist/{local-qR8CBseB.mjs → local-Cc8H3NUw.mjs} +84 -14
- package/dist/{mcp-OR_ashGC.mjs → mcp-DAaO4SQc.mjs} +1 -1
- package/dist/{package-Doigafvd.mjs → package-BOXNJrWP.mjs} +1 -1
- package/dist/{skill-DfSAZ2cW.mjs → skill-GPJrYL61.mjs} +1 -1
- package/dist/{src-DE37PKho.mjs → src-CxzGOEhP.mjs} +22 -1
- package/dist/{upgrade-DCQ3NzoL.mjs → upgrade-DSTg2cgu.mjs} +1 -1
- package/package.json +1 -1
- package/dist/dev-DO6Eso4L.mjs +0 -2
|
@@ -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 {
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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) {
|
package/dist/docs/cli-dev.md
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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";
|
|
@@ -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-
|
|
73
|
+
dev: () => import("./dev-CID2LCES.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-
|
|
83
|
+
feedback: () => import("./feedback-CrmDfqmZ.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-
|
|
90
|
-
skill: () => import("./skill-
|
|
91
|
-
upgrade: () => import("./upgrade-
|
|
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),
|
|
92
92
|
logs: () => import("./logs-DQQOawOG.mjs").then((m) => m.logsCommand),
|
|
93
|
-
mcp: () => import("./mcp-
|
|
93
|
+
mcp: () => import("./mcp-DAaO4SQc.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
|
|
3
|
-
import { i as rewriteApiPath } from "./dev-
|
|
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({
|
|
831
|
-
store
|
|
832
|
-
|
|
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.
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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-
|
|
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
|
-
import { t as version } from "./package-
|
|
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.
|
|
@@ -907,4 +928,4 @@ function stampWorkerHeaders(headers, ctx) {
|
|
|
907
928
|
return headers;
|
|
908
929
|
}
|
|
909
930
|
//#endregion
|
|
910
|
-
export {
|
|
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-
|
|
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
package/dist/dev-DO6Eso4L.mjs
DELETED