@agent-native/core-corpus 0.210.2 → 0.211.0

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 (192) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/actions/references/action-fields.md +5 -3
  3. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +41 -2
  4. package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +4 -2
  5. package/corpus/templates/analytics/CHANGELOG.md +18 -0
  6. package/corpus/templates/analytics/actions/build-data-index.ts +11 -2
  7. package/corpus/templates/analytics/actions/configure-content-collection.ts +29 -4
  8. package/corpus/templates/analytics/actions/configure-first-party-events-model.ts +9 -7
  9. package/corpus/templates/analytics/actions/create-analytics-public-key.ts +4 -0
  10. package/corpus/templates/analytics/actions/estimate-content-signups-cost.ts +72 -0
  11. package/corpus/templates/analytics/actions/estimate-query-cost.ts +16 -134
  12. package/corpus/templates/analytics/actions/list-sigma-workbooks.ts +123 -42
  13. package/corpus/templates/analytics/actions/migrate-sigma-workbook.ts +20 -17
  14. package/corpus/templates/analytics/actions/revoke-analytics-public-key.ts +4 -0
  15. package/corpus/templates/analytics/app/components/dashboard/SigmaMigrationDialog.tsx +246 -680
  16. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +1 -1
  17. package/corpus/templates/analytics/app/i18n/en-US.ts +54 -55
  18. package/corpus/templates/analytics/app/pages/DashboardOverview.tsx +0 -1
  19. package/corpus/templates/analytics/app/pages/DataSources.tsx +56 -0
  20. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/filter-vars.ts +4 -3
  21. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +26 -8
  22. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/interpolate.ts +3 -1
  23. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/panel-cost-notice.ts +172 -0
  24. package/corpus/templates/analytics/app/pages/data-sources/SearchConsoleDetail.tsx +441 -0
  25. package/corpus/templates/analytics/changelog/2026-10-15-a-content-dashboard-lists-every-blog-article-with-its-author.md +6 -0
  26. package/corpus/templates/analytics/changelog/2026-10-15-data-sources-has-a-google-search-console-card-that-shows-the.md +6 -0
  27. package/corpus/templates/analytics/changelog/2026-10-16-the-content-dashboard-can-show-signups-per-blog-article.md +6 -0
  28. package/corpus/templates/analytics/changelog/2026-10-19-creating-or-revoking-a-public-analytics-key-now-asks-you-to-.md +6 -0
  29. package/corpus/templates/analytics/changelog/2026-10-19-search-sigma-workbooks-by-name-and-open-ones-a-teammate-alre.md +6 -0
  30. package/corpus/templates/analytics/changelog/2026-10-20-blog-signup-attribution-excludes-signups-before-the-first-vi.md +6 -0
  31. package/corpus/templates/analytics/changelog/2026-10-20-collapsed-analytics-navigation-starts-at-the-top.md +6 -0
  32. package/corpus/templates/analytics/changelog/2026-10-20-content-keeps-successful-coverage-dates-and-missing-day-gaps.md +6 -0
  33. package/corpus/templates/analytics/changelog/2026-10-20-private-github-repositories-can-be-indexed-with-your-connect.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-10-20-search-console-preserves-backfill-and-paused-collection.md +6 -0
  35. package/corpus/templates/analytics/changelog/2026-10-20-search-console-refreshes-historical-coverage-when-properties.md +6 -0
  36. package/corpus/templates/analytics/changelog/2026-10-20-search-console-setup-keeps-saved-properties-readable-and-sup.md +6 -0
  37. package/corpus/templates/analytics/seeds/dashboards/content-view.json +182 -0
  38. package/corpus/templates/analytics/server/db/schema.ts +1 -0
  39. package/corpus/templates/analytics/server/lib/bi-migration/external-dashboards-store.ts +56 -18
  40. package/corpus/templates/analytics/server/lib/bi-migration/unfinished-import.ts +64 -0
  41. package/corpus/templates/analytics/server/lib/builtin-dashboard-pass.ts +17 -2
  42. package/corpus/templates/analytics/server/lib/content-metrics/collect.ts +2 -0
  43. package/corpus/templates/analytics/server/lib/content-metrics/config.ts +92 -3
  44. package/corpus/templates/analytics/server/lib/content-metrics/legacy-content-view.json +172 -0
  45. package/corpus/templates/analytics/server/lib/content-metrics/migration.ts +2 -0
  46. package/corpus/templates/analytics/server/lib/content-metrics/steps/daily-metrics.ts +8 -3
  47. package/corpus/templates/analytics/server/lib/content-metrics/steps/search-console.ts +16 -3
  48. package/corpus/templates/analytics/server/lib/content-metrics/steps/signups.ts +240 -0
  49. package/corpus/templates/analytics/server/lib/content-metrics/store.ts +5 -0
  50. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +21 -0
  51. package/corpus/templates/analytics/server/lib/dashboard-panel-verification.ts +300 -5
  52. package/corpus/templates/analytics/server/lib/dashboard-seeds.ts +2 -0
  53. package/corpus/templates/analytics/server/lib/dashboards-store.ts +28 -0
  54. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +128 -24
  55. package/corpus/templates/analytics/server/lib/google-analytics.ts +56 -16
  56. package/corpus/templates/analytics/server/lib/index-build-github.ts +5 -0
  57. package/corpus/templates/analytics/server/lib/provider-credentials.ts +47 -2
  58. package/corpus/templates/analytics/server/lib/query-cost-estimate.ts +184 -5
  59. package/corpus/templates/analytics/server/plugins/db.ts +9 -1
  60. package/corpus/templates/analytics/shared/builtin-dashboard-ids.ts +2 -0
  61. package/corpus/templates/analytics/shared/search-console-property.ts +78 -0
  62. package/corpus/templates/assets/.agents/skills/actions/references/action-fields.md +5 -3
  63. package/corpus/templates/brain/.agents/skills/actions/references/action-fields.md +5 -3
  64. package/corpus/templates/calendar/.agents/skills/actions/references/action-fields.md +5 -3
  65. package/corpus/templates/calendar/actions/delete-event.ts +1 -3
  66. package/corpus/templates/calendar/actions/delete-events.ts +1 -2
  67. package/corpus/templates/calendar/actions/event-action-helpers.ts +0 -4
  68. package/corpus/templates/calendar/actions/update-event.ts +2 -6
  69. package/corpus/templates/chat/.agents/skills/actions/references/action-fields.md +5 -3
  70. package/corpus/templates/clips/.agents/skills/actions/references/action-fields.md +5 -3
  71. package/corpus/templates/clips/CHANGELOG.md +11 -24
  72. package/corpus/templates/clips/actions/create-recording-upload.ts +5 -1
  73. package/corpus/templates/clips/app/components/player/chapter-list.tsx +7 -6
  74. package/corpus/templates/clips/app/components/player/comment-composer.tsx +5 -1
  75. package/corpus/templates/clips/app/components/player/player-controls.tsx +1 -0
  76. package/corpus/templates/clips/app/components/player/scrubber.tsx +4 -2
  77. package/corpus/templates/clips/app/i18n/en-US.ts +3 -3
  78. package/corpus/templates/clips/app/lib/extension-auth.ts +70 -0
  79. package/corpus/templates/clips/app/root.tsx +22 -10
  80. package/corpus/templates/clips/app/routes/_app.notifications.tsx +3 -0
  81. package/corpus/templates/clips/app/routes/import.tsx +115 -110
  82. package/corpus/templates/clips/changelog/2026-10-16-agent-uploads-can-use-free-clips-storage.md +6 -0
  83. package/corpus/templates/clips/changelog/2026-10-19-agents-see-a-clip-s-frames-as-soon-as-it-finishes-processing.md +6 -0
  84. package/corpus/templates/clips/changelog/2026-10-19-chapter-list-times-now-match-the-player-s-clock-and-each-cha.md +6 -0
  85. package/corpus/templates/clips/changelog/2026-10-19-editing-a-comment-puts-the-cursor-after-its-text-and-reply-o.md +6 -0
  86. package/corpus/templates/clips/changelog/2026-10-19-youtube-import-followups.md +6 -0
  87. package/corpus/templates/clips/changelog/2026-10-20-locally-saved-desktop-recordings-now-appear-in-the-recovery-.md +6 -0
  88. package/corpus/templates/clips/desktop/src/app.tsx +29 -186
  89. package/corpus/templates/clips/desktop/src/components/RecordingRecovery.tsx +171 -16
  90. package/corpus/templates/clips/desktop/src/lib/record-first.ts +9 -32
  91. package/corpus/templates/clips/desktop/src/lib/recording-recovery.ts +30 -1
  92. package/corpus/templates/clips/server/lib/agent-frame-request.ts +5 -2
  93. package/corpus/templates/clips/server/lib/recording-upload-ticket.ts +8 -0
  94. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +2 -0
  95. package/corpus/templates/content/.agents/skills/actions/references/action-fields.md +5 -3
  96. package/corpus/templates/content/CHANGELOG.md +12 -0
  97. package/corpus/templates/content/actions/_content-database-personal-view.ts +5 -15
  98. package/corpus/templates/content/actions/_database-navigation.ts +3 -3
  99. package/corpus/templates/content/actions/_database-utils.ts +10 -22
  100. package/corpus/templates/content/actions/_database-view-setup.ts +16 -76
  101. package/corpus/templates/content/actions/create-comment-ai-suggestion.ts +93 -44
  102. package/corpus/templates/content/actions/export-document.ts +2 -14
  103. package/corpus/templates/content/actions/suggest-document-edit.ts +5 -7
  104. package/corpus/templates/content/actions/update-content-database-view.ts +2 -14
  105. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +86 -30
  106. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +64 -28
  107. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -46
  108. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +72 -7
  109. package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +292 -103
  110. package/corpus/templates/content/app/components/editor/ThreadSuggestion.tsx +147 -40
  111. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +108 -11
  112. package/corpus/templates/content/app/components/editor/database/ContentTable.tsx +2 -1
  113. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +150 -166
  114. package/corpus/templates/content/app/components/editor/database/database-cell-comments.tsx +515 -0
  115. package/corpus/templates/content/app/components/editor/database/database-item-comments.tsx +5 -43
  116. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +8 -783
  117. package/corpus/templates/content/app/components/editor/live-editor-session.ts +5 -1
  118. package/corpus/templates/content/app/components/editor/page-draft-journal.ts +61 -1
  119. package/corpus/templates/content/app/components/editor/property-comments.tsx +35 -4
  120. package/corpus/templates/content/app/components/editor/property-date-format.ts +45 -0
  121. package/corpus/templates/content/app/components/editor/thread-suggestions.ts +30 -0
  122. package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +23 -4
  123. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +36 -10
  124. package/corpus/templates/content/app/global.css +23 -0
  125. package/corpus/templates/content/app/i18n-data.ts +1 -0
  126. package/corpus/templates/content/changelog/2026-10-16-opening-a-page-in-a-new-tab-while-another-tab-is-still-savin.md +6 -0
  127. package/corpus/templates/content/changelog/2026-10-16-typing-steadily-in-one-tab-no-longer-makes-another-open-tab-.md +6 -0
  128. package/corpus/templates/content/changelog/2026-10-19-database-views-can-filter-the-same-property-more-than-once-n.md +6 -0
  129. package/corpus/templates/content/changelog/2026-10-19-edits-made-from-a-block-s-menu-or-a-table-s-row-and-column-m.md +6 -0
  130. package/corpus/templates/content/changelog/2026-10-19-starting-a-comment-puts-the-cursor-in-the-comment-box-ready-.md +6 -0
  131. package/corpus/templates/content/changelog/2026-10-19-table-row-and-column-options-now-appear-for-the-row-or-colum.md +6 -0
  132. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +5 -1
  133. package/corpus/templates/content/docs/product/capabilities/content.revision.suggestions.md +3 -1
  134. package/corpus/templates/content/e2e/helpers.ts +10 -0
  135. package/corpus/templates/content/shared/api.ts +18 -12
  136. package/corpus/templates/content/shared/database-query.ts +107 -13
  137. package/corpus/templates/content/shared/suggestion-diff.ts +35 -0
  138. package/corpus/templates/crm/.agents/skills/actions/references/action-fields.md +5 -3
  139. package/corpus/templates/design/.agents/skills/actions/references/action-fields.md +5 -3
  140. package/corpus/templates/design/CHANGELOG.md +2 -0
  141. package/corpus/templates/design/actions/delete-file.ts +1 -4
  142. package/corpus/templates/design/app/components/design/LayersPanel.tsx +10 -1
  143. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +24 -4
  144. package/corpus/templates/design/app/pages/design-editor/commands/mode-change.ts +14 -9
  145. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +23 -2
  146. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +29 -0
  147. package/corpus/templates/design/changelog/2026-10-19-reply-and-edit-on-a-comment-in-the-sidebar-or-on-a-canvas-pi.md +6 -0
  148. package/corpus/templates/design/changelog/2026-10-20-comments-return-to-the-mode-they-came-from-and-layers-search.md +6 -0
  149. package/corpus/templates/design/e2e/global-setup.ts +185 -85
  150. package/corpus/templates/dispatch/.agents/skills/actions/references/action-fields.md +5 -3
  151. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  152. package/corpus/templates/dispatch/actions/apply-workspace-connection-setup.ts +4 -0
  153. package/corpus/templates/dispatch/actions/delete-workspace-connection.ts +4 -0
  154. package/corpus/templates/dispatch/actions/set-workspace-connection-grant.ts +4 -0
  155. package/corpus/templates/dispatch/actions/upsert-workspace-connection.ts +4 -0
  156. package/corpus/templates/dispatch/changelog/2026-10-19-vault-secrets-grants-link-tokens-app-access-and-workspace-co.md +6 -0
  157. package/corpus/templates/factory/.agents/skills/actions/references/action-fields.md +5 -3
  158. package/corpus/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +105 -33
  159. package/corpus/templates/factory/.agents/skills/review-latest-feedback/references/channel-inventory.md +128 -74
  160. package/corpus/templates/factory/.agents/skills/review-latest-feedback/references/slack-reaction-replies.md +39 -11
  161. package/corpus/templates/forms/.agents/skills/actions/references/action-fields.md +5 -3
  162. package/corpus/templates/mail/.agents/skills/actions/references/action-fields.md +5 -3
  163. package/corpus/templates/mail/.agents/skills/contacts-and-crm/SKILL.md +22 -17
  164. package/corpus/templates/mail/AGENTS.md +1 -1
  165. package/corpus/templates/mail/CHANGELOG.md +14 -0
  166. package/corpus/templates/mail/actions/provider-api-request.ts +3 -3
  167. package/corpus/templates/mail/app/components/email/IntegrationsSidebar.tsx +211 -54
  168. package/corpus/templates/mail/app/hooks/use-inbox-threads.ts +26 -2
  169. package/corpus/templates/mail/app/i18n/en-US.ts +5 -1
  170. package/corpus/templates/mail/app/lib/gong-connection.ts +27 -0
  171. package/corpus/templates/mail/app/lib/thread-cache.ts +110 -9
  172. package/corpus/templates/mail/app/root.tsx +10 -2
  173. package/corpus/templates/mail/changelog/2026-10-20-connected-gong-settings-now-open-the-shared-integration-mana.md +6 -0
  174. package/corpus/templates/mail/changelog/2026-10-20-gong-connection-details-now-match-the-call-data-available-in.md +6 -0
  175. package/corpus/templates/mail/changelog/2026-10-20-improve-gong-setup-with-a-click-to-connect-flow-and-permissi.md +6 -0
  176. package/corpus/templates/mail/changelog/2026-10-20-mail-lets-you-retry-when-it-cannot-check-your-gong-access.md +6 -0
  177. package/corpus/templates/mail/changelog/2026-10-20-mail-thread-cache-stays-isolated-when-mailbox-scope-changes.md +6 -0
  178. package/corpus/templates/mail/changelog/2026-10-20-the-inbox-and-recently-opened-threads-now-open-instantly-fro.md +6 -0
  179. package/corpus/templates/mail/server/handlers/emails.ts +9 -0
  180. package/corpus/templates/plan/.agents/skills/actions/references/action-fields.md +5 -3
  181. package/corpus/templates/plan/CHANGELOG.md +4 -0
  182. package/corpus/templates/plan/app/pages/PlansPage.tsx +173 -33
  183. package/corpus/templates/plan/changelog/2026-10-19-opening-a-comment-thread-puts-the-cursor-in-the-reply-box-an.md +6 -0
  184. package/corpus/templates/slides/.agents/skills/actions/references/action-fields.md +5 -3
  185. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -42
  186. package/corpus/templates/slides/app/components/editor/FloatingToolbar.tsx +3 -7
  187. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +5 -14
  188. package/corpus/templates/slides/app/i18n/en-US.ts +0 -2
  189. package/corpus/templates/slides/app/pages/DeckEditor.tsx +27 -33
  190. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +1 -3
  191. package/corpus/templates/tasks/.agents/skills/actions/references/action-fields.md +5 -3
  192. package/package.json +1 -1
@@ -66,30 +66,70 @@ export async function readServiceAccountIdentity(): Promise<ServiceAccountIdenti
66
66
  throw error;
67
67
  }
68
68
  try {
69
- const email = (JSON.parse(credsJson) as { client_email?: unknown })
70
- .client_email;
71
- return typeof email === "string" && email
72
- ? { status: "ok", email }
73
- : { status: "invalid" };
69
+ const creds = parseServiceAccountKey(credsJson);
70
+ return { status: "ok", email: creds.client_email };
74
71
  } catch {
75
72
  return { status: "invalid" };
76
73
  }
77
74
  }
78
75
 
76
+ /**
77
+ * A saved key that can't be parsed or signed with. Only the signer's error is
78
+ * quoted: a JSON parse error can echo part of the key.
79
+ */
80
+ function unreadableServiceAccountKey(cause?: string): Error {
81
+ return Object.assign(
82
+ new Error(
83
+ `Couldn't read the saved service account key${cause ? ` (${cause})` : ""}. Re-save the key under Google Cloud in Data sources.`,
84
+ ),
85
+ { errorCode: "google_credentials_unreadable", statusCode: 422 },
86
+ );
87
+ }
88
+
89
+ function parseServiceAccountKey(credsJson: string): {
90
+ client_email: string;
91
+ private_key: string;
92
+ } {
93
+ let creds: { client_email?: unknown; private_key?: unknown };
94
+ try {
95
+ creds = JSON.parse(credsJson);
96
+ } catch {
97
+ throw unreadableServiceAccountKey();
98
+ }
99
+ if (
100
+ !creds ||
101
+ typeof creds !== "object" ||
102
+ typeof creds.private_key !== "string" ||
103
+ !creds.private_key.trim() ||
104
+ typeof creds.client_email !== "string" ||
105
+ !creds.client_email.trim()
106
+ ) {
107
+ throw unreadableServiceAccountKey();
108
+ }
109
+ return { client_email: creds.client_email, private_key: creds.private_key };
110
+ }
111
+
79
112
  async function getAccessToken(scope: string): Promise<string> {
80
- const creds = JSON.parse(await readServiceAccountJson());
113
+ const creds = parseServiceAccountKey(await readServiceAccountJson());
81
114
  const now = Math.floor(Date.now() / 1000);
82
115
 
83
- const jwt = await signRs256Jwt(
84
- {
85
- iss: creds.client_email,
86
- scope,
87
- aud: "https://oauth2.googleapis.com/token",
88
- iat: now,
89
- exp: now + 3600,
90
- },
91
- creds.private_key,
92
- );
116
+ let jwt: string;
117
+ try {
118
+ jwt = await signRs256Jwt(
119
+ {
120
+ iss: creds.client_email,
121
+ scope,
122
+ aud: "https://oauth2.googleapis.com/token",
123
+ iat: now,
124
+ exp: now + 3600,
125
+ },
126
+ creds.private_key,
127
+ );
128
+ } catch (error) {
129
+ throw unreadableServiceAccountKey(
130
+ error instanceof Error ? error.message : undefined,
131
+ );
132
+ }
93
133
 
94
134
  const res = await fetch("https://oauth2.googleapis.com/token", {
95
135
  method: "POST",
@@ -69,6 +69,11 @@ async function readHeadCommit(
69
69
  }),
70
70
  );
71
71
  if (!result.response.ok) {
72
+ if (result.response.status === 404) {
73
+ throw new Error(
74
+ "GitHub could not find this repository or the current authorization cannot access it (HTTP 404). Verify the owner and repository name, and that the connected account has access.",
75
+ );
76
+ }
72
77
  throw new Error(
73
78
  `GitHub commit lookup failed with HTTP ${result.response.status}.`,
74
79
  );
@@ -48,6 +48,7 @@ const GONG_LEGACY_API_KEY = "GONG_API_KEY";
48
48
 
49
49
  export type AnalyticsProviderCredentialSource =
50
50
  | "workspace_connection"
51
+ | "analytics_oauth"
51
52
  | "analytics_local";
52
53
 
53
54
  export interface AnalyticsProviderCredential {
@@ -241,6 +242,18 @@ function normalizeCoreCredentialResult(
241
242
  };
242
243
  }
243
244
 
245
+ function isAllowedForCredentialScope(
246
+ credential: AnalyticsProviderCredential,
247
+ ctx: CredentialContext,
248
+ ): boolean {
249
+ if (ctx.credentialScope !== "org") return true;
250
+ return (
251
+ Boolean(ctx.orgId) &&
252
+ (credential.scope === "org" || credential.scope === "workspace") &&
253
+ credential.scopeId === ctx.orgId
254
+ );
255
+ }
256
+
244
257
  function isGongAuthKey(
245
258
  key: string,
246
259
  ): key is (typeof GONG_ANALYTICS_CREDENTIAL_KEYS)[number] {
@@ -337,6 +350,25 @@ async function resolveWorkspaceOAuthProviderCredential(
337
350
  ) {
338
351
  return null;
339
352
  }
353
+ const contextOrgId = options.ctx.orgId?.trim();
354
+ const connectionOrgId = resolvedConnection.connection.orgId?.trim();
355
+ const connectionScope =
356
+ resolvedConnection.connection.config.workspaceConnectionScope;
357
+ // A legacy app allowlist is ambiguous with an app-scoped OAuth grant.
358
+ const legacyOrganizationScope =
359
+ connectionScope === undefined &&
360
+ (resolvedConnection.connection.allowedApps?.length ?? 0) === 0 &&
361
+ (resolvedConnection.connection.allowedUsers?.length ?? 0) === 0 &&
362
+ (resolvedConnection.connection.allowedUserGroups?.length ?? 0) === 0;
363
+ if (
364
+ options.ctx.credentialScope === "org" &&
365
+ (!contextOrgId ||
366
+ !connectionOrgId ||
367
+ connectionOrgId !== contextOrgId ||
368
+ (connectionScope !== "organization" && !legacyOrganizationScope))
369
+ ) {
370
+ return null;
371
+ }
340
372
 
341
373
  const oauth = await resolveProviderApiOAuthAccessToken(
342
374
  {
@@ -357,6 +389,12 @@ async function resolveWorkspaceOAuthProviderCredential(
357
389
  connectionId: oauth.connectionId ?? resolvedConnection.connection.id,
358
390
  connectionLabel:
359
391
  oauth.connectionLabel ?? resolvedConnection.connection.label,
392
+ ...(connectionOrgId
393
+ ? {
394
+ scope: "org" as const,
395
+ scopeId: connectionOrgId,
396
+ }
397
+ : {}),
360
398
  };
361
399
  }
362
400
 
@@ -411,7 +449,7 @@ export async function resolveLocalAnalyticsProviderCredential(
411
449
  for (const key of keys) {
412
450
  const credential = await resolveCredentialDetailed(key, options.ctx);
413
451
  if (credential) {
414
- return {
452
+ const normalized: AnalyticsProviderCredential = {
415
453
  value: credential.value,
416
454
  key,
417
455
  provider: options.provider,
@@ -419,6 +457,9 @@ export async function resolveLocalAnalyticsProviderCredential(
419
457
  scope: credential.scope,
420
458
  scopeId: credential.scopeId,
421
459
  };
460
+ if (isAllowedForCredentialScope(normalized, options.ctx)) {
461
+ return normalized;
462
+ }
422
463
  }
423
464
  }
424
465
  return null;
@@ -464,7 +505,11 @@ export async function resolveAnalyticsProviderCredential(
464
505
 
465
506
  const workspaceCredential =
466
507
  await resolveWorkspaceConnectionProviderCredential({ ...options, keys });
467
- if (workspaceCredential) return workspaceCredential;
508
+ if (workspaceCredential) {
509
+ if (isAllowedForCredentialScope(workspaceCredential, options.ctx)) {
510
+ return workspaceCredential;
511
+ }
512
+ }
468
513
 
469
514
  if (!options.connectionId?.trim()) {
470
515
  const localCredential = await resolveLocalAnalyticsProviderCredential({
@@ -4,6 +4,7 @@ import {
4
4
  } from "./bigquery.js";
5
5
  import { describeError } from "./dashboard-panel-runner.js";
6
6
  import type { ResolvedDashboardPanelQuery } from "./dashboard-panel-verification.js";
7
+ import { countEnabledDashboardReportSubscriptions } from "./dashboard-report-subscriptions.js";
7
8
  import {
8
9
  renderFirstPartyAnalyticsBigQueryEstimateSql,
9
10
  type AnalyticsScope,
@@ -276,10 +277,181 @@ export async function estimateResolvedPanel(
276
277
  };
277
278
  }
278
279
 
280
+ function timedOutPanel(
281
+ panel: { panelId: string; title: string; source: string },
282
+ error: string,
283
+ ): PanelCostEstimate {
284
+ return {
285
+ panelId: panel.panelId,
286
+ title: panel.title,
287
+ source: panel.source,
288
+ status: "failed",
289
+ failure: "timeout",
290
+ error,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Ends a batch early without losing it: panels still running or not yet
296
+ * started come back timed out with `error`, and finished panels keep their
297
+ * estimates.
298
+ */
299
+ export interface EstimateStop {
300
+ signal: AbortSignal;
301
+ error: string;
302
+ }
303
+
304
+ /**
305
+ * Estimate one panel, but give up when the deadline passes or `stop` fires,
306
+ * even mid-way through credential, table, or backend lookups, so finished
307
+ * panels still come back before the caller's own timeout.
308
+ */
309
+ async function estimatePanelBefore(
310
+ deadline: number,
311
+ panel: ResolvedDashboardPanelQuery,
312
+ scope: AnalyticsScope,
313
+ runsPerMonth: number | null,
314
+ signal?: AbortSignal,
315
+ stop?: EstimateStop,
316
+ ): Promise<PanelCostEstimate> {
317
+ const remaining = deadline - Date.now();
318
+ if (remaining <= 0) {
319
+ return timedOutPanel(
320
+ panel,
321
+ "Skipped: this request ran out of time. Estimate this panel on its own with panelId.",
322
+ );
323
+ }
324
+ if (stop?.signal.aborted) return timedOutPanel(panel, stop.error);
325
+ const controller = new AbortController();
326
+ const forwardAbort = () => controller.abort(signal?.reason);
327
+ if (signal?.aborted) forwardAbort();
328
+ signal?.addEventListener("abort", forwardAbort, { once: true });
329
+ let timer: ReturnType<typeof setTimeout> | undefined;
330
+ let onStop: (() => void) | undefined;
331
+ const timedOut = new Promise<PanelCostEstimate>((resolve) => {
332
+ const giveUp = (error: string) => {
333
+ resolve(timedOutPanel(panel, error));
334
+ controller.abort();
335
+ };
336
+ timer = setTimeout(
337
+ () =>
338
+ giveUp(
339
+ "Stopped: this panel's dry run didn't finish in time. Estimate it on its own with panelId.",
340
+ ),
341
+ remaining,
342
+ );
343
+ if (stop) {
344
+ onStop = () => giveUp(stop.error);
345
+ stop.signal.addEventListener("abort", onStop, { once: true });
346
+ }
347
+ });
348
+ try {
349
+ return await Promise.race([
350
+ estimateResolvedPanel(panel, scope, runsPerMonth, controller.signal),
351
+ timedOut,
352
+ ]);
353
+ } finally {
354
+ clearTimeout(timer);
355
+ signal?.removeEventListener("abort", forwardAbort);
356
+ if (onStop) stop?.signal.removeEventListener("abort", onStop);
357
+ }
358
+ }
359
+
360
+ async function mapPool<T, R>(
361
+ items: readonly T[],
362
+ concurrency: number,
363
+ fn: (item: T) => Promise<R>,
364
+ ): Promise<R[]> {
365
+ const results: R[] = Array(items.length);
366
+ let next = 0;
367
+ const worker = async () => {
368
+ while (next < items.length) {
369
+ const index = next++;
370
+ results[index] = await fn(items[index]);
371
+ }
372
+ };
373
+ await Promise.all(
374
+ Array.from({ length: Math.min(concurrency, items.length) }, worker),
375
+ );
376
+ return results;
377
+ }
378
+
379
+ const DRY_RUN_CONCURRENCY = 6;
380
+
381
+ /**
382
+ * Dry-run resolved panels a few at a time; none starts after `deadline` or
383
+ * once `stop` fires.
384
+ */
385
+ export function estimateResolvedPanels(
386
+ panels: readonly ResolvedDashboardPanelQuery[],
387
+ scope: AnalyticsScope,
388
+ runsPerMonth: number | null,
389
+ deadline: number,
390
+ signal?: AbortSignal,
391
+ stop?: EstimateStop,
392
+ ): Promise<PanelCostEstimate[]> {
393
+ return mapPool(panels, DRY_RUN_CONCURRENCY, (panel) =>
394
+ estimatePanelBefore(deadline, panel, scope, runsPerMonth, signal, stop),
395
+ );
396
+ }
397
+
398
+ /**
399
+ * One run a day for each enabled email report on the dashboard in the
400
+ * caller's tenant; other tenants' reports run on their own BigQuery
401
+ * connection.
402
+ */
403
+ export async function scheduledDashboardRuns(
404
+ dashboardId: string,
405
+ scope: AnalyticsScope,
406
+ ): Promise<{ reportSchedules: number; runsPerMonth: number | null }> {
407
+ const reportSchedules = await countEnabledDashboardReportSubscriptions(
408
+ dashboardId,
409
+ { email: scope.userEmail, orgId: scope.orgId },
410
+ );
411
+ return {
412
+ reportSchedules,
413
+ runsPerMonth: reportSchedules > 0 ? reportSchedules * DAYS_PER_MONTH : null,
414
+ };
415
+ }
416
+
417
+ export function describeReportSchedules(
418
+ reportSchedules: number,
419
+ orgId: string | null,
420
+ ): string {
421
+ return `${reportSchedules} enabled daily email report${reportSchedules === 1 ? "" : "s"} ${orgId ? "in your org" : "of yours"}`;
422
+ }
423
+
424
+ export function describeEstimate(estimate: QueryCostEstimate): string {
425
+ if (
426
+ estimate.status === "not_bigquery" ||
427
+ estimate.status === "not_estimated"
428
+ ) {
429
+ return estimate.reason;
430
+ }
431
+ if (estimate.status === "failed") {
432
+ return `no estimate (${estimate.failure}): ${estimate.error}`;
433
+ }
434
+ const monthly =
435
+ estimate.usdPerMonth === null
436
+ ? "not scheduled"
437
+ : `~${formatUsd(estimate.usdPerMonth)}/month over ${estimate.runsPerMonth} scheduled runs`;
438
+ const flags = [
439
+ estimate.overPerRunThreshold
440
+ ? `over the ${formatUsd(QUERY_COST_THRESHOLDS.perRunUsd)}/run threshold`
441
+ : null,
442
+ estimate.overPerMonthThreshold
443
+ ? `over the ${formatUsd(QUERY_COST_THRESHOLDS.perMonthUsd)}/month threshold`
444
+ : null,
445
+ ].filter(Boolean);
446
+ return `~${formatBytes(estimate.estimatedBytes)}, ${formatUsd(estimate.usdPerRun)} per run, ${monthly}${flags.length ? ` (${flags.join("; ")})` : ""}`;
447
+ }
448
+
279
449
  export function totalCost(estimates: readonly QueryCostEstimate[]): {
280
450
  estimatedBytes: number;
281
451
  usdPerRun: number;
282
452
  usdPerMonth: number | null;
453
+ overPerRunThreshold: boolean;
454
+ overPerMonthThreshold: boolean;
283
455
  } {
284
456
  const estimated = estimates.filter(
285
457
  (
@@ -288,13 +460,20 @@ export function totalCost(estimates: readonly QueryCostEstimate[]): {
288
460
  estimate.status === "estimated",
289
461
  );
290
462
  const monthly = estimated.filter((estimate) => estimate.usdPerMonth !== null);
463
+ const usdPerRun = roundUsd(
464
+ estimated.reduce((sum, e) => sum + e.usdPerRun, 0),
465
+ );
466
+ const usdPerMonth =
467
+ monthly.length === 0
468
+ ? null
469
+ : roundUsd(monthly.reduce((sum, e) => sum + (e.usdPerMonth ?? 0), 0));
291
470
  return {
292
471
  estimatedBytes: estimated.reduce((sum, e) => sum + e.estimatedBytes, 0),
293
- usdPerRun: roundUsd(estimated.reduce((sum, e) => sum + e.usdPerRun, 0)),
294
- usdPerMonth:
295
- monthly.length === 0
296
- ? null
297
- : roundUsd(monthly.reduce((sum, e) => sum + (e.usdPerMonth ?? 0), 0)),
472
+ usdPerRun,
473
+ usdPerMonth,
474
+ overPerRunThreshold: usdPerRun > QUERY_COST_THRESHOLDS.perRunUsd,
475
+ overPerMonthThreshold:
476
+ usdPerMonth !== null && usdPerMonth > QUERY_COST_THRESHOLDS.perMonthUsd,
298
477
  };
299
478
  }
300
479
 
@@ -15,7 +15,10 @@ import "../db/index.js";
15
15
  import * as schema from "../db/schema.js";
16
16
  import { EXTERNAL_DASHBOARD_MIGRATION_SQL } from "../lib/bi-migration/external-dashboards-store.js";
17
17
  import { BUILTIN_DASHBOARD_CHECKS_MIGRATION_SQL } from "../lib/builtin-dashboard-checks-migration.js";
18
- import { CONTENT_METRICS_MIGRATION_SQL } from "../lib/content-metrics/migration.js";
18
+ import {
19
+ CONTENT_METRICS_MIGRATION_SQL,
20
+ CONTENT_COVERAGE_CONFIG_MIGRATION_SQL,
21
+ } from "../lib/content-metrics/migration.js";
19
22
  import { DATA_NOTES_MIGRATION_SQL } from "../lib/data-notes-migration.js";
20
23
  import { KEY_DATES_MIGRATION_SQL } from "../lib/key-dates-migration.js";
21
24
  import { isProductionServerlessRuntime } from "../lib/production-serverless-runtime.js";
@@ -1839,6 +1842,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS dashboard_views_default_per_dashboard_idx
1839
1842
  name: "analytics-content-metrics-tables",
1840
1843
  sql: CONTENT_METRICS_MIGRATION_SQL.join(";\n"),
1841
1844
  },
1845
+ {
1846
+ version: 181,
1847
+ name: "analytics-content-coverage-config",
1848
+ sql: CONTENT_COVERAGE_CONFIG_MIGRATION_SQL,
1849
+ },
1842
1850
  ],
1843
1851
  { table: "analytics_migrations" },
1844
1852
  );
@@ -30,6 +30,7 @@ export const SEED_BUILTIN_DASHBOARD_IDS = [
30
30
  "skills-cli-funnel",
31
31
  "google-analytics",
32
32
  "node-exporter-full",
33
+ "content-view",
33
34
  ] as const;
34
35
 
35
36
  export function nativeV2BuiltinDashboardId(templateId: string): string {
@@ -55,6 +56,7 @@ const LEGACY_NATIVE_ID = /^native-(.+)-[0-9a-f]{10}$/;
55
56
  * never belonged to one. Seed ids map to themselves.
56
57
  */
57
58
  export function builtinDashboardIdForLegacyId(id: string): string | null {
59
+ if (/^content-view-[0-9a-f]{10}$/.test(id)) return "content-view";
58
60
  if (LEGACY_DEMO_ID.test(id)) return BUILTIN_DEMO_DASHBOARD_ID;
59
61
  if (id === "risk-meeting") return BUILTIN_RISK_MEETING_DASHBOARD_ID;
60
62
  const native = LEGACY_NATIVE_ID.exec(id);
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Search Console addresses a property by the exact string it was created
3
+ * with: `sc-domain:example.com` for a Domain property, or the full URL with
4
+ * its trailing slash for a URL-prefix property. Anything else reaches the API
5
+ * as a property nobody has access to, so it fails as "no access" instead of
6
+ * as a typo.
7
+ */
8
+ export type SearchConsolePropertyProblem =
9
+ | { kind: "bare_domain"; suggestion: string }
10
+ | { kind: "invalid_domain"; suggestion?: string }
11
+ | { kind: "missing_trailing_slash"; suggestion: string }
12
+ | { kind: "unrecognized" };
13
+
14
+ export function normalizeSearchConsoleProperty(value: string): string {
15
+ const trimmed = value.trim();
16
+ return /^sc-domain:/i.test(trimmed) ? trimmed.toLowerCase() : trimmed;
17
+ }
18
+
19
+ export function searchConsolePathPrefixProblem(value?: string): boolean {
20
+ return Boolean(value?.trim() && !value.trim().startsWith("/"));
21
+ }
22
+
23
+ const HOSTNAME =
24
+ /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
25
+
26
+ function hostOf(value: string): string | null {
27
+ const url = URL.canParse(value)
28
+ ? new URL(value)
29
+ : URL.canParse(`https://${value}`)
30
+ ? new URL(`https://${value}`)
31
+ : null;
32
+ return url && HOSTNAME.test(url.hostname) ? url.hostname.toLowerCase() : null;
33
+ }
34
+
35
+ export function searchConsolePropertyProblem(
36
+ siteUrl: string,
37
+ ): SearchConsolePropertyProblem | null {
38
+ const value = siteUrl.trim();
39
+ if (value.toLowerCase().startsWith("sc-domain:")) {
40
+ const domain = value.slice("sc-domain:".length);
41
+ if (HOSTNAME.test(domain)) return null;
42
+ const host = hostOf(domain);
43
+ return host
44
+ ? { kind: "invalid_domain", suggestion: `sc-domain:${host}` }
45
+ : { kind: "invalid_domain" };
46
+ }
47
+ if (/^https?:\/\//i.test(value)) {
48
+ if (!URL.canParse(value) || !hostOf(value)) return { kind: "unrecognized" };
49
+ return value.endsWith("/")
50
+ ? null
51
+ : { kind: "missing_trailing_slash", suggestion: `${value}/` };
52
+ }
53
+ const host = /^[^/\s:]+$/.test(value) ? hostOf(value) : null;
54
+ return host
55
+ ? { kind: "bare_domain", suggestion: `sc-domain:${host}` }
56
+ : { kind: "unrecognized" };
57
+ }
58
+
59
+ /** The English explanation agents and the action's validation error show. */
60
+ export function searchConsolePropertyMessage(
61
+ siteUrl: string,
62
+ problem: SearchConsolePropertyProblem,
63
+ ): string {
64
+ const forms =
65
+ "Use sc-domain:example.com for a Domain property, or the full URL-prefix property ending in /, such as https://www.example.com/.";
66
+ switch (problem.kind) {
67
+ case "bare_domain":
68
+ return `"${siteUrl}" isn't a Search Console property. For a Domain property, use ${problem.suggestion}.`;
69
+ case "invalid_domain":
70
+ return problem.suggestion
71
+ ? `A Domain property is sc-domain: followed by the domain only, with no https:// or path: ${problem.suggestion}.`
72
+ : `"${siteUrl}" isn't a valid Domain property. ${forms}`;
73
+ case "missing_trailing_slash":
74
+ return `URL-prefix properties end in /. Use ${problem.suggestion}.`;
75
+ case "unrecognized":
76
+ return `"${siteUrl}" isn't a Search Console property. ${forms}`;
77
+ }
78
+ }
@@ -95,9 +95,9 @@ MCP clients see no `outputSchema` by default. Set `mcpOutputSchema: true` (requi
95
95
 
96
96
  `authorize` decides whether the caller may run the action at all. It wraps
97
97
  `run`, so it holds at **all six dispatch sites** — agent tool, HTTP, frontend
98
- hook, MCP, A2A, CLI — unlike `needsApproval`, which is honoured only in the
99
- agent loop. Use `authorize` for "not everyone may do this"; use `needsApproval`
100
- to ask a human to bless one call a permitted caller is already allowed to make.
98
+ hook, MCP, A2A, CLI. Use `authorize` for "not everyone may do this"; use
99
+ `needsApproval` to ask a human to bless one call a permitted caller is already
100
+ allowed to make.
101
101
 
102
102
  ```ts
103
103
  import { coachAccess } from "../lib/access.js"; // defineAppRoles(...)
@@ -140,4 +140,6 @@ export default defineAction({
140
140
 
141
141
  When the gate is truthy and the call isn't yet approved, the loop emits an `approval_required` event and **stops the turn — `run()` never executes**. A predicate gates conditionally (e.g. only external recipients) and **fails closed**: a throw is treated as "approval required". It receives its own copy of the prepared snapshot of the validated arguments, defaults applied, and the approval binds to the snapshot's hash, so an approved call runs only with the arguments that were approved (a Date binds by its time). The human approves via the chat UI's Approve affordance, which re-issues the turn with the call's `approvalKey`, and only then does the action run.
142
142
 
143
+ Approval is given in the app, so HTTP never grants it. The HTTP action route and the generated Workers entry run a gated call only for the proven app UI (a frontend call from the signed-in user, same origin, holding the short-lived UI capability; `isProvenUiRequest`, the proof `uiOnly` uses) or the app's MCP directory widget. Every other HTTP caller (bearer or API key, MCP OAuth token, org service token, A2A, extension iframe, mobile app) gets `409 approval_required`, and a service identity can never approve. The shared client presents the capability with every write from a signed-in session (minted once per user, reused for nine minutes, never for reads or public pages), so a gated button works on its first request. A call without proof gets `403 ui_capability_required`; the client then re-mints and retries once, which is only the fallback for an expired or rejected capability. Every surface evaluates the gate through `preparedCallNeedsApproval`, so a throwing predicate asks there too. A caller that must run unattended needs a different action, never a bypass of the gate.
144
+
143
145
  **Keep approvals rare** — the default is off and almost every action should leave it off. The canonical example is Mail's `send-email` (`needsApproval: true`). See the `security` skill and the Human Approval doc.
@@ -95,9 +95,9 @@ MCP clients see no `outputSchema` by default. Set `mcpOutputSchema: true` (requi
95
95
 
96
96
  `authorize` decides whether the caller may run the action at all. It wraps
97
97
  `run`, so it holds at **all six dispatch sites** — agent tool, HTTP, frontend
98
- hook, MCP, A2A, CLI — unlike `needsApproval`, which is honoured only in the
99
- agent loop. Use `authorize` for "not everyone may do this"; use `needsApproval`
100
- to ask a human to bless one call a permitted caller is already allowed to make.
98
+ hook, MCP, A2A, CLI. Use `authorize` for "not everyone may do this"; use
99
+ `needsApproval` to ask a human to bless one call a permitted caller is already
100
+ allowed to make.
101
101
 
102
102
  ```ts
103
103
  import { coachAccess } from "../lib/access.js"; // defineAppRoles(...)
@@ -140,4 +140,6 @@ export default defineAction({
140
140
 
141
141
  When the gate is truthy and the call isn't yet approved, the loop emits an `approval_required` event and **stops the turn — `run()` never executes**. A predicate gates conditionally (e.g. only external recipients) and **fails closed**: a throw is treated as "approval required". It receives its own copy of the prepared snapshot of the validated arguments, defaults applied, and the approval binds to the snapshot's hash, so an approved call runs only with the arguments that were approved (a Date binds by its time). The human approves via the chat UI's Approve affordance, which re-issues the turn with the call's `approvalKey`, and only then does the action run.
142
142
 
143
+ Approval is given in the app, so HTTP never grants it. The HTTP action route and the generated Workers entry run a gated call only for the proven app UI (a frontend call from the signed-in user, same origin, holding the short-lived UI capability; `isProvenUiRequest`, the proof `uiOnly` uses) or the app's MCP directory widget. Every other HTTP caller (bearer or API key, MCP OAuth token, org service token, A2A, extension iframe, mobile app) gets `409 approval_required`, and a service identity can never approve. The shared client presents the capability with every write from a signed-in session (minted once per user, reused for nine minutes, never for reads or public pages), so a gated button works on its first request. A call without proof gets `403 ui_capability_required`; the client then re-mints and retries once, which is only the fallback for an expired or rejected capability. Every surface evaluates the gate through `preparedCallNeedsApproval`, so a throwing predicate asks there too. A caller that must run unattended needs a different action, never a bypass of the gate.
144
+
143
145
  **Keep approvals rare** — the default is off and almost every action should leave it off. The canonical example is Mail's `send-email` (`needsApproval: true`). See the `security` skill and the Human Approval doc.
@@ -95,9 +95,9 @@ MCP clients see no `outputSchema` by default. Set `mcpOutputSchema: true` (requi
95
95
 
96
96
  `authorize` decides whether the caller may run the action at all. It wraps
97
97
  `run`, so it holds at **all six dispatch sites** — agent tool, HTTP, frontend
98
- hook, MCP, A2A, CLI — unlike `needsApproval`, which is honoured only in the
99
- agent loop. Use `authorize` for "not everyone may do this"; use `needsApproval`
100
- to ask a human to bless one call a permitted caller is already allowed to make.
98
+ hook, MCP, A2A, CLI. Use `authorize` for "not everyone may do this"; use
99
+ `needsApproval` to ask a human to bless one call a permitted caller is already
100
+ allowed to make.
101
101
 
102
102
  ```ts
103
103
  import { coachAccess } from "../lib/access.js"; // defineAppRoles(...)
@@ -140,4 +140,6 @@ export default defineAction({
140
140
 
141
141
  When the gate is truthy and the call isn't yet approved, the loop emits an `approval_required` event and **stops the turn — `run()` never executes**. A predicate gates conditionally (e.g. only external recipients) and **fails closed**: a throw is treated as "approval required". It receives its own copy of the prepared snapshot of the validated arguments, defaults applied, and the approval binds to the snapshot's hash, so an approved call runs only with the arguments that were approved (a Date binds by its time). The human approves via the chat UI's Approve affordance, which re-issues the turn with the call's `approvalKey`, and only then does the action run.
142
142
 
143
+ Approval is given in the app, so HTTP never grants it. The HTTP action route and the generated Workers entry run a gated call only for the proven app UI (a frontend call from the signed-in user, same origin, holding the short-lived UI capability; `isProvenUiRequest`, the proof `uiOnly` uses) or the app's MCP directory widget. Every other HTTP caller (bearer or API key, MCP OAuth token, org service token, A2A, extension iframe, mobile app) gets `409 approval_required`, and a service identity can never approve. The shared client presents the capability with every write from a signed-in session (minted once per user, reused for nine minutes, never for reads or public pages), so a gated button works on its first request. A call without proof gets `403 ui_capability_required`; the client then re-mints and retries once, which is only the fallback for an expired or rejected capability. Every surface evaluates the gate through `preparedCallNeedsApproval`, so a throwing predicate asks there too. A caller that must run unattended needs a different action, never a bypass of the gate.
144
+
143
145
  **Keep approvals rare** — the default is off and almost every action should leave it off. The canonical example is Mail's `send-email` (`needsApproval: true`). See the `security` skill and the Human Approval doc.
@@ -11,7 +11,6 @@ import {
11
11
  cliBoolean,
12
12
  googleEventResultId,
13
13
  normalizeWritableGoogleEventId,
14
- rawCliBoolean,
15
14
  requireActionUserEmail,
16
15
  resolveGoogleEventAccountEmail,
17
16
  resolveOwnedAccountEmail,
@@ -53,8 +52,7 @@ export default defineAction({
53
52
  }),
54
53
  toolCallable: false,
55
54
  needsApproval: ({ sendUpdates, notificationMessage, removeOnly }) =>
56
- !rawCliBoolean(removeOnly) &&
57
- (sendUpdates === "all" || !!notificationMessage?.trim()),
55
+ !removeOnly && (sendUpdates === "all" || !!notificationMessage?.trim()),
58
56
  run: async (args) => {
59
57
  const ownerEmail = requireActionUserEmail();
60
58
  if (!(await googleCalendar.isConnected(ownerEmail))) {
@@ -19,7 +19,6 @@ import {
19
19
  isBookedOnAccount,
20
20
  mapWithConcurrency,
21
21
  normalizeWritableGoogleEventId,
22
- rawCliBoolean,
23
22
  requireActionUserEmail,
24
23
  requireExplicitBound,
25
24
  resolveBulkGoogleEventAccountEmail,
@@ -121,7 +120,7 @@ export default defineAction({
121
120
  .describe("Return the matched events without deleting anything"),
122
121
  }),
123
122
  toolCallable: false,
124
- needsApproval: ({ dryRun }) => !rawCliBoolean(dryRun),
123
+ needsApproval: ({ dryRun }) => !dryRun,
125
124
  run: async (args) => {
126
125
  const ownerEmail = requireActionUserEmail();
127
126
  if (!(await googleCalendar.isConnected(ownerEmail))) {
@@ -21,10 +21,6 @@ export const cliBoolean = z
21
21
  .union([z.boolean(), z.enum(["true", "false"])])
22
22
  .transform((value) => value === true || value === "true");
23
23
 
24
- export function rawCliBoolean(value: unknown): boolean {
25
- return value === true || value === "true";
26
- }
27
-
28
24
  export const eventTypeInput = z
29
25
  .enum(["default", "outOfOffice", "focusTime", "workingLocation"])
30
26
  .optional();
@@ -74,12 +74,8 @@ function mergeAttendees(
74
74
  return Array.from(merged.values());
75
75
  }
76
76
 
77
- function namesGuests(value: unknown): boolean {
78
- if (typeof value !== "string" && !Array.isArray(value)) return false;
79
- return (
80
- (normalizeAttendees(value as Parameters<typeof normalizeAttendees>[0])
81
- ?.length ?? 0) > 0
82
- );
77
+ function namesGuests(value: Parameters<typeof normalizeAttendees>[0]): boolean {
78
+ return (normalizeAttendees(value)?.length ?? 0) > 0;
83
79
  }
84
80
 
85
81
  function workingLocationTitle(