@k2b/cloud 0.45.0 → 0.46.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 (71) hide show
  1. package/package.json +5 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/ai/capabilities.ts +4 -2
  9. package/src/ai/chat/map-messages.ts +270 -0
  10. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  11. package/src/ai/chat/map-notebook.tsx +301 -0
  12. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  13. package/src/ai/chat/map-widget.css +107 -0
  14. package/src/ai/chat/map-widget.tsx +1279 -0
  15. package/src/ai/chat/message-utils.ts +1 -0
  16. package/src/ai/chat/tool-names.ts +2 -0
  17. package/src/ai/chat/turn-layout.ts +2 -1
  18. package/src/ai/chat/turn-view.tsx +17 -1
  19. package/src/ai/default-tools.ts +3 -0
  20. package/src/ai/executor.ts +13 -1
  21. package/src/ai/map-contracts.ts +209 -0
  22. package/src/ai/map-execution.ts +14 -0
  23. package/src/ai/map-notebook-save.ts +276 -0
  24. package/src/ai/map-notebook.ts +32 -0
  25. package/src/ai/map-opening-hours.ts +40 -0
  26. package/src/ai/map-routes.ts +84 -0
  27. package/src/ai/map-service.ts +288 -0
  28. package/src/ai/map-store.ts +137 -0
  29. package/src/ai/map-tool.ts +73 -0
  30. package/src/ai/migrate.ts +3 -0
  31. package/src/ai/protocol.ts +3 -1
  32. package/src/ai/routes.ts +2 -0
  33. package/src/ai/store.ts +19 -6
  34. package/src/ai/tools.ts +14 -0
  35. package/src/ai/types.ts +3 -0
  36. package/src/api/capabilities.ts +90 -12
  37. package/src/api/mcp.ts +4 -2
  38. package/src/browser/notifications.ts +25 -8
  39. package/src/capabilities/discussion-providers.ts +99 -0
  40. package/src/capabilities/executions.ts +2 -2
  41. package/src/capabilities/migrate.ts +10 -1
  42. package/src/capabilities/resource-readers.ts +58 -0
  43. package/src/capabilities/server-invoke.ts +23 -13
  44. package/src/capabilities/server.ts +17 -6
  45. package/src/capabilities/testing.ts +13 -0
  46. package/src/contracts/capabilities.ts +137 -4
  47. package/src/contracts/discussions.ts +91 -0
  48. package/src/contracts/geo-document.ts +60 -0
  49. package/src/contracts/geo-snapshot.ts +86 -0
  50. package/src/contracts/geo.ts +14 -0
  51. package/src/contracts/index.ts +2 -0
  52. package/src/contracts/outgoing-mail.ts +1 -1
  53. package/src/contracts/resource-types.ts +21 -0
  54. package/src/geo/AddressAutocomplete.tsx +213 -0
  55. package/src/geo/messages.ts +29 -0
  56. package/src/geo/navigation.ts +25 -0
  57. package/src/geo/ui.ts +2 -0
  58. package/src/services/accounts/app.ts +28 -6
  59. package/src/services/accounts/entities.ts +11 -4
  60. package/src/services/accounts/groups.ts +10 -4
  61. package/src/services/accounts/local-groups.ts +87 -38
  62. package/src/services/accounts/machine-group-management.ts +94 -0
  63. package/src/services/geo/cache.ts +2 -1
  64. package/src/services/geo/geoapify.ts +3 -0
  65. package/src/services/identity/invocation-token.ts +110 -0
  66. package/src/services/pdf/geo-snapshot.ts +107 -0
  67. package/src/services/pdf/index.ts +2 -0
  68. package/src/ssr/AdminArea.tsx +2 -2
  69. package/src/ssr/AdminLayout.tsx +1 -1
  70. package/src/ssr/AdminSidebar.tsx +1 -1
  71. package/src/styles/global.css +2 -0
package/src/ai/migrate.ts CHANGED
@@ -2,6 +2,7 @@ import { sql } from "bun";
2
2
  import { CapabilityActionReviewDataSchema } from "../contracts/capabilities";
3
3
  import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
4
4
  import { migrateChatTaskReasons } from "./chat-task-reasons-migrate";
5
+ import { migrateAiMaps } from "./map-store";
5
6
  import { migrateAiMessageQueue } from "./message-queue";
6
7
  import { migrateAiModelAccess } from "./model-access-migrate";
7
8
  import { migrateAiQuotas } from "./quotas-migrate";
@@ -81,6 +82,8 @@ export const migrateCloudAi = async (): Promise<void> => {
81
82
  END $$
82
83
  `.simple();
83
84
 
85
+ await migrateAiMaps();
86
+
84
87
  await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
85
88
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_conversations_short_id ON ai.conversations(short_id)`.simple();
86
89
  await backfillAiShortIds(
@@ -258,6 +258,7 @@ export const buildBlocksFromMessages = (
258
258
  toolPresentations?: Record<string, AiToolPresentation>;
259
259
  toolReviews?: Record<string, AiStoredActionReview>;
260
260
  toolChecks?: Record<string, AiActionCheck>;
261
+ toolInterimResults?: Record<string, unknown>;
261
262
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
262
263
  websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
263
264
  } | null;
@@ -287,6 +288,7 @@ export const buildBlocksFromMessages = (
287
288
  presentation: meta?.toolPresentations?.[block.id],
288
289
  review: meta?.toolReviews?.[block.id],
289
290
  check: meta?.toolChecks?.[block.id],
291
+ result: meta?.toolInterimResults?.[block.id],
290
292
  // A turn that ended before an approved call returned records the approval on the call's message, and an
291
293
  // approval it left waiting as expired.
292
294
  ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
@@ -311,7 +313,7 @@ export const buildBlocksFromMessages = (
311
313
  blocks[at!] = {
312
314
  ...existing,
313
315
  status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
314
- result: message.result,
316
+ result: message.isError && existing.result !== undefined ? existing.result : message.result,
315
317
  isError: message.isError,
316
318
  ...(createdAt ? { at: createdAt } : {}),
317
319
  ...(outcome === "approved" ? { approved: true } : {}),
package/src/ai/routes.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  } from "./http";
67
67
  import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema } from "./local-tool-contracts";
68
68
  import { aiMaintenanceJobs } from "./maintenance";
69
+ import { aiMapRoutes } from "./map-routes";
69
70
  import { AI_MEMORY_CONTENT_MAX_CHARS, aiMemories } from "./memories";
70
71
  import { aiMemoryLearningRuns } from "./memory-learning-runs";
71
72
  import { createCloudAiMemoryTool } from "./memory-tool";
@@ -338,6 +339,7 @@ export const aiRoutes = (() => {
338
339
  .use("*", noTransform())
339
340
  .use(rateLimit())
340
341
  .use("*", auth.requireRole("authenticated"))
342
+ .route("/conversations", aiMapRoutes)
341
343
  .get("/status", async (c) => {
342
344
  const [status, enabled] = await Promise.all([
343
345
  assistantAiSettingsState(c.get("accessSubject")),
package/src/ai/store.ts CHANGED
@@ -7,12 +7,14 @@ import { type CapabilityActionReviewPresentation, CapabilityActionReviewPresenta
7
7
  import { logger } from "../services/logging";
8
8
  import { toPgTextArray } from "../services/postgres";
9
9
  import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
10
+ import { AiMapSchema } from "./map-contracts";
10
11
 
11
12
  export { AI_TOOL_CHECK_STALE_MS };
12
13
 
13
14
  import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
14
15
  import { projectSettledAiToolChecks } from "./capability-result";
15
16
  import { aiTurnErrorText } from "./chat/turn-error";
17
+ import { projectAiMaps } from "./map-store";
16
18
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
17
19
  import type { AiTurnBlock } from "./protocol";
18
20
  import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
@@ -1515,6 +1517,17 @@ export const aiConversations: AiConversationService = {
1515
1517
  SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
1516
1518
  FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
1517
1519
  `;
1520
+ // A fork owns independent snapshots. Rewrite only copied map references, never the source conversation.
1521
+ const mapRows = await tx<
1522
+ { id: string; snapshot: unknown; retry: unknown }[]
1523
+ >`SELECT id,snapshot,retry FROM ai.maps WHERE conversation_id=${input.sourceConversationId}::uuid AND (id IN (SELECT (message->'result'->'map'->>'id')::uuid FROM ai.messages WHERE conversation_id=${target.id}::uuid AND role='tool_result' AND message->>'name'='map') OR snapshot->>'originCallId' IN (SELECT block->>'id' FROM ai.messages CROSS JOIN LATERAL jsonb_array_elements(message->'content') block WHERE conversation_id=${target.id}::uuid AND role='assistant' AND block->>'type'='tool_call' AND block->>'name'='map'))`;
1524
+ for (const saved of mapRows) {
1525
+ const id = crypto.randomUUID();
1526
+ const snapshot = AiMapSchema.parse(typeof saved.snapshot === "string" ? JSON.parse(saved.snapshot) : saved.snapshot);
1527
+ const next = { ...snapshot, id, conversationId: target.short_id };
1528
+ await tx`INSERT INTO ai.maps(id,conversation_id,revision,snapshot,retry) VALUES(${id}::uuid,${target.id}::uuid,${snapshot.revision},${JSON.stringify(next)}::text::jsonb,${saved.retry ? JSON.stringify(saved.retry) : null}::text::jsonb)`;
1529
+ await tx`UPDATE ai.messages SET message=jsonb_set(message,'{result,map}',${JSON.stringify({ ...next })}::text::jsonb) WHERE conversation_id=${target.id}::uuid AND role='tool_result' AND message->>'name'='map' AND message->'result'->'map'->>'id'=${saved.id}`;
1530
+ }
1518
1531
  return rowToConversation(target);
1519
1532
  });
1520
1533
  },
@@ -2569,7 +2582,7 @@ export const aiConversations: AiConversationService = {
2569
2582
  AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
2570
2583
  ORDER BY seq ASC, (kind = 'summary')::int ASC
2571
2584
  `;
2572
- return rows.map(rowToMessage);
2585
+ return projectAiMaps(rows.map(rowToMessage));
2573
2586
  },
2574
2587
 
2575
2588
  listMessagesPage: async (input) => {
@@ -2598,7 +2611,7 @@ export const aiConversations: AiConversationService = {
2598
2611
  AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
2599
2612
  ORDER BY seq ASC, (kind = 'summary')::int ASC
2600
2613
  `;
2601
- const messages = rows.map(rowToMessage);
2614
+ const messages = await projectAiMaps(rows.map(rowToMessage));
2602
2615
  const oldestSeq = messages[0]?.seq;
2603
2616
  if (oldestSeq === undefined) return { messages, hasMore: false };
2604
2617
  const older = await sql<{ exists: boolean }[]>`
@@ -2639,7 +2652,7 @@ export const aiConversations: AiConversationService = {
2639
2652
  `;
2640
2653
  const page = rows.filter((row) => Number(row.page_rank) <= limit);
2641
2654
  return {
2642
- messages: page.map(rowToMessage),
2655
+ messages: await projectAiMaps(page.map(rowToMessage)),
2643
2656
  ...(rows.some((row) => Number(row.page_rank) > limit) && page.at(-1) ? { nextCursor: String(page.at(-1)!.seq) } : {}),
2644
2657
  };
2645
2658
  },
@@ -2758,7 +2771,7 @@ export const aiConversations: AiConversationService = {
2758
2771
  AND compacted_at IS NULL
2759
2772
  ORDER BY seq ASC
2760
2773
  `;
2761
- return rows.map(rowToMessage);
2774
+ return projectAiMaps(rows.map(rowToMessage));
2762
2775
  },
2763
2776
 
2764
2777
  listTurnMessages: async (input) => {
@@ -2773,7 +2786,7 @@ export const aiConversations: AiConversationService = {
2773
2786
  AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
2774
2787
  ORDER BY seq ASC
2775
2788
  `;
2776
- return rows.map(rowToMessage);
2789
+ return projectAiMaps(rows.map(rowToMessage));
2777
2790
  },
2778
2791
 
2779
2792
  setMessageFeedback: async (input) => {
@@ -3968,7 +3981,7 @@ export const aiConversations: AiConversationService = {
3968
3981
  ).map(rowToMessage)
3969
3982
  : await aiConversations.listContextMessages({ conversationId: input.conversationId });
3970
3983
  return projectSettledAiToolChecks(
3971
- rows.map((row) => ({
3984
+ (await projectAiMaps(rows, true)).map((row) => ({
3972
3985
  seq: row.seq,
3973
3986
  kind: row.kind,
3974
3987
  meta: row.meta,
package/src/ai/tools.ts CHANGED
@@ -4,6 +4,7 @@ import type { z } from "zod";
4
4
  import type { CapabilityActionCallReview } from "../contracts/capabilities";
5
5
  import type { RequestActor } from "../server";
6
6
  import { aiToolNeedsApproval } from "./approvals";
7
+ import { resolveAiToolResultMaxChars } from "./tool-result-budget";
7
8
  import type {
8
9
  AiApprovalTarget,
9
10
  AiDataBoundary,
@@ -64,6 +65,8 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
64
65
  timeZone?: string;
65
66
  /** Localized short status; never include payloads or secrets. */
66
67
  reportProgress?: (message: string) => Promise<void>;
68
+ /** A validated result snapshot while a server tool is still running. */
69
+ reportResult?: (result: z.infer<TOutput>) => Promise<void>;
67
70
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
68
71
  requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
69
72
  /** Relays a decision for an effect another platform component consumes and executes. */
@@ -117,6 +120,8 @@ export type AiToolPreparationContext = {
117
120
  locale?: string;
118
121
  timeZone?: string;
119
122
  reportToolProgress?: (callId: string, message: string) => Promise<void>;
123
+ reportToolResult?: (callId: string, result: unknown) => Promise<void>;
124
+ maxToolResultChars?: number;
120
125
  /** Uses the approved snapshot immediately before the tool executes its effect. */
121
126
  consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
122
127
  /** Links a relayed decision to the record the effect owner will consume. */
@@ -241,6 +246,15 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
241
246
  locale: input.locale,
242
247
  timeZone: input.timeZone,
243
248
  reportProgress: callId && report ? (message) => report(callId, message) : undefined,
249
+ reportResult:
250
+ callId && input.reportToolResult
251
+ ? async (result) => {
252
+ const validated = tool.def.outputSchema.parse(result);
253
+ const maximum = input.maxToolResultChars ?? resolveAiToolResultMaxChars({ configuredMaxChars: Number.MAX_SAFE_INTEGER });
254
+ if (JSON.stringify(validated).length > maximum) throw new Error("AI_TOOL_INTERIM_RESULT_TOO_LARGE");
255
+ await input.reportToolResult!(callId, validated);
256
+ }
257
+ : undefined,
244
258
  reportWebsiteReceipts:
245
259
  callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
246
260
  });
package/src/ai/types.ts CHANGED
@@ -313,6 +313,7 @@ export type AiStoredMessage = {
313
313
  toolPresentations?: Record<string, AiToolPresentation>;
314
314
  toolReviews?: Record<string, AiStoredActionReview>;
315
315
  toolChecks?: Record<string, AiActionCheck>;
316
+ toolInterimResults?: Record<string, unknown>;
316
317
  /** How the person decided an approval; `expired` when the turn ended while it still waited. */
317
318
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
318
319
  /** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
@@ -1159,6 +1160,8 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
1159
1160
  timeZone?: string;
1160
1161
  /** Localized short status; never include payloads or secrets. */
1161
1162
  reportProgress?: (message: string) => Promise<void>;
1163
+ /** A validated result snapshot while a server tool is still running. */
1164
+ reportResult?: (result: z.infer<TOutput>) => Promise<void>;
1162
1165
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
1163
1166
  requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
1164
1167
  /** Relays a decision for an effect another platform component consumes and executes. */
@@ -3,14 +3,14 @@ import { describeRoute } from "hono-openapi";
3
3
  import { z } from "zod";
4
4
  import { type CatalogSubscription, projectAppEventCatalog, readCatalogSubscriptions } from "../_internal/app-event-catalog";
5
5
  import { readBoundedJson } from "../_internal/bounded-json";
6
- import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
6
+ import { isPersonVisibleCapabilityQuery, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
7
7
  import {
8
8
  capabilityActionReceiptIssues,
9
9
  capabilityActionReviewIssues,
10
10
  capabilityQueryChecksActionOutcome,
11
11
  } from "../_internal/capability-reviews";
12
12
  import { capabilityTimeoutMs, createCapabilityDeadline, withCapabilityRequestTimeout } from "../_internal/capability-timeout";
13
- import { getCapability, listApps } from "../_internal/registry";
13
+ import { getApp, getCapability, listApps } from "../_internal/registry";
14
14
  import { resolveCommandLink } from "../capabilities/command-link";
15
15
  import {
16
16
  type CapabilityClaimScope,
@@ -59,12 +59,13 @@ import { AppAccessDeniedError, appAccessDeniedBody } from "../server/middleware/
59
59
  import { oauthScopeByOperation } from "../server/middleware/auth";
60
60
  import { noTransform } from "../server/middleware/no-transform";
61
61
  import { hasOAuthScope } from "../server/oauth-scope";
62
+ import { ratelimit } from "../server/ratelimit";
62
63
  import { appAccessAuthority, canUseApp } from "../services/app-access";
63
64
  import { discoverableApps } from "../services/app-discovery";
64
65
  import { authorityFromParentInvocation, invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
65
66
  import { capabilityInvocationOperation } from "../services/identity/invocation-operations";
66
67
  import type { CloudInvocationClaims, InvocationAuthority } from "../services/identity/invocation-token";
67
- import { normalizeInvocationRequestId } from "../services/identity/invocation-token";
68
+ import { normalizeInvocationRequestId, signAppQueryInvocation } from "../services/identity/invocation-token";
68
69
  import { issueInvocationToken as signInvocationToken } from "../services/identity/issue-invocation";
69
70
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
70
71
  import {
@@ -73,6 +74,7 @@ import {
73
74
  nestedInvocationLeases,
74
75
  nestedSourceDeadline,
75
76
  } from "../services/identity/nested-invocation";
77
+ import type { AuthenticatedWorkload } from "../services/identity/workload-auth";
76
78
  import { logger } from "../services/logging";
77
79
  import { withMandateIssueAuthority } from "../services/mandates";
78
80
  import * as settings from "../services/settings";
@@ -80,6 +82,9 @@ import { resolveAppPresentation } from "../shared/app-presentation";
80
82
  import { capabilityMessages } from "../shared/capability-messages";
81
83
  import { dispatchCapabilityStream, sealCapabilityStream } from "./capability-streams";
82
84
 
85
+ // Approved reader-check budget, shared across targets for each calling application.
86
+ export const APP_QUERY_REQUESTS_PER_MINUTE = 1_501;
87
+ const appQueryLimit = ratelimit({ id: "app-queries", limit: APP_QUERY_REQUESTS_PER_MINUTE, windowSecs: 60 });
83
88
  const log = logger("capabilities");
84
89
  const DEFAULT_CATALOG_LIMIT = 10;
85
90
  const MAX_CATALOG_LIMIT = 25;
@@ -114,6 +119,7 @@ const CapabilityCatalogQuerySchema = z
114
119
  export type CapabilityRouteDependencies = {
115
120
  listApps?: () => Promise<AppRegistryEntry[]>;
116
121
  eventSubscriptions?: () => Promise<CatalogSubscription[]>;
122
+ getApp?: (appId: string) => Promise<AppRegistryEntry | null>;
117
123
  getCapability?: (appId: string) => Promise<CapabilityRegistryEntry | null>;
118
124
  fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
119
125
  authenticate?: MiddlewareHandler<AuthContext>;
@@ -122,12 +128,15 @@ export type CapabilityRouteDependencies = {
122
128
  withMandateIssueAuthority?: typeof withMandateIssueAuthority;
123
129
  queryTimeoutMs?: number;
124
130
  actionTimeoutMs?: number;
131
+ checkAppQueryRate?: (appId: string) => Promise<{ limited: boolean }>;
125
132
  reviewTimeoutMs?: number;
126
133
  appUrl?: () => Promise<string>;
127
134
  };
128
135
 
129
136
  export type CapabilityDispatchDependencies = Pick<
130
137
  CapabilityRouteDependencies,
138
+ | "checkAppQueryRate"
139
+ | "getApp"
131
140
  | "getCapability"
132
141
  | "fetch"
133
142
  | "queryTimeoutMs"
@@ -223,7 +232,7 @@ export const loadCapabilityCatalogPage = async (
223
232
  appName: presentedEntry.name,
224
233
  appIcon: capability.appIcon,
225
234
  appDescription: presentedEntry.description,
226
- manifest,
235
+ manifest: { ...manifest, queries: manifest.queries.filter(isPersonVisibleCapabilityQuery) },
227
236
  ...(manifest.events ? { events: projectAppEventCatalog(manifest, manifest.events.length ? await loadSubscribers() : []) } : {}),
228
237
  };
229
238
  const projectedBytes = new TextEncoder().encode(JSON.stringify(projected)).byteLength;
@@ -276,6 +285,8 @@ export type CapabilityDispatchParams = {
276
285
  assistantTurn?: { conversationId: string; turnId: string };
277
286
  /** Core-only absolute child deadline; cannot be extended by the application. */
278
287
  deadline?: number;
288
+ /** Core-only authenticated workload. Never populated from request JSON. */
289
+ workload?: AuthenticatedWorkload;
279
290
  mandate?: MandatedCapabilityAuthority;
280
291
  callingAppId?: string;
281
292
  /** Internal observation only: called immediately before attempting the app request. */
@@ -342,6 +353,43 @@ const runCapabilityDispatch = async (
342
353
  );
343
354
  return error;
344
355
  }
356
+ if ("appOnly" in operation && operation.appOnly !== undefined && !params.workload)
357
+ return errorResponse("FORBIDDEN", "App authority required for this query", 403);
358
+ if (params.workload) {
359
+ if (
360
+ params.authority ||
361
+ params.mandate ||
362
+ params.parentInvocation ||
363
+ params.review ||
364
+ params.kind !== "queries" ||
365
+ !("appOnly" in operation) ||
366
+ operation.appOnly !== true ||
367
+ operation.stream
368
+ )
369
+ return errorResponse("FORBIDDEN", "App caller is not allowed for this query", 403);
370
+ try {
371
+ const caller = await deadline.wait(() => (params.dependencies?.getApp ?? getApp)(params.workload!.appId));
372
+ if (!caller) return errorResponse("APP_UNAVAILABLE", "Calling app registry entry unavailable", 503);
373
+ if (!caller.platformPermissions?.includes("resources:readers"))
374
+ return errorResponse("APP_QUERY_NOT_ADMITTED", "App caller does not declare resources:readers", 403);
375
+ } catch {
376
+ deadline.check();
377
+ return errorResponse("APP_UNAVAILABLE", "App registry unavailable", 503);
378
+ }
379
+ try {
380
+ if (
381
+ (
382
+ await deadline.wait(() =>
383
+ (params.dependencies?.checkAppQueryRate ?? ((appId) => appQueryLimit.check(appId)))(params.workload!.appId),
384
+ )
385
+ ).limited
386
+ )
387
+ return errorResponse("RATE_LIMITED", "App query rate limit exceeded; retry after one minute", 429);
388
+ } catch {
389
+ deadline.check();
390
+ return errorResponse("APP_UNAVAILABLE", "App query rate limiter unavailable", 503);
391
+ }
392
+ }
345
393
  if (params.review && (params.kind !== "actions" || !("review" in operation) || operation.review !== true)) {
346
394
  const error = errorResponse(
347
395
  "CAPABILITY_NOT_FOUND",
@@ -488,6 +536,33 @@ const runCapabilityDispatch = async (
488
536
  });
489
537
  })()
490
538
  : await (async () => {
539
+ if (params.workload) {
540
+ const workload = params.workload;
541
+ return deadline.wait(() =>
542
+ (params.dependencies?.withActiveSigner ?? withActiveIdentitySigner)(
543
+ "invocation",
544
+ async (signer) => {
545
+ deadline.check();
546
+ const signed = await signAppQueryInvocation({
547
+ workload,
548
+ targetAppId: params.appId,
549
+ capabilityId: params.capabilityId,
550
+ schemaHash: operation.schemaHash,
551
+ input: input.data,
552
+ requestId,
553
+ signer,
554
+ });
555
+ observed.invocationId = signed.claims.jti;
556
+ return new Headers({
557
+ "content-type": "application/json",
558
+ accept: "application/json",
559
+ authorization: `Bearer ${signed.token}`,
560
+ });
561
+ },
562
+ { signal, timeoutMs: deadline.remainingMs() },
563
+ ),
564
+ );
565
+ }
491
566
  if (!params.authority) throw new Error("Resolved request authority is required for Cloud invocation issuance");
492
567
  const authority = params.authority;
493
568
  const signed = await deadline.wait(() =>
@@ -665,12 +740,15 @@ const runCapabilityDispatch = async (
665
740
  try {
666
741
  params.onUpstreamAttempt?.();
667
742
  const fetchResponse = () =>
668
- fetchUpstream(`${entry.endpoint}/${params.kind}/${encodeURIComponent(params.capabilityId)}${params.review ? "/review" : ""}`, {
669
- method: "POST",
670
- headers,
671
- body: requestBody,
672
- signal,
673
- });
743
+ fetchUpstream(
744
+ `${entry.endpoint}/${params.workload ? "app-queries" : params.kind}/${encodeURIComponent(params.capabilityId)}${params.review ? "/review" : ""}`,
745
+ {
746
+ method: "POST",
747
+ headers,
748
+ body: requestBody,
749
+ signal,
750
+ },
751
+ );
674
752
  // A read may be released at its deadline; Action effects retain their existing uncertainty handling.
675
753
  response = params.kind === "queries" || params.review ? await deadline.wait(fetchResponse) : await fetchResponse();
676
754
  } catch (error) {
@@ -895,12 +973,12 @@ export const dispatchCapability = async (params: CapabilityDispatchParams): Prom
895
973
  origin: params.origin,
896
974
  invocationId: observed.invocationId,
897
975
  parentInvocationId: params.parentInvocation?.jti,
898
- viaAppId: params.parentInvocation ? params.callingAppId : undefined,
976
+ viaAppId: params.workload?.appId ?? (params.parentInvocation ? params.callingAppId : undefined),
899
977
  appId: params.appId,
900
978
  capability: `${params.appId}.${params.capabilityId}`,
901
979
  kind: params.kind === "queries" ? "query" : "action",
902
980
  destructive: observed.destructive,
903
- actorKind: principal?.type ?? null,
981
+ actorKind: params.workload ? "app" : (principal?.type ?? null),
904
982
  actorId: principal?.id ?? null,
905
983
  userId: actor?.kind === "user" ? actor.user.id : (actor?.delegatedUser?.id ?? (principal?.type === "user" ? principal.id : null)),
906
984
  accessSubject: subject && subject.id !== principal?.id ? subject : null,
package/src/api/mcp.ts CHANGED
@@ -16,7 +16,7 @@ import { Hono, type MiddlewareHandler } from "hono";
16
16
  import { z } from "zod";
17
17
  import { type CatalogSubscription, projectAppEventCatalog, readEventCatalogSubscriptions } from "../_internal/app-event-catalog";
18
18
  import { readBoundedJson } from "../_internal/bounded-json";
19
- import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
19
+ import { isPersonVisibleCapabilityQuery, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
20
20
  import { capabilitySentenceText } from "../_internal/capability-sentences";
21
21
  import { withCapabilityRequestTimeout } from "../_internal/capability-timeout";
22
22
  import {
@@ -311,7 +311,9 @@ const orderedCapabilityEntries = function* (app: CapabilityRegistryEntry): Gener
311
311
  for (const operation of [...app.manifest.actions].sort((left, right) => left.localId.localeCompare(right.localId))) {
312
312
  yield { app, kind: "actions", operation };
313
313
  }
314
- for (const operation of [...app.manifest.queries].sort((left, right) => left.localId.localeCompare(right.localId))) {
314
+ for (const operation of app.manifest.queries
315
+ .filter(isPersonVisibleCapabilityQuery)
316
+ .sort((left, right) => left.localId.localeCompare(right.localId))) {
315
317
  yield { app, kind: "queries", operation };
316
318
  }
317
319
  };
@@ -13,6 +13,8 @@ export type BrowserNotificationState = {
13
13
  permission: NotificationPermission;
14
14
  enabled: boolean;
15
15
  reason?: string;
16
+ /** On iPhone and iPad outside the Home Screen app: notifications start working once Cloud is added to the Home Screen. */
17
+ homeScreenRequired?: true;
16
18
  };
17
19
 
18
20
  const isIos = (): boolean =>
@@ -21,7 +23,15 @@ const isIos = (): boolean =>
21
23
  const isStandalone = (): boolean =>
22
24
  window.matchMedia("(display-mode: standalone)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
23
25
 
24
- const support = (): { supported: boolean; reason?: string } => {
26
+ const support = (): { supported: boolean; reason?: string; homeScreenRequired?: true } => {
27
+ // Safari on iPhone and iPad offers push only to the Home Screen app, so it lacks the APIs checked below.
28
+ if (typeof window !== "undefined" && isIos() && !isStandalone()) {
29
+ return {
30
+ supported: false,
31
+ reason: "On iPhone and iPad, add Cloud to your Home Screen before enabling browser notifications.",
32
+ homeScreenRequired: true,
33
+ };
34
+ }
25
35
  if (
26
36
  typeof window === "undefined" ||
27
37
  !window.isSecureContext ||
@@ -31,12 +41,6 @@ const support = (): { supported: boolean; reason?: string } => {
31
41
  ) {
32
42
  return { supported: false, reason: "Browser notifications are not supported in this browser." };
33
43
  }
34
- if (isIos() && !isStandalone()) {
35
- return {
36
- supported: false,
37
- reason: "On iPhone and iPad, add Cloud to your Home Screen before enabling browser notifications.",
38
- };
39
- }
40
44
  return { supported: true };
41
45
  };
42
46
 
@@ -101,7 +105,13 @@ const pushSubscriptionError = (cause: unknown): Error => {
101
105
  const currentState = async (registration?: ServiceWorkerRegistration): Promise<BrowserNotificationState> => {
102
106
  const capability = support();
103
107
  if (!capability.supported) {
104
- return { supported: false, permission: "denied", enabled: false, reason: capability.reason };
108
+ return {
109
+ supported: false,
110
+ permission: "denied",
111
+ enabled: false,
112
+ reason: capability.reason,
113
+ ...(capability.homeScreenRequired ? { homeScreenRequired: true } : {}),
114
+ };
105
115
  }
106
116
  return withNotificationTimeout(
107
117
  (async () => {
@@ -131,6 +141,13 @@ export const browserNotificationClient = {
131
141
  return (await response.json()).state;
132
142
  },
133
143
 
144
+ /** End do not disturb now, for every app. Quiet hours keep their schedule. Returns the quiet state afterwards. */
145
+ endDoNotDisturb: async (): Promise<NotificationQuietState> => {
146
+ const response = await apiClient.me.notifications.quiet.$patch({ json: { doNotDisturbUntil: null } });
147
+ if (!response.ok) throw await responseError(response, "Failed to end do not disturb.");
148
+ return (await response.json()).state;
149
+ },
150
+
134
151
  /** Close this application's group on this device without opting into notifications. */
135
152
  closeGroup: async (appId: string, group: string): Promise<void> => {
136
153
  const tag = notificationGroupTag(appId, group);
@@ -0,0 +1,99 @@
1
+ import { discussionsIssues } from "../contracts/discussions";
2
+ import { type CapabilityCaller, listCapabilityCatalog } from "./server-invoke";
3
+ import type { CapabilityCatalogApp } from "./types";
4
+
5
+ // Same interactive discovery budget as the calendar reader. Cursor pages are sequential (one in flight).
6
+ const MAX_CALLS = 60;
7
+ const MAX_BYTES = 4 * 1024 * 1024;
8
+ export type DiscussionProvider = {
9
+ app: CapabilityCatalogApp;
10
+ itemType: string;
11
+ containerType: string;
12
+ containers: string;
13
+ activity?: string;
14
+ };
15
+
16
+ /** Request-local discovery. Failure, truncation, and cancellation throw; no partial success or retained authority. */
17
+ export const discussionProviders = async (
18
+ caller: CapabilityCaller,
19
+ options: { deadline: number; signal?: AbortSignal },
20
+ ): Promise<DiscussionProvider[]> => {
21
+ if (!Number.isFinite(options.deadline)) throw new RangeError("Discussion deadline must be a finite epoch time in milliseconds");
22
+ const controller = new AbortController();
23
+ const signal = AbortSignal.any([
24
+ controller.signal,
25
+ ...[caller.signal, options.signal].filter((signal): signal is AbortSignal => !!signal),
26
+ ]);
27
+ const timer = setTimeout(() => controller.abort(new Error("DISCUSSION_DEADLINE")), Math.max(0, options.deadline - Date.now()));
28
+ let bytes = 0;
29
+ const addBytes = (size: number) => {
30
+ bytes += size;
31
+ if (bytes > MAX_BYTES) {
32
+ controller.abort(new Error("DISCUSSION_BYTE_BUDGET"));
33
+ signal.throwIfAborted();
34
+ }
35
+ };
36
+ const credentials: CapabilityCaller = {
37
+ ...caller,
38
+ signal,
39
+ ...(!caller.authority
40
+ ? {
41
+ transport: async (request: Request) => {
42
+ const response = await (caller.transport ?? ((request: Request) => fetch(request, { redirect: "error" })))(request);
43
+ if (!response.body) return response;
44
+ const body = response.body.pipeThrough(
45
+ new TransformStream<Uint8Array, Uint8Array>({
46
+ transform(chunk, stream) {
47
+ addBytes(chunk.byteLength);
48
+ stream.enqueue(chunk);
49
+ },
50
+ }),
51
+ { signal },
52
+ );
53
+ return new Response(body, { status: response.status, headers: response.headers });
54
+ },
55
+ }
56
+ : {}),
57
+ };
58
+ const providers: DiscussionProvider[] = [];
59
+ const seen = new Set<string>();
60
+ const appIds = new Set<string>();
61
+ let cursor: string | undefined;
62
+ let onAbort: (() => void) | undefined;
63
+ const cancelled = new Promise<never>((_, reject) => {
64
+ onAbort = () => reject(signal.reason);
65
+ signal.addEventListener("abort", onAbort, { once: true });
66
+ });
67
+ try {
68
+ for (let calls = 0; calls < MAX_CALLS; calls++) {
69
+ if (options.deadline <= Date.now()) controller.abort(new Error("DISCUSSION_DEADLINE"));
70
+ signal.throwIfAborted();
71
+ const page = await Promise.race([listCapabilityCatalog({ caller: credentials, cursor, limit: 25 }), cancelled]);
72
+ signal.throwIfAborted();
73
+ if (!page.ok) throw new Error(`Discussion discovery unavailable: ${page.error.code}`);
74
+ if (caller.authority) addBytes(new TextEncoder().encode(JSON.stringify(page.data)).byteLength);
75
+ for (const app of page.data.apps) {
76
+ const role = app.manifest.discussions;
77
+ const model = app.manifest.resourceTypes?.find((model) => model.type === role?.item);
78
+ if (!role || !model?.container || discussionsIssues(app.manifest).length || appIds.has(app.appId)) continue;
79
+ appIds.add(app.appId);
80
+ providers.push({
81
+ app,
82
+ itemType: `${app.appId}.${role.item}`,
83
+ containerType: `${app.appId}.${model.container}`,
84
+ containers: role.containers,
85
+ ...(role.activity ? { activity: role.activity } : {}),
86
+ });
87
+ }
88
+ if (!page.data.page.hasMore) return providers;
89
+ cursor = page.data.page.nextCursor;
90
+ if (seen.has(cursor)) throw new Error("DISCUSSION_CURSOR_LOOP");
91
+ seen.add(cursor);
92
+ }
93
+ throw new Error("DISCUSSION_CALL_BUDGET");
94
+ } finally {
95
+ clearTimeout(timer);
96
+ if (onAbort) signal.removeEventListener("abort", onAbort);
97
+ controller.abort();
98
+ }
99
+ };
@@ -63,7 +63,7 @@ export type CapabilityExecutionInput = {
63
63
  capability: string;
64
64
  kind: "query" | "action";
65
65
  destructive: boolean;
66
- actorKind?: "user" | "service_account" | null;
66
+ actorKind?: "user" | "service_account" | "app" | null;
67
67
  actorId?: string | null;
68
68
  userId?: string | null;
69
69
  /** Recorded only when the access subject differs from the acting principal. */
@@ -90,7 +90,7 @@ export type CapabilityExecution = {
90
90
  capability: string;
91
91
  kind: "query" | "action";
92
92
  destructive: boolean;
93
- actorKind: "user" | "service_account" | null;
93
+ actorKind: "user" | "service_account" | "app" | null;
94
94
  actorId: string | null;
95
95
  userId: string | null;
96
96
  accessSubjectType: "user" | "service_account" | null;
@@ -35,7 +35,7 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
35
35
  duration_ms INTEGER NOT NULL DEFAULT 0,
36
36
  CONSTRAINT capabilities_executions_origin_check CHECK (origin IN ('assistant', 'mcp', 'http', 'app')),
37
37
  CONSTRAINT capabilities_executions_kind_check CHECK (kind IN ('query', 'action')),
38
- CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account')),
38
+ CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app')),
39
39
  CONSTRAINT capabilities_executions_subject_check CHECK (access_subject_type IS NULL OR access_subject_type IN ('user', 'service_account')),
40
40
  CONSTRAINT capabilities_executions_status_check CHECK (status IN ('succeeded', 'failed', 'denied', 'invalid_input', 'timed_out', 'rejected'))
41
41
  )
@@ -59,6 +59,15 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
59
59
  await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS parent_invocation_id UUID`.simple();
60
60
  await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS via_app_id TEXT`.simple();
61
61
 
62
+ await sql`DO $$ BEGIN
63
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'capabilities.executions'::regclass
64
+ AND conname = 'capabilities_executions_actor_kind_check' AND position('''app''' in pg_get_constraintdef(oid)) = 0) THEN
65
+ ALTER TABLE capabilities.executions DROP CONSTRAINT capabilities_executions_actor_kind_check;
66
+ ALTER TABLE capabilities.executions ADD CONSTRAINT capabilities_executions_actor_kind_check
67
+ CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app'));
68
+ END IF;
69
+ END $$`.simple();
70
+
62
71
  // Platform-owned idempotency claims. One row per
63
72
  // (app, capability, principal, key); the dispatcher claims before it
64
73
  // forwards and resolves the row once the outcome is known. Response bodies