@mediamonks/canopy-cli 0.7.42 → 0.7.44

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.
Files changed (63) hide show
  1. package/dist/{ai-DpQ1nLka.mjs → ai-FBHTSCLZ.mjs} +2 -2
  2. package/dist/{api-Do9c8tZ_.mjs → api-Bl6SG8Oq.mjs} +2 -2
  3. package/dist/{browse-Dp5HytqK.mjs → browse-B8-0dweq.mjs} +2 -2
  4. package/dist/{db-BNsX7sXo.mjs → db-BJJGydgL.mjs} +3 -3
  5. package/dist/{deploy-CXMLABvH.mjs → deploy-ExrIeX_m.mjs} +22 -3
  6. package/dist/dev-CUJKgaqJ.mjs +2 -0
  7. package/dist/{dev-CPR-9slK.mjs → dev-Cq98yblV.mjs} +98 -16
  8. package/dist/{dev-worker-D78leKBJ.mjs → dev-worker-CcYwQjxz.mjs} +23 -10
  9. package/dist/docs/agent-browsing.md +2 -2
  10. package/dist/docs/agent-tools.md +2 -2
  11. package/dist/docs/ci.md +1 -1
  12. package/dist/docs/cli-browse.md +1 -1
  13. package/dist/docs/cli-dev.md +1 -0
  14. package/dist/docs/cli-mcp.md +1 -1
  15. package/dist/docs/cli-members.md +41 -0
  16. package/dist/docs/cli-roles.md +39 -0
  17. package/dist/docs/cli-token.md +1 -1
  18. package/dist/docs/developers-architecture.md +12 -0
  19. package/dist/docs/index.json +16 -1
  20. package/dist/docs/overview-faq.md +6 -4
  21. package/dist/docs/overview-how-it-works.md +3 -2
  22. package/dist/docs/overview-use-cases.md +1 -1
  23. package/dist/docs/overview-what-is-canopy.md +1 -1
  24. package/dist/docs/platform-client-access.md +1 -1
  25. package/dist/docs/platform-egress.md +14 -7
  26. package/dist/docs/platform-members-and-access.md +66 -42
  27. package/dist/docs/roles.md +100 -0
  28. package/dist/docs/sdk-auth.md +24 -10
  29. package/dist/docs/sdk-realtime.md +1 -1
  30. package/dist/docs/static-serving.md +14 -5
  31. package/dist/{docs-CkJGcCpR.mjs → docs-KmN6jD1S.mjs} +3 -3
  32. package/dist/{egress-D6qyaaAn.mjs → egress-C2amvqmE.mjs} +2 -2
  33. package/dist/{feedback-DMgUUl0V.mjs → feedback-B5-95neo.mjs} +3 -3
  34. package/dist/index.mjs +34 -32
  35. package/dist/{local-w2wXfE1d.mjs → local-CIkL7tXP.mjs} +5 -4
  36. package/dist/{login-CeNvHLUv.mjs → login-CREcZfLH.mjs} +2 -2
  37. package/dist/{logs-BzgJfWNP.mjs → logs-BnrNZvLc.mjs} +2 -2
  38. package/dist/{mcp-DFLaXiY_.mjs → mcp-DZdMNTWr.mjs} +3 -3
  39. package/dist/{migrations-Bsu4quya.mjs → migrations-D8NqjC5I.mjs} +1 -1
  40. package/dist/{open-D71Ms1nG.mjs → open-Bpz4C2Sr.mjs} +1 -1
  41. package/dist/{package-CVDpzGgA.mjs → package-Bvly8-rB.mjs} +1 -1
  42. package/dist/project-template-715H_lnu.mjs +2 -0
  43. package/dist/{project-template-BXfHpsI8.mjs → project-template-Cdr1uwwE.mjs} +2 -2
  44. package/dist/{projects-BWTIk80a.mjs → projects-BRG2HnBs.mjs} +4 -4
  45. package/dist/{rename-BdIFrqvG.mjs → rename-BGKa25Rq.mjs} +2 -2
  46. package/dist/{repo-LSOxnmUk.mjs → repo-CwT2RDoW.mjs} +2 -2
  47. package/dist/repo-DmFs6H9m.mjs +2 -0
  48. package/dist/roles-BDZaN3vi.mjs +2 -0
  49. package/dist/roles-UM0Iiws6.mjs +379 -0
  50. package/dist/{schedule-DpMIpCGC.mjs → schedule-BcqBspn3.mjs} +2 -2
  51. package/dist/{secrets-BVx8L0Gj.mjs → secrets-BjbIi7Gp.mjs} +2 -2
  52. package/dist/skill/canopy/SKILL.md +9 -0
  53. package/dist/{skill-D58yV6K8.mjs → skill-DRLniv-I.mjs} +2 -2
  54. package/dist/{src-RUf3Gfuh.mjs → src-8vxOBOxW.mjs} +763 -2
  55. package/dist/{staging-ynPlMiHW.mjs → staging-_2Js0Fkc.mjs} +2 -2
  56. package/dist/{token-CxiPrKn_.mjs → token-7vSNuz-X.mjs} +2 -2
  57. package/dist/{ui-CDUJq-X-.mjs → ui-CLnICOne.mjs} +1 -1
  58. package/dist/{upgrade-Dll5rhOQ.mjs → upgrade-D_yrD7zp.mjs} +2 -2
  59. package/dist/{usage-BtPsb7Iu.mjs → usage-DGGmTwjJ.mjs} +2 -2
  60. package/package.json +2 -2
  61. package/dist/dev-Dj3wFKHe.mjs +0 -2
  62. package/dist/project-template-BbmSmraT.mjs +0 -2
  63. package/dist/repo-JBKJqyoS.mjs +0 -2
@@ -1,5 +1,5 @@
1
- import { a as apiRequest, f as withContext } from "./api-Do9c8tZ_.mjs";
2
- import { r as reportErrors } from "./ui-CDUJq-X-.mjs";
1
+ import { a as apiRequest, f as withContext } from "./api-Bl6SG8Oq.mjs";
2
+ import { r as reportErrors } from "./ui-CLnICOne.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/ai.ts
5
5
  /**
@@ -57,11 +57,11 @@ const ERROR_COPY = {
57
57
  fix: "Per-project workers, schedules and secrets are capability-gated per target — remove the unsupported part (e.g. the worker bundle) or run against a target that supports it."
58
58
  },
59
59
  deploy_too_large: {
60
- why: "the deploy bundle exceeds the 25 MB limit",
60
+ why: "the deploy bundle exceeds the 50 MB limit",
61
61
  fix: "Trim large assets from the build output (or move them to blob storage), then redeploy."
62
62
  },
63
63
  too_many_files: {
64
- why: "the deploy has more than 500 files",
64
+ why: "the deploy has more than 1000 files",
65
65
  fix: "Reduce the file count — bundle/minify assets or drop what isn't served — then redeploy."
66
66
  },
67
67
  empty_deploy: {
@@ -1,5 +1,5 @@
1
1
  import { p as tryResolveProject, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, t as ApiError } from "./api-Do9c8tZ_.mjs";
2
+ import { a as apiRequest, t as ApiError } from "./api-Bl6SG8Oq.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  //#region src/browse.ts
5
5
  /**
@@ -15,7 +15,7 @@ var BrowseError = class extends Error {
15
15
  /** The control plane's refusal, in words that say what to do about it. */
16
16
  function explainBrowseRefusal(code, slug, detail) {
17
17
  switch (code) {
18
- case "members_only": return `${slug} is limited to its collaborators — ask the owner to add you.`;
18
+ case "members_only": return `${slug} is limited to its members — ask the owner to add you.`;
19
19
  case "project_not_found": return `There is no project ${slug} on this site — check the slug and --profile.`;
20
20
  case "project_paused": return `${slug} is paused.`;
21
21
  case "project_moved": return `${slug} has moved${detail ? ` (${detail})` : ""}.`;
@@ -1,7 +1,7 @@
1
1
  import { l as resolveProject, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, r as apiDataRequest } from "./api-Do9c8tZ_.mjs";
3
- import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CDUJq-X-.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Bsu4quya.mjs";
2
+ import { a as apiRequest, f as withContext, r as apiDataRequest } from "./api-Bl6SG8Oq.mjs";
3
+ import { a as unwrap, i as requireTty, r as reportErrors } from "./ui-CLnICOne.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-D8NqjC5I.mjs";
5
5
  import { defineCommand } from "citty";
6
6
  import { readFileSync, writeFileSync } from "node:fs";
7
7
  import * as p from "@clack/prompts";
@@ -1,7 +1,7 @@
1
1
  import { c as projectUrl, d as stagingUrl, f as tryReadCanopyJson, l as resolveProject, o as projectDirArgs, r as siteConfig, s as projectFlagArgs } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, i as apiDownload, l as currentSlug, r as apiDataRequest } from "./api-Do9c8tZ_.mjs";
3
- import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-CDUJq-X-.mjs";
4
- import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-Bsu4quya.mjs";
2
+ import { a as apiRequest, f as withContext, i as apiDownload, l as currentSlug, r as apiDataRequest } from "./api-Bl6SG8Oq.mjs";
3
+ import { a as unwrap, r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
4
+ import { n as migrateProject, r as readMigrationFiles, t as describeMigrateResult } from "./migrations-D8NqjC5I.mjs";
5
5
  import { builtinModules } from "node:module";
6
6
  import { defineCommand } from "citty";
7
7
  import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -290,6 +290,11 @@ const deployCommand = defineCommand({
290
290
  channel
291
291
  }, void 0, source), { action: `deploy "${slug}"` });
292
292
  stagingCreated = upload.stagingCreated === true;
293
+ const overLimit = bundleLimitError(files, upload);
294
+ if (overLimit) {
295
+ spin.error("Deploy refused — nothing was uploaded.");
296
+ throw new Error(overLimit);
297
+ }
293
298
  const uploads = [...files, ...workerBytes ? [{
294
299
  path: WORKER_BUNDLE_PATH,
295
300
  data: workerBytes
@@ -377,6 +382,20 @@ const promoteCommand = defineCommand({
377
382
  })
378
383
  });
379
384
  const UPLOAD_CONCURRENCY = 8;
385
+ /**
386
+ * Why the asset files exceed the limits the platform stated, or null. The
387
+ * worker bundle has its own key and isn't counted, as on the server. A server
388
+ * that states no limits isn't checked here; completion still enforces them.
389
+ */
390
+ function bundleLimitError(files, limits) {
391
+ if (limits.maxFiles !== void 0 && files.length > limits.maxFiles) return `This deploy has ${files.length} files; the limit is ${limits.maxFiles}. Bundle/minify assets or drop what isn't served, then redeploy.`;
392
+ const bytes = files.reduce((sum, f) => sum + f.data.byteLength, 0);
393
+ if (limits.maxTotalBytes !== void 0 && bytes > limits.maxTotalBytes) {
394
+ const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
395
+ return `This deploy is ${mb(bytes)}; the limit is ${mb(limits.maxTotalBytes)}. Move large images or fonts to file storage, then redeploy.`;
396
+ }
397
+ return null;
398
+ }
380
399
  /** PUT each file to /data/v1/uploads/<grant> — the grant is the credential. */
381
400
  async function uploadFiles(grant, files) {
382
401
  const { apiUrl } = siteConfig();
@@ -0,0 +1,2 @@
1
+ import { n as devCommand } from "./dev-Cq98yblV.mjs";
2
+ export { devCommand };
@@ -1,8 +1,9 @@
1
1
  import { a as projectDialect, f as tryReadCanopyJson, l as resolveProject, o as projectDirArgs, r as siteConfig } from "./site-0i7wAJqg.mjs";
2
- import { a as apiRequest, f as withContext, t as ApiError } from "./api-Do9c8tZ_.mjs";
3
- import { r as reportErrors, t as formatDateTime } from "./ui-CDUJq-X-.mjs";
4
- import { r as readMigrationFiles } from "./migrations-Bsu4quya.mjs";
5
- import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER } from "./src-RUf3Gfuh.mjs";
2
+ import { a as apiRequest, f as withContext, t as ApiError } from "./api-Bl6SG8Oq.mjs";
3
+ import { r as reportErrors, t as formatDateTime } from "./ui-CLnICOne.mjs";
4
+ import { r as readMigrationFiles } from "./migrations-D8NqjC5I.mjs";
5
+ import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER } from "./src-8vxOBOxW.mjs";
6
+ import { a as parseAppRoleFlag } from "./roles-UM0Iiws6.mjs";
6
7
  import { n as writeDevServerRecord } from "./dev-server-file-Dk9pk4AR.mjs";
7
8
  import { createRequire } from "node:module";
8
9
  import { defineCommand } from "citty";
@@ -10,6 +11,71 @@ import { existsSync } from "node:fs";
10
11
  import { join } from "node:path";
11
12
  import { randomBytes } from "node:crypto";
12
13
  import { pathToFileURL } from "node:url";
14
+ //#region src/dev-roles.ts
15
+ /** The roles a visitor holding `asRoles` carries: the default first, then theirs. */
16
+ function overrideRoles(defaultRole, asRoles) {
17
+ const mine = [...new Set(asRoles)].filter((r) => r !== defaultRole);
18
+ return defaultRole ? [defaultRole, ...mine] : mine;
19
+ }
20
+ /** An /auth/me body with `roles` replaced; anything that isn't an object passes through. */
21
+ function patchAuthMeRoles(body, roles) {
22
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return body;
23
+ return {
24
+ ...body,
25
+ roles
26
+ };
27
+ }
28
+ /**
29
+ * The roles this dev session runs with, plus lines to print. `roles` is
30
+ * undefined when nothing could be learned and nothing was asked for — the
31
+ * worker then falls back to whatever /auth/me reports.
32
+ */
33
+ async function resolveDevRoles(slug, asRoles) {
34
+ const notes = [];
35
+ const mine = await apiRequest("GET", `/projects/${slug}/me/roles`).catch(() => void 0);
36
+ if (!asRoles || asRoles.length === 0) {
37
+ if (mine && mine.roles.length > 0) notes.push(` app roles: ${mine.roles.join(", ")}`);
38
+ return {
39
+ roles: mine?.roles,
40
+ override: false,
41
+ notes
42
+ };
43
+ }
44
+ const roles = overrideRoles(mine?.default ?? null, asRoles);
45
+ const known = await apiRequest("GET", `/projects/${slug}/roles`).then((r) => r.roles.map((role) => role.slug)).catch(() => void 0);
46
+ const unknown = known ? asRoles.filter((r) => !known.includes(r)) : [];
47
+ if (unknown.length > 0) notes.push(` warning: ${slug} has no app role ${unknown.join(", ")} — its roles are ${known.length > 0 ? known.join(", ") : "(none yet)"}. Using it anyway.`);
48
+ notes.push(` app roles: ${roles.join(", ") || "(none)"} (--as-role; the platform is unchanged)`);
49
+ return {
50
+ roles,
51
+ override: true,
52
+ notes
53
+ };
54
+ }
55
+ /**
56
+ * Answers `/_api/data/v1/auth/me` with the platform's answer and `roles`
57
+ * swapped, for the page and the worker alike (the worker's CANOPY.fetch goes
58
+ * through the dev server's own `/_api`). Registered as a plain middleware so
59
+ * it runs ahead of Vite's proxy.
60
+ */
61
+ function authMeRolesPlugin(roles, upstream) {
62
+ return {
63
+ name: "canopy-dev-as-role",
64
+ configureServer(server) {
65
+ server.middlewares.use((req, res, next) => {
66
+ const path = (req.url ?? "").split("?")[0];
67
+ if (req.method !== "GET" || path !== "/_api/data/v1/auth/me") return next();
68
+ (async () => {
69
+ const response = await upstream();
70
+ const body = await response.json().catch(() => null);
71
+ res.writeHead(response.status, { "content-type": "application/json" });
72
+ res.end(JSON.stringify(response.ok ? patchAuthMeRoles(body, roles) : body));
73
+ })().catch(next);
74
+ });
75
+ }
76
+ };
77
+ }
78
+ //#endregion
13
79
  //#region src/dev.ts
14
80
  /**
15
81
  * Mirror the gateway (apps/gateway/src/index.ts): strip /_api and forward.
@@ -34,21 +100,25 @@ function workerDialect(flags) {
34
100
  * gateway serves it in production (dev-worker.ts). Returns the plugin plus
35
101
  * the notice lines to print, or undefined when the project has no worker.
36
102
  */
37
- async function workerPlugin(flags, slug, workerAttest, remoteUser) {
103
+ async function workerPlugin(flags, slug, workerAttest, remoteUser, roles) {
38
104
  const entry = tryReadCanopyJson(flags.dir)?.worker;
39
105
  if (entry === void 0) return void 0;
40
106
  if (!existsSync(join(flags.dir, entry))) {
41
107
  console.log(`note: canopy.json points "worker" at ${entry}, which does not exist — skipping.`);
42
108
  return;
43
109
  }
44
- const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-D78leKBJ.mjs");
110
+ const { missingSecretNames, readDevVars, workerDevPlugin } = await import("./dev-worker-CcYwQjxz.mjs");
45
111
  const secrets = readDevVars(flags.dir);
46
112
  const email = siteConfig().email;
47
- const user = await remoteUser?.() ?? (email ? {
113
+ const found = await remoteUser?.() ?? (email ? {
48
114
  sub: "local-dev",
49
115
  email,
50
116
  name: "Local Dev"
51
117
  } : null);
118
+ const user = found && roles ? {
119
+ ...found,
120
+ roles
121
+ } : found;
52
122
  const plugin = workerDevPlugin({
53
123
  dir: flags.dir,
54
124
  entry,
@@ -170,7 +240,8 @@ async function fetchDevUser(apiUrl, headers) {
170
240
  return {
171
241
  sub: me.id,
172
242
  email: me.email ?? "",
173
- name: me.name ?? ""
243
+ name: me.name ?? "",
244
+ ...Array.isArray(me.roles) && { roles: me.roles }
174
245
  };
175
246
  } catch {
176
247
  return;
@@ -223,14 +294,16 @@ async function bridgeToSandbox(sandbox, req, res) {
223
294
  async function devRemote(flags, slug) {
224
295
  const { token, expires_at } = await withContext(apiRequest("POST", `/projects/${slug}/dev-token`), devTokenContext(slug));
225
296
  const { apiUrl } = siteConfig();
226
- const channel = flags.staging ? "staging" : "live";
227
- const worker = await workerPlugin(flags, slug, void 0, () => fetchDevUser(apiUrl, devProxyHeaders(token, slug, channel)));
297
+ const proxyHeaders = devProxyHeaders(token, slug, flags.staging ? "staging" : "live");
298
+ const devRoles = await resolveDevRoles(slug, flags.asRoles);
299
+ const worker = await workerPlugin(flags, slug, void 0, () => fetchDevUser(apiUrl, proxyHeaders), devRoles.override ? devRoles.roles : void 0);
300
+ const asRolePlugin = devRoles.override && devRoles.roles ? [authMeRolesPlugin(devRoles.roles, () => fetch(`${apiUrl}/data/v1/auth/me`, { headers: proxyHeaders }))] : [];
228
301
  const server = await startVite(flags, { proxy: { "/_api": {
229
302
  target: apiUrl,
230
303
  changeOrigin: true,
231
- headers: devProxyHeaders(token, slug, channel),
304
+ headers: proxyHeaders,
232
305
  rewrite: rewriteApiPath
233
- } } }, worker ? [worker.plugin] : []);
306
+ } } }, [...asRolePlugin, ...worker ? [worker.plugin] : []]);
234
307
  if (flags.staging) {
235
308
  console.log(`\n ${slug} — remote-first dev against the STAGING channel's data`);
236
309
  console.log(" (live data is untouched; `canopy staging sync` re-clones it from live)");
@@ -244,6 +317,7 @@ async function devRemote(flags, slug) {
244
317
  console.log(" while the schema is in flux");
245
318
  }
246
319
  for (const note of worker?.notes ?? []) console.log(note);
320
+ for (const note of devRoles.notes) console.log(note);
247
321
  console.log("");
248
322
  server.printUrls();
249
323
  console.log(`\n Dev token expires ${formatDateTime(expires_at)} — restart canopy dev after that.`);
@@ -251,7 +325,7 @@ async function devRemote(flags, slug) {
251
325
  async function devLocal(flags, slug) {
252
326
  let dump;
253
327
  if (flags.pull) {
254
- const { fetchProjectDump } = await import("./local-w2wXfE1d.mjs");
328
+ const { fetchProjectDump } = await import("./local-CIkL7tXP.mjs");
255
329
  console.log(`Pulling a fresh export of ${slug}${flags.staging ? " [staging]" : ""}…`);
256
330
  dump = await fetchProjectDump(slug, flags.staging ? "staging" : "live");
257
331
  }
@@ -267,11 +341,13 @@ async function devLocal(flags, slug) {
267
341
  console.log(`Note: ai/services will be unavailable — could not reach the platform (${reason}).`);
268
342
  }
269
343
  const workerAttest = randomBytes(24).toString("base64url");
270
- const { createSandbox } = await import("./local-w2wXfE1d.mjs");
344
+ const devRoles = await resolveDevRoles(slug, flags.asRoles);
345
+ const { createSandbox } = await import("./local-CIkL7tXP.mjs");
271
346
  const sandbox = await createSandbox({
272
347
  slug,
273
348
  persistDir: join(flags.dir, ".canopy", "local"),
274
349
  email: siteConfig().email,
350
+ roles: devRoles.roles ?? [],
275
351
  dialect: flags.dialect,
276
352
  workerAttest
277
353
  });
@@ -304,7 +380,7 @@ async function devLocal(flags, slug) {
304
380
  headers: { authorization: `Bearer ${remote.token}` },
305
381
  rewrite: rewriteApiPath
306
382
  };
307
- const worker = await workerPlugin(flags, slug, workerAttest);
383
+ const worker = await workerPlugin(flags, slug, workerAttest, void 0, devRoles.roles ?? []);
308
384
  const server = await startVite(flags, { proxy: {
309
385
  ...remoteEntry && {
310
386
  "/_api/data/v1/ai": remoteEntry,
@@ -328,6 +404,7 @@ async function devLocal(flags, slug) {
328
404
  console.log(" blobs are not pulled — the local blob store starts empty");
329
405
  console.log(remote ? " ai/services pass through to the platform (metered as usual)" : " ai/services answer 501 until you log in and restart");
330
406
  for (const note of worker?.notes ?? []) console.log(note);
407
+ for (const note of devRoles.notes) console.log(note);
331
408
  console.log("");
332
409
  server.printUrls();
333
410
  }
@@ -356,6 +433,10 @@ const devCommand = defineCommand({
356
433
  type: "boolean",
357
434
  default: false,
358
435
  description: "Point remote dev at the staging channel's data (or pull the staging dump with --local --pull)"
436
+ },
437
+ "as-role": {
438
+ type: "string",
439
+ description: "Try the app as someone with this app role instead of your own (repeatable; the default role stays)"
359
440
  }
360
441
  },
361
442
  run: reportErrors("dev", async ({ args }) => {
@@ -370,7 +451,8 @@ const devCommand = defineCommand({
370
451
  local: args.local,
371
452
  pull: args.pull,
372
453
  staging: args.staging,
373
- dialect: projectDialect(dir)
454
+ dialect: projectDialect(dir),
455
+ asRoles: parseAppRoleFlag(args["as-role"])
374
456
  };
375
457
  await (flags.local ? devLocal(flags, slug) : devRemote(flags, slug));
376
458
  })
@@ -1,4 +1,4 @@
1
- import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER, r as redactCanopyCredentials, t as CALLER_HEADER } from "./src-RUf3Gfuh.mjs";
1
+ import { i as stampWorkerHeaders, n as WORKER_ATTEST_HEADER, r as redactCanopyCredentials, t as CALLER_HEADER } from "./src-8vxOBOxW.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { relative, resolve, sep } from "node:path";
4
4
  //#region src/dev-worker.ts
@@ -189,14 +189,22 @@ function createDevBlob(canopyFetch, prefix) {
189
189
  }
190
190
  };
191
191
  }
192
+ /** Stamped app roles (project-roles); undefined when the request carries none. */
193
+ function rolesFromHeaders(headers) {
194
+ const raw = headers.get("x-canopy-identity-roles");
195
+ if (raw === null) return void 0;
196
+ return raw.split(",").map((r) => r.trim()).filter(Boolean);
197
+ }
192
198
  /** Gateway-stamped identity, honored in dev too so a request can pose as one. */
193
199
  function userFromHeaders(headers, fallback) {
194
200
  const sub = (headers.get("x-canopy-user-sub") ?? "").trim();
195
201
  if (!sub) return fallback;
202
+ const roles = rolesFromHeaders(headers);
196
203
  return {
197
204
  sub,
198
205
  email: (headers.get("x-canopy-user-email") ?? "").trim(),
199
- name: (headers.get("x-canopy-user-name") ?? "").trim()
206
+ name: (headers.get("x-canopy-user-name") ?? "").trim(),
207
+ ...roles && { roles }
200
208
  };
201
209
  }
202
210
  /**
@@ -208,20 +216,25 @@ function callerFromHeaders(headers, fallback) {
208
216
  const kind = (headers.get("x-canopy-identity-kind") ?? "").trim();
209
217
  const id = (headers.get("x-canopy-identity-id") ?? "").trim();
210
218
  const agent = headers.get("x-canopy-caller") === "agent";
211
- if ((kind === "monk" || kind === "external") && id) return {
212
- kind,
213
- id,
214
- email: (headers.get("x-canopy-identity-email") ?? "").trim(),
215
- name: (headers.get("x-canopy-identity-name") ?? "").trim(),
216
- agent
217
- };
219
+ if ((kind === "monk" || kind === "external") && id) {
220
+ const roles = rolesFromHeaders(headers);
221
+ return {
222
+ kind,
223
+ id,
224
+ email: (headers.get("x-canopy-identity-email") ?? "").trim(),
225
+ name: (headers.get("x-canopy-identity-name") ?? "").trim(),
226
+ agent,
227
+ ...roles && { roles }
228
+ };
229
+ }
218
230
  const user = userFromHeaders(headers, fallback);
219
231
  return user ? {
220
232
  kind: "monk",
221
233
  id: user.sub,
222
234
  email: user.email,
223
235
  name: user.name,
224
- agent
236
+ agent,
237
+ ...user.roles && { roles: user.roles }
225
238
  } : null;
226
239
  }
227
240
  /**
@@ -16,7 +16,7 @@ It prints a one-time link. Whatever browser opens it is signed in to **my-app**,
16
16
  ## What the agent gets
17
17
 
18
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.
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 members-only project you have to be a member.
20
20
  - **An hour.** After that the agent meets the sign-in page and can run `canopy browse` again.
21
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
22
 
@@ -47,7 +47,7 @@ The agent's session ends when the hour is up. It also ends early if you:
47
47
  - revoke that CLI sign-in on your Account page in the dashboard, or
48
48
  - sign out everywhere.
49
49
 
50
- If you lose access to a collaborators-only project, the agent loses it too.
50
+ If you lose access to a members-only project, the agent loses it too.
51
51
 
52
52
  ## Good to know
53
53
 
@@ -136,7 +136,7 @@ schema to decide what to send. A few habits help a lot:
136
136
  ## Connect your agent
137
137
 
138
138
  Anyone who can open the project in a browser can connect. For
139
- collaborators-only projects, that means collaborators only. From a terminal:
139
+ members-only projects, that means members only. From a terminal:
140
140
 
141
141
  ```bash
142
142
  canopy mcp add <slug>
@@ -202,7 +202,7 @@ an agent (`agent`), in any route. Two limits apply to your own server:
202
202
  - They can only call your tools. They can't reach your app's pages or `/api/*`
203
203
  routes directly, the dashboard, or other projects.
204
204
  - Their access ends when the person signs out (`canopy logout`, or "sign out
205
- everywhere"), or loses access to a collaborators-only project.
205
+ everywhere"), or loses access to a members-only project.
206
206
  - Very long tool descriptions or schemas are held back, a project shows at
207
207
  most 64 tools, results over 512 KB become an error, and a call stops after
208
208
  60 seconds.
package/dist/docs/ci.md CHANGED
@@ -27,7 +27,7 @@ can deploy that one project and nothing else.
27
27
 
28
28
  ## Create a token
29
29
 
30
- Only the project owner can create a deploy token (any collaborator can list
30
+ Only the project owner can create a deploy token (any editor can list
31
31
  and revoke them). From inside the project directory:
32
32
 
33
33
  ```bash
@@ -24,7 +24,7 @@ The site comes from the active profile, as for every command (`--profile staging
24
24
 
25
25
  ## How it works
26
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.
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. Members-only projects only give links to their members.
28
28
 
29
29
  - The link **works once** and **expires after a minute**. Run the command again for a new one.
30
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.
@@ -21,6 +21,7 @@ canopy dev --local --pull # sandbox hydrated from a fresh prod export
21
21
  - `--local` — use the local-data sandbox instead of live data. Default off.
22
22
  - `--pull` — hydrate the sandbox from a fresh export of the project's production database first. Only valid with `--local`; the command errors otherwise (remote dev already sees live data).
23
23
  - `--staging` — point remote dev at the [staging channel's](https://docs.canopy.website/guides/staging) data instead of live (the safe remote target while a schema is in flux), or, with `--local --pull`, hydrate the sandbox from the staging dump.
24
+ - `--as-role <slug>` — run as someone with that [app role](https://docs.canopy.website/guides/roles) instead of your own (repeatable). You keep the project's default role, as a real visitor would. `auth.user()` and `env.CALLER` both report the override, and nothing changes on the platform. Without it, dev runs with your real roles.
24
25
  - `--profile <site>` / `CANOPY_PROFILE` — which site the dev token and API calls target. Projects usually pin a profile in `canopy.json`.
25
26
 
26
27
  ## How it works
@@ -79,7 +79,7 @@ While developing: the tools from your working tree, served by `canopy dev`.
79
79
  usual cases:
80
80
 
81
81
  - **Not signed in** — run `canopy login`.
82
- - **Limited to its collaborators** — ask the owner to add you.
82
+ - **Limited to its members** — ask the owner to add you.
83
83
  - **No backend, or serves no tools** — the project has no `worker/`, or it
84
84
  doesn't mount any tools yet.
85
85
  - **Redeploy required** — the backend was deployed before tools existed. Run
@@ -0,0 +1,41 @@
1
+ ---
2
+ title: canopy members
3
+ description: See who is on the project, add people as editors or viewers, and give them app roles.
4
+ ---
5
+
6
+ `canopy members` manages the project's roster from a terminal: the same as
7
+ the **Members** card under Settings → Access. See
8
+ [Members and access](https://docs.canopy.website/platform/members-and-access) for what editors and
9
+ viewers can do.
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ canopy members list
15
+ canopy members add jane@monks.com
16
+ canopy members add sam@monks.com --viewer --app-role approver
17
+ canopy members set sam@monks.com --app-role approver --app-role reviewer
18
+ canopy members set sam@monks.com --editor
19
+ canopy members set sam@monks.com --clear-app-roles
20
+ canopy members remove sam@monks.com
21
+ ```
22
+
23
+ ## Arguments & flags
24
+
25
+ All subcommands find the project the usual way (`canopy.json` in the target
26
+ directory, or `--project <slug>`) and honor the global `--profile <site>`.
27
+
28
+ - `list` prints everyone on the project and every pending invite, with their
29
+ Canopy role and app roles. `--json` prints the same as JSON.
30
+ - `add <email>` adds someone as an editor, or a viewer with `--viewer`.
31
+ Someone who hasn't signed in to Canopy yet gets an invite and joins at their
32
+ first sign-in. Owner only.
33
+ - `set <email>` changes someone. `--editor` or `--viewer` changes their Canopy
34
+ role (owner only). `--app-role <slug>` sets their app roles, replacing what
35
+ they had (repeat it, or separate slugs with commas), and `--clear-app-roles`
36
+ takes them all away. Editors can change app roles.
37
+ - `remove <email>` takes someone off the project, or withdraws their invite.
38
+ Owner only.
39
+
40
+ `--app-role` takes slugs from [`canopy roles list`](https://docs.canopy.website/cli/roles). The default
41
+ role needs no assigning: everyone has it.
@@ -0,0 +1,39 @@
1
+ ---
2
+ title: canopy roles
3
+ description: Create, rename and remove the project's app roles, and pick the default.
4
+ ---
5
+
6
+ `canopy roles` manages your project's [app roles](https://docs.canopy.website/guides/roles): the labels
7
+ your app reads as `roles` on the signed-in user. It does the same job as the
8
+ **App roles** card under Settings → Access. Owners and editors can use it.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ canopy roles list
14
+ canopy roles list --json
15
+ canopy roles add "Approver"
16
+ canopy roles add "Sign-off lead" --slug lead
17
+ canopy roles rename approver "Sign-off"
18
+ canopy roles default reader
19
+ canopy roles remove approver
20
+ ```
21
+
22
+ ## Arguments & flags
23
+
24
+ All subcommands find the project the usual way (`canopy.json` in the target
25
+ directory, or `--project <slug>`) and honor the global `--profile <site>`.
26
+
27
+ - `list` prints each role's slug and name, with the default marked. `--json`
28
+ prints the same as JSON, handy for an agent before it writes a role check.
29
+ - `add <name>` creates a role. The slug comes from the name unless you pass
30
+ `--slug`. Slugs are 1–32 lowercase letters, digits and dashes, starting with
31
+ a letter, and never change. The first role becomes the default. A project
32
+ has at most 20 roles.
33
+ - `rename <slug> <name>` changes the display name. The slug stays.
34
+ - `default <slug>` makes that role the default, which everyone who can open
35
+ the app has.
36
+ - `remove <slug>` deletes the role and takes it away from everyone. Make
37
+ another role the default before removing the current default.
38
+
39
+ To give roles to people, use [`canopy members`](https://docs.canopy.website/cli/members).
@@ -17,7 +17,7 @@ canopy token revoke <id>
17
17
 
18
18
  All subcommands resolve the project the usual way — `canopy.json` in the target directory, or `--project <slug>` to override — and honor the global `--profile <site>` / `CANOPY_PROFILE` for which site the token belongs to.
19
19
 
20
- - `create` — mint a new deploy token for the project. Owner only: collaborators get a clear error and should ask the owner.
20
+ - `create` — mint a new deploy token for the project. Owner only: editors get a clear error and should ask the owner.
21
21
  - `--name <label>` — a label shown in the dashboard, e.g. `"GitHub Actions"`. Optional but recommended so you can tell tokens apart later.
22
22
  - `list` — show the project's deploy tokens: id, name, creation time, who created them, and when each was last used. Token values are never shown here.
23
23
  - `revoke <id>` — delete a token by its id (from `canopy token list`). Deploys using it stop working immediately.
@@ -103,6 +103,18 @@ else is still static serving. A few things to know:
103
103
  `process.env`); dashboard Schedules invoke an exported
104
104
  `scheduled(event, env)` — switch on `event.name`.
105
105
 
106
+ - **Backends run as Workers, on every site.** Your worker runs in its own
107
+ isolated runtime, with the rules Workers have everywhere:
108
+ - no file system to write to, and no child processes;
109
+ - `eval` and `new Function` don't work;
110
+ - a connection or stream belongs to the request that opened it;
111
+ - work you start but don't await stops when the response is sent. Hand it to
112
+ `c.executionCtx.waitUntil(...)` if it has to finish.
113
+
114
+ Node built-ins from the `nodejs_compat` set work (`node:crypto`,
115
+ `node:buffer`, `node:https`, …). `process.env` holds your secrets and the
116
+ project's own names, never the platform's.
117
+
106
118
  > **Note:** Backends run on the `main` and `gcp-pilot` sites. On a site without backend support, a deploy that
107
119
  > includes one answers with a clear "not supported here" rather than failing obscurely;
108
120
  > frontend-only projects deploy everywhere.
@@ -52,6 +52,11 @@
52
52
  "title": "Staging & promoting",
53
53
  "summary": "Deploy to a safe copy of your project first — own database cloned from live — then promote the exact same version."
54
54
  },
55
+ {
56
+ "slug": "roles",
57
+ "title": "Roles and permissions",
58
+ "summary": "Give people different powers inside your app with app roles, and check them where it counts."
59
+ },
55
60
  {
56
61
  "slug": "data",
57
62
  "title": "Working with project data",
@@ -157,7 +162,7 @@
157
162
  {
158
163
  "slug": "platform-members-and-access",
159
164
  "title": "Members & access",
160
- "summary": "Everything on Canopy sits behind Monks sign-in. Collaborators manage a project; other Monks can view the live app — unless you tighten it."
165
+ "summary": "Everything on Canopy sits behind Monks sign-in. Editors manage a project, viewers open it, and your app can hand out its own roles on top."
161
166
  },
162
167
  {
163
168
  "slug": "platform-feedback",
@@ -296,6 +301,16 @@
296
301
  "title": "canopy secrets",
297
302
  "summary": "Set, list, and remove the project's worker secrets from the terminal."
298
303
  },
304
+ {
305
+ "slug": "cli-members",
306
+ "title": "canopy members",
307
+ "summary": "See who is on the project, add people as editors or viewers, and give them app roles."
308
+ },
309
+ {
310
+ "slug": "cli-roles",
311
+ "title": "canopy roles",
312
+ "summary": "Create, rename and remove the project's app roles, and pick the default."
313
+ },
299
314
  {
300
315
  "slug": "cli-schedule",
301
316
  "title": "canopy schedule",
@@ -9,7 +9,7 @@ start with [getting started](https://docs.canopy.website/guides/getting-started)
9
9
  ## Who can see my project?
10
10
 
11
11
  By default, any Monk who signs in with OneLogin. If that's too broad, switch
12
- the project to **collaborators only** in Settings — then only the people you've
12
+ the project to **members only** in Settings — then only the people you've
13
13
  invited can open the live app (it still appears in the company project list,
14
14
  and platform admins can still manage it).
15
15
 
@@ -63,9 +63,11 @@ agent so it knows how the platform works.
63
63
 
64
64
  ## Can I work on it with teammates?
65
65
 
66
- Yes. Invite people from the project's page in the dashboard with the people
67
- picker — as admins or collaborators. Combined with collaborators-only mode,
68
- this also controls who can open the live app.
66
+ Yes. Add people under **Settings → Access** in the dashboard: as editors,
67
+ who work on the project with you, or as viewers, who can only open the app.
68
+ Combined with members-only mode, this also controls who can open the live
69
+ app. Your app can give people its own roles too, see
70
+ [Roles and permissions](https://docs.canopy.website/guides/roles).
69
71
 
70
72
  ## Can I bring an app I already started?
71
73