@k2b/cloud 0.44.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 (137) hide show
  1. package/package.json +9 -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/access/GroupCoverage.tsx +20 -5
  9. package/src/access/PermissionEditor.tsx +11 -2
  10. package/src/access/PrincipalAvatar.tsx +30 -21
  11. package/src/access/messages.ts +4 -4
  12. package/src/account/EntitySearch.tsx +12 -0
  13. package/src/ai/capabilities.ts +4 -2
  14. package/src/ai/chat/map-messages.ts +270 -0
  15. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  16. package/src/ai/chat/map-notebook.tsx +301 -0
  17. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  18. package/src/ai/chat/map-widget.css +107 -0
  19. package/src/ai/chat/map-widget.tsx +1279 -0
  20. package/src/ai/chat/message-utils.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +2 -1
  23. package/src/ai/chat/turn-view.tsx +17 -1
  24. package/src/ai/default-tools.ts +3 -0
  25. package/src/ai/dictation-runtime.ts +6 -2
  26. package/src/ai/executor.ts +22 -4
  27. package/src/ai/index.ts +1 -0
  28. package/src/ai/map-contracts.ts +209 -0
  29. package/src/ai/map-execution.ts +14 -0
  30. package/src/ai/map-notebook-save.ts +276 -0
  31. package/src/ai/map-notebook.ts +32 -0
  32. package/src/ai/map-opening-hours.ts +40 -0
  33. package/src/ai/map-routes.ts +84 -0
  34. package/src/ai/map-service.ts +288 -0
  35. package/src/ai/map-store.ts +137 -0
  36. package/src/ai/map-tool.ts +73 -0
  37. package/src/ai/migrate.ts +3 -0
  38. package/src/ai/project-tool.ts +4 -4
  39. package/src/ai/projects-authorization.ts +53 -0
  40. package/src/ai/projects-routes.ts +56 -46
  41. package/src/ai/projects.ts +100 -79
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/recent-resources.ts +3 -3
  44. package/src/ai/routes.ts +32 -30
  45. package/src/ai/skill-tool.ts +4 -4
  46. package/src/ai/skills-routes.ts +27 -26
  47. package/src/ai/skills.ts +120 -81
  48. package/src/ai/store.ts +19 -6
  49. package/src/ai/tools.ts +14 -0
  50. package/src/ai/types.ts +3 -0
  51. package/src/api/accounts-entities.ts +6 -3
  52. package/src/api/admin-ai-skills.ts +4 -1
  53. package/src/api/auth.ts +9 -2
  54. package/src/api/capabilities.ts +90 -12
  55. package/src/api/mcp.ts +4 -2
  56. package/src/browser/notifications.ts +25 -8
  57. package/src/browser/session.ts +39 -0
  58. package/src/browser/shared-text-history-validation.ts +151 -0
  59. package/src/browser/shared-text-history.ts +464 -0
  60. package/src/browser/shared-text-offline-authorized.ts +29 -0
  61. package/src/browser/shared-text-offline.ts +351 -0
  62. package/src/browser/shared-text-provider.ts +714 -0
  63. package/src/browser/shared-text-table-contract.ts +150 -0
  64. package/src/browser/shared-text.ts +19 -0
  65. package/src/capabilities/discussion-providers.ts +99 -0
  66. package/src/capabilities/executions.ts +2 -2
  67. package/src/capabilities/migrate.ts +10 -1
  68. package/src/capabilities/resource-readers.ts +58 -0
  69. package/src/capabilities/server-invoke.ts +23 -13
  70. package/src/capabilities/server.ts +17 -6
  71. package/src/capabilities/testing.ts +13 -0
  72. package/src/contracts/capabilities.ts +137 -4
  73. package/src/contracts/discussions.ts +91 -0
  74. package/src/contracts/geo-document.ts +60 -0
  75. package/src/contracts/geo-snapshot.ts +86 -0
  76. package/src/contracts/geo.ts +14 -0
  77. package/src/contracts/index.ts +2 -0
  78. package/src/contracts/outgoing-mail.ts +1 -1
  79. package/src/contracts/resource-types.ts +21 -0
  80. package/src/contracts/shared.ts +2 -1
  81. package/src/geo/AddressAutocomplete.tsx +213 -0
  82. package/src/geo/messages.ts +29 -0
  83. package/src/geo/navigation.ts +25 -0
  84. package/src/geo/ui.ts +2 -0
  85. package/src/server/api/respond.ts +10 -2
  86. package/src/server/index.ts +1 -0
  87. package/src/server/services/access.ts +23 -10
  88. package/src/server/services/index.ts +1 -0
  89. package/src/services/accounts/app.ts +56 -17
  90. package/src/services/accounts/entities.ts +36 -16
  91. package/src/services/accounts/group-sql.ts +23 -0
  92. package/src/services/accounts/groups.ts +26 -17
  93. package/src/services/accounts/identities.ts +8 -1
  94. package/src/services/accounts/identity-reconciliation.ts +3 -1
  95. package/src/services/accounts/local-groups.ts +141 -27
  96. package/src/services/accounts/machine-group-management.ts +94 -0
  97. package/src/services/accounts/users.ts +38 -8
  98. package/src/services/geo/cache.ts +2 -1
  99. package/src/services/geo/geoapify.ts +3 -0
  100. package/src/services/identity/invocation-token.ts +110 -0
  101. package/src/services/mandates/index.ts +2 -2
  102. package/src/services/mandates/policy.ts +1 -0
  103. package/src/services/pdf/geo-snapshot.ts +107 -0
  104. package/src/services/pdf/index.ts +2 -0
  105. package/src/services/session/index.ts +12 -3
  106. package/src/shared/email-html.ts +1 -0
  107. package/src/shared/markdown/extensions/katex.ts +8 -1
  108. package/src/shared/markdown/extensions/tables.ts +14 -1
  109. package/src/shared/markdown/index.ts +22 -11
  110. package/src/shared-text/anchors.ts +29 -0
  111. package/src/shared-text/awareness.ts +87 -0
  112. package/src/shared-text/changes.ts +27 -0
  113. package/src/shared-text/documents.ts +428 -77
  114. package/src/shared-text/events.ts +12 -2
  115. package/src/shared-text/index.ts +16 -1
  116. package/src/shared-text/log.ts +24 -5
  117. package/src/shared-text/snapshots.ts +69 -23
  118. package/src/shared-text/structural-history.ts +377 -0
  119. package/src/shared-text/table-actions.ts +432 -0
  120. package/src/shared-text/types.ts +24 -5
  121. package/src/ssr/AdminArea.tsx +2 -2
  122. package/src/ssr/AdminLayout.tsx +1 -1
  123. package/src/ssr/AdminSidebar.tsx +1 -1
  124. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  125. package/src/ssr/CloudShell.island.tsx +19 -3
  126. package/src/ssr/Layout.tsx +1 -1
  127. package/src/ssr/MobileProfileActions.tsx +2 -2
  128. package/src/ssr/ProfilePreferences.tsx +2 -1
  129. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  130. package/src/ssr/admin-navigation.ts +1 -11
  131. package/src/ssr/app-launchpad.tsx +6 -1
  132. package/src/ssr/platform-messages.ts +2 -0
  133. package/src/ssr/profile-actions.ts +3 -6
  134. package/src/ssr/shell-data.tsx +1 -1
  135. package/src/styles/global.css +2 -0
  136. package/src/workflows/language/expressions.ts +3 -2
  137. package/src/workflows/runtime/executor.ts +2 -1
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. */
@@ -36,6 +36,7 @@ const QuerySchema = z
36
36
  profile: UserProfileSchema.optional(),
37
37
  user_ids: z.string().optional(),
38
38
  group_ids: z.string().optional(),
39
+ service_account_ids: z.string().optional(),
39
40
  service_account_kinds: z.string().optional(),
40
41
  service_account_status: ServiceAccountStatusSchema.optional(),
41
42
  exclude_user_ids: z.string().optional(),
@@ -47,7 +48,6 @@ const QuerySchema = z
47
48
  parent_group_id: z.uuid().optional(),
48
49
  managed_by_user_id: z.uuid().optional(),
49
50
  recursive: z.enum(["true", "false"]).optional(),
50
- include_personal: z.enum(["true", "false"]).optional(),
51
51
  })
52
52
  .refine(
53
53
  (value) => {
@@ -112,7 +112,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
112
112
  tags: ["Accounts"],
113
113
  summary: "List visible directory entries",
114
114
  description:
115
- "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account. Standalone and agent accounts with read scope see only explicitly shared directory entries and cannot query relationships. Personal Linux groups (a user's private primary group, flagged with `personalOwner`) are left out unless `include_personal=true`; exact `group_ids` lookups and relation filters always return them.",
115
+ "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account. Standalone and agent accounts with read scope see only explicitly shared directory entries and cannot query relationships. Personal Linux groups are excluded, including exact group IDs and relation filters. Inspect the owning user's Linux identity for diagnostic attributes.",
116
116
  ...requiresAuth,
117
117
  responses: {
118
118
  200: jsonResponse(EntitiesListResponseSchema, "Paginated mixed entity list"),
@@ -153,6 +153,9 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
153
153
  const groupIds = parseUuidList(query.group_ids, "group_ids");
154
154
  if (!groupIds.ok) return respond(c, fail(err.badInput(groupIds.message)));
155
155
 
156
+ const serviceAccountIds = parseUuidList(query.service_account_ids, "service_account_ids");
157
+ if (!serviceAccountIds.ok) return respond(c, fail(err.badInput(serviceAccountIds.message)));
158
+
156
159
  const filters = {
157
160
  search: query.search,
158
161
  kinds: kinds.value,
@@ -160,6 +163,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
160
163
  profile: query.profile,
161
164
  userIds: userIds.value,
162
165
  groupIds: groupIds.value,
166
+ serviceAccountIds: serviceAccountIds.value,
163
167
  serviceAccountKinds: serviceAccountKinds.value,
164
168
  serviceAccountStatus: query.service_account_status,
165
169
  excludeUserIds: excludeUserIds.value,
@@ -171,7 +175,6 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
171
175
  parentGroupId: query.parent_group_id,
172
176
  managedByUserId: query.managed_by_user_id,
173
177
  recursive: query.recursive === "true",
174
- includePersonal: query.include_personal === "true",
175
178
  };
176
179
  if (!user) {
177
180
  const result = await (dependencies.searchShares ?? directoryShares.search)({
@@ -94,7 +94,10 @@ export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthCo
94
94
  .get("/:skillId/projects", async (c) => {
95
95
  const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
96
96
  if (!skill) return notFound(c, "Skill");
97
- const projects = await aiSkills.admin.linkedProjects(skill.id, c.get("accessSubject") ?? null);
97
+ const projects = await aiSkills.admin.linkedProjects(skill.id, {
98
+ actor: c.get("actor"),
99
+ accessSubject: c.get("accessSubject"),
100
+ });
98
101
  return respond(c, ok({ projects: projects.map((project) => ({ id: project.shortId, name: project.name })) }));
99
102
  })
100
103
  .get("/:skillId/access", async (c) => {
package/src/api/auth.ts CHANGED
@@ -162,13 +162,20 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
162
162
  tags: ["Auth"],
163
163
  summary: "Logout",
164
164
  description:
165
- "Idempotent: clears the session cookie and revokes the current credential if present. No authentication required — logout must always succeed.",
165
+ "Idempotent: clears the session cookie and revokes the current credential if present. No authentication required. An optional X-Cloud-Logout-User header binds revocation to that user UUID (or none) without writing the shared browser cookie; a changed account returns 409 without revocation.",
166
+ parameters: [{ in: "header", name: "X-Cloud-Logout-User", required: false, schema: { type: "string" } }],
166
167
  responses: {
167
168
  200: jsonResponse(MessageResponseSchema, "Session invalidated"),
169
+ 400: jsonResponse(ErrorResponseSchema, "Invalid expected account"),
170
+ 409: jsonResponse(ErrorResponseSchema, "Browser account changed"),
168
171
  },
169
172
  }),
170
173
  async (c) => {
171
- await auth.session.delete(c);
174
+ const expected = c.req.header("x-cloud-logout-user");
175
+ if (expected !== undefined && expected !== "none" && !z.string().uuid().safeParse(expected).success)
176
+ return c.json({ message: "Invalid expected account" }, 400);
177
+ if (!(await auth.session.delete(c, expected === "none" ? null : expected)))
178
+ return c.json({ message: "Browser account changed; reload before signing out" }, 409);
172
179
  log.info("Logout");
173
180
  return c.json({ message: "Logged out" });
174
181
  },
@@ -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,39 @@
1
+ import { apiClient } from "../clients/core";
2
+
3
+ /** Fence and remove application-owned entries in the authenticated user's browser namespace. */
4
+ const clearUserStorage = (userId: string): void => {
5
+ const prefix = `cloud:user:${userId}:`;
6
+ localStorage.setItem(`cloud:user-epoch:${userId}`, crypto.randomUUID());
7
+ if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("cloud:user-storage-invalidated", { detail: { userId } }));
8
+ const keys = Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index));
9
+ for (const key of keys) if (key?.startsWith(prefix)) localStorage.removeItem(key);
10
+ };
11
+
12
+ /** Revoke this browser's credential even if local storage cleanup fails. The caller keeps a visible retry until both succeed. */
13
+ export const signOutBrowserSession = async (userId: string | undefined, errorMessage: string): Promise<void> => {
14
+ await import("./notifications").then(({ browserNotificationClient }) => browserNotificationClient.disable()).catch(() => undefined);
15
+ let cleanupFailed = false;
16
+ try {
17
+ if (userId) {
18
+ clearUserStorage(userId);
19
+ const { logoutSharedTextOffline } = await import("./shared-text-offline");
20
+ await logoutSharedTextOffline(userId);
21
+ }
22
+ } catch {
23
+ cleanupFailed = true;
24
+ }
25
+ const response = await apiClient.auth.logout.$post({}, { headers: { "x-cloud-logout-user": userId ?? "none" } });
26
+ // Close the interval between the first local fence and credential revocation:
27
+ // a tab that authenticated during it must not retain or recreate device data.
28
+ if (response.ok && userId) {
29
+ try {
30
+ clearUserStorage(userId);
31
+ const { logoutSharedTextOffline } = await import("./shared-text-offline");
32
+ await logoutSharedTextOffline(userId);
33
+ cleanupFailed = false;
34
+ } catch {
35
+ cleanupFailed = true;
36
+ }
37
+ }
38
+ if (!response.ok || cleanupFailed) throw new Error(errorMessage);
39
+ };
@@ -0,0 +1,151 @@
1
+ import * as T from "@k2b/ui/text-editor/model";
2
+ import * as Y from "yjs";
3
+
4
+ type NativeFrame = Y.UndoManager["undoStack"][number];
5
+ type Span = { from: number; to: number };
6
+ type Piece = Span & { original: number | null };
7
+
8
+ export class SharedTextNativeHistoryError extends Error {
9
+ readonly code = "shared_text_history_target";
10
+ constructor(readonly reason: "dependency" | "unavailable") {
11
+ super("The input history target is unavailable");
12
+ }
13
+ }
14
+
15
+ const intersect = (set: NativeFrame["insertions"], item: Y.Item): Span[] =>
16
+ (set.clients.get(item.id.client) ?? []).flatMap((run) => {
17
+ const from = Math.max(run.clock, item.id.clock) - item.id.clock;
18
+ const to = Math.min(run.clock + run.len, item.id.clock + item.length) - item.id.clock;
19
+ return from < to ? [{ from, to }] : [];
20
+ });
21
+ const subtract = (wanted: Span[], excluded: Span[]): Span[] =>
22
+ wanted.flatMap((span) => {
23
+ const result: Span[] = [];
24
+ let from = span.from;
25
+ for (const cut of excluded) {
26
+ if (cut.to <= from || cut.from >= span.to) continue;
27
+ if (cut.from > from) result.push({ from, to: cut.from });
28
+ from = Math.max(from, cut.to);
29
+ }
30
+ if (from < span.to) result.push({ from, to: span.to });
31
+ return result;
32
+ });
33
+
34
+ type Region = Span & { key: string | undefined };
35
+ const regions = (source: string, original: (position: number) => number | null): Region[] =>
36
+ T.findTables(source).flatMap((table) =>
37
+ table.lines.flatMap((line, row) =>
38
+ line.cells.map((_cell, column) => {
39
+ const left = line.leadingPipe ? line.pipes[column]! + 1 : column === 0 ? line.from : line.pipes[column - 1]! + 1;
40
+ const right = (line.leadingPipe ? line.pipes[column + 1] : line.pipes[column]) ?? line.to;
41
+ const separator = table.lines[1]!.cells[column];
42
+ const hyphen = separator ? original(separator.from + separator.text.indexOf("-")) : null;
43
+ const header = original(table.lines[0]!.to);
44
+ const rowIdentity = row < 2 ? row : original(line.from - 1);
45
+ // Extra physical cells have no separator hyphen. Their surviving left
46
+ // delimiter distinguishes them even when columns are inserted/removed.
47
+ // An unknown identity is never evidence that two targets are the same.
48
+ const columnIdentity = separator ? hyphen : original(left - 1);
49
+ return {
50
+ from: left,
51
+ to: right,
52
+ key:
53
+ header === null || rowIdentity === null || columnIdentity === null
54
+ ? undefined
55
+ : `${header}:${rowIdentity}:${separator ? "column" : "extra"}:${columnIdentity}`,
56
+ };
57
+ }),
58
+ ),
59
+ );
60
+ const locate = (regions: Region[], position: number, end: number) => {
61
+ let low = 0,
62
+ high = regions.length;
63
+ while (low < high) {
64
+ const middle = (low + high) >>> 1;
65
+ if (regions[middle]!.to <= position) low = middle + 1;
66
+ else high = middle;
67
+ }
68
+ const region = regions[low];
69
+ return region && region.from <= position ? { key: region.key, to: region.to } : { key: null, to: region?.from ?? end };
70
+ };
71
+
72
+ /** Project root plain-text native effects without a second manager or live mutation.
73
+ * Identity sets and linked Item/content fields are exported Yjs contracts. Native
74
+ * restoration occupies the original deleted item's gap; redone items already
75
+ * have their effect and are not copied again. Unknown content refuses safely.
76
+ */
77
+ export const validateNativeHistory = (
78
+ text: Y.Text,
79
+ frame: NativeFrame,
80
+ liveInsertions: NativeFrame["insertions"],
81
+ baseline: Uint8Array | null,
82
+ ) => {
83
+ const known = baseline ? Y.decodeStateVector(baseline) : new Map<number, number>();
84
+ const chunks: string[] = [];
85
+ const pieces: Piece[] = [];
86
+ const protectedSpans: { before: number; after: number; length: number }[] = [];
87
+ let before = 0,
88
+ after = 0;
89
+ const append = (value: string, original: number | null) => {
90
+ if (!value.length) return;
91
+ chunks.push(value);
92
+ pieces.push({ from: after, to: after + value.length, original });
93
+ after += value.length;
94
+ };
95
+ for (const item of Y.getTypeChildren(text)) {
96
+ // Adopted frames lack correspondence captured by this controller. Native
97
+ // Undo follows pre-existing redone chains for insertions; projecting only
98
+ // the original IDs would omit their live effects. Leave such frames intact.
99
+ if (baseline === null && item.redone !== null && intersect(frame.insertions, item).length)
100
+ throw new SharedTextNativeHistoryError("unavailable");
101
+ if (item.deleted) {
102
+ const restore = subtract(intersect(frame.deletions, item), intersect(frame.insertions, item));
103
+ if (!restore.length || item.redone !== null) continue;
104
+ const content = item.content;
105
+ if (!(content instanceof Y.ContentString)) throw new SharedTextNativeHistoryError("unavailable");
106
+ for (const span of restore) append(content.str.slice(span.from, span.to), null);
107
+ continue;
108
+ }
109
+ if (!item.countable) continue;
110
+ const content = item.content;
111
+ if (!(content instanceof Y.ContentString)) throw new SharedTextNativeHistoryError("unavailable");
112
+ const keep = subtract([{ from: 0, to: item.length }], intersect(liveInsertions, item));
113
+ for (const span of keep) {
114
+ const newer = Math.max(span.from, (known.get(item.id.client) ?? 0) - item.id.clock);
115
+ if (newer < span.to) protectedSpans.push({ before: before + newer, after: after + newer - span.from, length: span.to - newer });
116
+ append(content.str.slice(span.from, span.to), before + span.from);
117
+ }
118
+ before += item.length;
119
+ }
120
+ const source = text.toString();
121
+ const candidate = chunks.join("");
122
+ if (before !== source.length) throw new SharedTextNativeHistoryError("unavailable");
123
+ const original = (position: number) => {
124
+ let low = 0,
125
+ high = pieces.length;
126
+ while (low < high) {
127
+ const middle = (low + high) >>> 1;
128
+ if (pieces[middle]!.to <= position) low = middle + 1;
129
+ else high = middle;
130
+ }
131
+ const piece = pieces[low];
132
+ return piece?.original != null ? piece.original + position - piece.from : null;
133
+ };
134
+ const beforeRegions = regions(source, (position) => position);
135
+ const afterRegions = regions(candidate, original);
136
+ for (const span of protectedSpans) {
137
+ let offset = 0;
138
+ while (offset < span.length) {
139
+ const oldAt = span.before + offset,
140
+ newAt = span.after + offset;
141
+ const oldTarget = locate(beforeRegions, oldAt, source.length);
142
+ const newTarget = locate(afterRegions, newAt, candidate.length);
143
+ if (oldTarget.key === undefined || newTarget.key === undefined || oldTarget.key !== newTarget.key)
144
+ throw new SharedTextNativeHistoryError("dependency");
145
+ const length = Math.min(span.length - offset, oldTarget.to - oldAt, newTarget.to - newAt);
146
+ if (length <= 0) throw new SharedTextNativeHistoryError("unavailable");
147
+ offset += length;
148
+ }
149
+ }
150
+ return candidate;
151
+ };