@k2b/cloud 0.42.0 → 0.44.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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
package/src/api/mcp.ts CHANGED
@@ -14,7 +14,9 @@ import {
14
14
  } from "@modelcontextprotocol/sdk/types.js";
15
15
  import { Hono, type MiddlewareHandler } from "hono";
16
16
  import { z } from "zod";
17
+ import { type CatalogSubscription, projectAppEventCatalog, readEventCatalogSubscriptions } from "../_internal/app-event-catalog";
17
18
  import { readBoundedJson } from "../_internal/bounded-json";
19
+ import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
18
20
  import { capabilitySentenceText } from "../_internal/capability-sentences";
19
21
  import { withCapabilityRequestTimeout } from "../_internal/capability-timeout";
20
22
  import {
@@ -32,8 +34,12 @@ import {
32
34
  CAPABILITY_MAX_REQUEST_BYTES,
33
35
  CAPABILITY_MAX_RESULT_BYTES,
34
36
  CAPABILITY_PROTOCOL_VERSION,
37
+ CAPABILITY_QUALIFIED_ID_MAX_LENGTH,
35
38
  type CapabilityActionManifest,
36
39
  CapabilityActionReceiptSchema,
40
+ CapabilityAppIdSchema,
41
+ CapabilityEventCatalogEntrySchema,
42
+ CapabilityQualifiedIdSchema,
37
43
  type CapabilityQueryManifest,
38
44
  CapabilitySemanticLinkSchema,
39
45
  type CloudResourceRef,
@@ -46,6 +52,7 @@ import {
46
52
  import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
47
53
  import { type AuthContext, auth, type RequestAuthority, rateLimit, rejectReservedWorkloadCredential, resolveLocale } from "../server";
48
54
  import { preferredLocale } from "../server/locale";
55
+ import { oauthScopeByOperation } from "../server/middleware/auth";
49
56
  import { noTransform } from "../server/middleware/no-transform";
50
57
  import { discoverableApps } from "../services/app-discovery";
51
58
  import { createHelpReader, type HelpArticle, type HelpMetadata, type HelpReader, type HelpReaderFactory } from "../services/help";
@@ -63,6 +70,27 @@ const IDEMPOTENCY_KEY_FIELD = "idempotencyKey";
63
70
  const RESOURCE_READ_TOOL = "cloud__resource__read";
64
71
  const HELP_SEARCH_TOOL = "cloud__help__search";
65
72
  const HELP_READ_TOOL = "cloud__help__read";
73
+ const EVENT_LIST_TOOL = "cloud__events__list";
74
+ const EVENT_READ_TOOL = "cloud__events__read";
75
+ const eventListArguments = z.strictObject({
76
+ appId: CapabilityAppIdSchema.optional(),
77
+ automation: z.boolean().optional(),
78
+ cursor: z
79
+ .string()
80
+ .max(CAPABILITY_QUALIFIED_ID_MAX_LENGTH + 1 + String(Number.MAX_SAFE_INTEGER).length)
81
+ .regex(/^[a-z][a-z0-9-]*\.[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*@[1-9]\d*$/)
82
+ .optional(),
83
+ limit: z.number().int().min(1).max(MCP_TOOL_PAGE_SIZE).default(MCP_TOOL_PAGE_SIZE),
84
+ });
85
+ const eventReadArguments = z.strictObject({ type: CapabilityQualifiedIdSchema, version: z.number().int().positive() });
86
+ const eventSummarySchema = CapabilityEventCatalogEntrySchema.omit({ dataSchema: true, subscribers: true }).extend({
87
+ appId: CapabilityAppIdSchema,
88
+ });
89
+ const eventDetailSchema = CapabilityEventCatalogEntrySchema.extend({ appId: CapabilityAppIdSchema });
90
+ const eventPageSchema = z.object({
91
+ events: z.array(eventSummarySchema),
92
+ nextCursor: z.string().optional(),
93
+ });
66
94
  export const CLOUD_MCP_PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource/api/mcp/v1";
67
95
  const MCP_TOOL_NAME_MAX_LENGTH = 128;
68
96
  const MCP_INSTRUCTIONS =
@@ -72,6 +100,7 @@ const log = logger("mcp");
72
100
  type McpRouteDependencies = CapabilityDispatchDependencies & {
73
101
  listApps?: () => Promise<AppRegistryEntry[]>;
74
102
  help?: HelpReaderFactory;
103
+ eventSubscriptions?: (type: string, version: number) => Promise<CatalogSubscription[]>;
75
104
  getOperatorLocale?: () => Promise<string | undefined>;
76
105
  getOperatorTimeZone?: () => Promise<string | undefined>;
77
106
  getAppUrl?: () => Promise<string>;
@@ -205,6 +234,38 @@ const platformTools: Tool[] = [
205
234
  annotations: { title: "Read Cloud Help", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
206
235
  _meta: { "cloud/kind": "help-read" },
207
236
  },
237
+ {
238
+ name: EVENT_LIST_TOOL,
239
+ title: "List declared Cloud events",
240
+ description:
241
+ "Discover declared event types of apps you may discover. Returns publisher appId, type, version, title, description, subject and place types (place may be null), and automation metadata. Filter by appId or automation; pass nextCursor to continue. This reads declarations only.",
242
+ inputSchema: cloneObjectSchema(z.toJSONSchema(eventListArguments, { io: "input" })),
243
+ outputSchema: cloneObjectSchema(z.toJSONSchema(eventPageSchema)),
244
+ annotations: {
245
+ title: "List declared Cloud events",
246
+ readOnlyHint: true,
247
+ destructiveHint: false,
248
+ idempotentHint: true,
249
+ openWorldHint: false,
250
+ },
251
+ _meta: { "cloud/kind": "events-list" },
252
+ },
253
+ {
254
+ name: EVENT_READ_TOOL,
255
+ title: "Read declared Cloud event",
256
+ description:
257
+ "Read one exact event type and version returned by cloud__events__list, including its data JSON Schema and discoverable subscribers with persisted subscription states. This reads declarations only; it does not observe events or create subscriptions.",
258
+ inputSchema: cloneObjectSchema(z.toJSONSchema(eventReadArguments)),
259
+ outputSchema: cloneObjectSchema(z.toJSONSchema(z.object({ event: eventDetailSchema }))),
260
+ annotations: {
261
+ title: "Read declared Cloud event",
262
+ readOnlyHint: true,
263
+ destructiveHint: false,
264
+ idempotentHint: true,
265
+ openWorldHint: false,
266
+ },
267
+ _meta: { "cloud/kind": "events-read" },
268
+ },
208
269
  ];
209
270
 
210
271
  const projectTool = (
@@ -342,10 +403,12 @@ const parseResponse = async (response: Response): Promise<Record<string, unknown
342
403
  : { code: CAPABILITY_FRAMEWORK_ERROR_CODES.invalidAppResponse, message: "Capability returned a non-object result" };
343
404
  };
344
405
 
345
- const responseTooLargeToolResult = (): CallToolResult => {
406
+ const responseTooLargeToolResult = (
407
+ message = "Capability result is too large for an MCP tool response; narrow the query and retry",
408
+ ): CallToolResult => {
346
409
  const error = {
347
410
  code: CAPABILITY_FRAMEWORK_ERROR_CODES.responseTooLarge,
348
- message: "Capability result is too large for an MCP tool response; narrow the query and retry",
411
+ message,
349
412
  };
350
413
  return { isError: true, structuredContent: error, content: [{ type: "text", text: JSON.stringify(error) }] };
351
414
  };
@@ -489,6 +552,97 @@ const callHelpTool = async (name: string, argsValue: unknown, reader: HelpReader
489
552
  return null;
490
553
  };
491
554
 
555
+ const eventCursor = (event: { type: string; version: number }): string => `${event.type}@${event.version}`;
556
+
557
+ type EventCatalogReader = {
558
+ registry: () => Promise<AppRegistryEntry[]>;
559
+ lookup: (appId: string) => Promise<CapabilityRegistryEntry | null>;
560
+ locale: () => Promise<string>;
561
+ subscriptions: (type: string, version: number) => Promise<CatalogSubscription[]>;
562
+ };
563
+
564
+ const listEvents = async (argsValue: unknown, reader: EventCatalogReader): Promise<CallToolResult> => {
565
+ const parsed = eventListArguments.safeParse(argsValue ?? {});
566
+ if (!parsed.success) {
567
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
568
+ return validationToolError(`cloud__events__list accepts appId, automation (boolean), cursor and limit (1-100) (${issues})`);
569
+ }
570
+ const { appId, automation, cursor, limit } = parsed.data;
571
+ const cursorAppId = cursor?.split(".")[0];
572
+ if (appId && cursorAppId && appId !== cursorAppId) {
573
+ return validationToolError("cloud__events__list cursor must belong to the requested appId");
574
+ }
575
+ const summaries = (await reader.registry())
576
+ .filter((entry) => entry.capabilities?.protocolVersion === CAPABILITY_PROTOCOL_VERSION)
577
+ .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
578
+ const locale = await reader.locale();
579
+ const events: z.infer<typeof eventSummarySchema>[] = [];
580
+ const encoder = new TextEncoder();
581
+ const emptyBytes = encoder.encode(JSON.stringify(toolResult({ events: [] }, false))).byteLength;
582
+ let bytes = emptyBytes;
583
+ for (const summary of summaries) {
584
+ if (appId && summary.id !== appId) continue;
585
+ if (cursorAppId && summary.id < cursorAppId) continue;
586
+ const app = await reader.lookup(summary.id);
587
+ if (!app || app.manifest.manifestHash !== summary.capabilities?.manifestHash) continue;
588
+ const manifest = resolveCapabilityManifestPresentation(app.manifest, app.presentation, locale);
589
+ const catalog = projectAppEventCatalog(manifest, []).sort((left, right) =>
590
+ eventCursor(left) < eventCursor(right) ? -1 : eventCursor(left) > eventCursor(right) ? 1 : 0,
591
+ );
592
+ for (const entry of catalog) {
593
+ if (automation !== undefined && entry.automation !== automation) continue;
594
+ if (cursor && summary.id === cursorAppId && eventCursor(entry) <= cursor) continue;
595
+ const last = events.at(-1);
596
+ if (last && events.length === limit) return boundedToolResult({ events, nextCursor: eventCursor(last) }, false);
597
+ const { dataSchema: _schema, subscribers: _subscribers, ...summaryEntry } = entry;
598
+ const next = { appId: app.appId, ...summaryEntry };
599
+ const serialized = JSON.stringify(next);
600
+ // Count both structuredContent and the JSON-escaped text copy, plus their array separators.
601
+ const entryBytes = encoder.encode(serialized).byteLength + encoder.encode(JSON.stringify(serialized)).byteLength - 2;
602
+ const nextBytes = bytes + entryBytes + (events.length ? 2 : 0);
603
+ const cursorBytes =
604
+ encoder.encode(JSON.stringify(toolResult({ events: [], nextCursor: eventCursor(next) }, false))).byteLength - emptyBytes;
605
+ if (nextBytes + cursorBytes > CAPABILITY_MAX_RESULT_BYTES) {
606
+ return last ? boundedToolResult({ events, nextCursor: eventCursor(last) }, false) : responseTooLargeToolResult();
607
+ }
608
+ events.push(next);
609
+ bytes = nextBytes;
610
+ }
611
+ }
612
+ return boundedToolResult({ events }, false);
613
+ };
614
+
615
+ const readEvent = async (argsValue: unknown, reader: EventCatalogReader): Promise<CallToolResult> => {
616
+ const parsed = eventReadArguments.safeParse(argsValue);
617
+ if (!parsed.success) {
618
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
619
+ return validationToolError(`cloud__events__read requires an exact event type and positive integer version (${issues})`);
620
+ }
621
+ const { type, version } = parsed.data;
622
+ const summaries = (await reader.registry()).filter((entry) => entry.capabilities?.protocolVersion === CAPABILITY_PROTOCOL_VERSION);
623
+ const summary = summaries.find((entry) => entry.id === type.split(".")[0]);
624
+ if (summary) {
625
+ const app = await reader.lookup(summary.id);
626
+ if (app && app.manifest.manifestHash === summary.capabilities?.manifestHash) {
627
+ const manifest = resolveCapabilityManifestPresentation(app.manifest, app.presentation, await reader.locale());
628
+ const declaration = manifest.events?.find((event) => `${app.appId}.${event.localId}` === type && event.version === version);
629
+ if (declaration) {
630
+ const visibleApps = new Set(summaries.map((entry) => entry.id));
631
+ const subscribers = (await reader.subscriptions(type, version)).filter((row) => visibleApps.has(row.appId));
632
+ const [entry] = projectAppEventCatalog({ ...manifest, events: [declaration] }, subscribers);
633
+ const result = toolResult({ event: { appId: app.appId, ...entry } }, false);
634
+ return fitsToolResult(result)
635
+ ? result
636
+ : responseTooLargeToolResult("Event schema and subscriber list exceed the MCP response size limit");
637
+ }
638
+ }
639
+ }
640
+ return boundedToolResult(
641
+ { code: "EVENT_NOT_FOUND", message: "Event type and version are not in the current discoverable catalog" },
642
+ true,
643
+ );
644
+ };
645
+
492
646
  const createMcpServer = (
493
647
  request: Request,
494
648
  dependencies: McpRouteDependencies,
@@ -581,6 +735,26 @@ const createMcpServer = (
581
735
  });
582
736
 
583
737
  server.setRequestHandler(CallToolRequestSchema, async (message) => {
738
+ if (message.params.name === EVENT_LIST_TOOL || message.params.name === EVENT_READ_TOOL) {
739
+ if (!hasScope("read")) return boundedToolResult({ code: "FORBIDDEN", message: "OAuth scope read is required" }, true);
740
+ const reader: EventCatalogReader = {
741
+ registry,
742
+ lookup: capabilityLookup,
743
+ locale: resolveHelpLocale,
744
+ subscriptions: dependencies.eventSubscriptions ?? readEventCatalogSubscriptions,
745
+ };
746
+ try {
747
+ return message.params.name === EVENT_LIST_TOOL
748
+ ? await listEvents(message.params.arguments, reader)
749
+ : await readEvent(message.params.arguments, reader);
750
+ } catch (error) {
751
+ log.error("Failed to read MCP event catalog", { error: error instanceof Error ? error.message : String(error) });
752
+ return boundedToolResult(
753
+ { code: CAPABILITY_FRAMEWORK_ERROR_CODES.appUnavailable, message: "Event catalog is currently unavailable" },
754
+ true,
755
+ );
756
+ }
757
+ }
584
758
  if (message.params.name === HELP_SEARCH_TOOL || message.params.name === HELP_READ_TOOL) {
585
759
  if (!hasScope("read")) {
586
760
  return boundedToolResult({ code: "FORBIDDEN", message: "OAuth scope read is required" }, true);
@@ -783,8 +957,8 @@ export const createMcpRoutes = (dependencies: McpRouteDependencies = {}) =>
783
957
  .use("/mcp/v1", validateMcpOrigin(dependencies.getAppUrl ?? defaultAppUrl))
784
958
  .use("/mcp/v1", dependencies.authenticate ?? mcpAuthentication(dependencies.getAppUrl ?? defaultAppUrl))
785
959
  .use("/mcp/v1", rejectReservedWorkloadCredential)
786
- .use("/mcp/v1", dependencies.limit ?? rateLimit())
787
- .all("/mcp/v1", async (c) =>
960
+ .use("/mcp/v1", auth.readOnlyMiddleware(dependencies.limit ?? rateLimit()))
961
+ .post("/mcp/v1", oauthScopeByOperation, async (c) =>
788
962
  withCapabilityRequestTimeout(c, "query", async () => {
789
963
  if (c.req.method !== "POST") {
790
964
  return new Response(null, { status: 405, headers: { Allow: "POST" } });
@@ -825,6 +999,7 @@ export const createMcpRoutes = (dependencies: McpRouteDependencies = {}) =>
825
999
  }
826
1000
  }
827
1001
  }),
828
- );
1002
+ )
1003
+ .all("/mcp/v1", () => new Response(null, { status: 405, headers: { Allow: "POST" } }));
829
1004
 
830
1005
  export type McpApiType = ReturnType<typeof createMcpRoutes>;
package/src/api/me.ts CHANGED
@@ -13,6 +13,7 @@ import { describeRoute } from "hono-openapi";
13
13
  import { z } from "zod";
14
14
  import {
15
15
  AccountActivityListResponseSchema,
16
+ AvatarStateSchema,
16
17
  BrowserNotificationConfigurationSchema,
17
18
  BrowserNotificationEndpointSchema,
18
19
  BrowserPushSubscriptionSchema,
@@ -27,10 +28,10 @@ import {
27
28
  NotificationDeliveryStatusSchema,
28
29
  NotificationQuietSettingsSchema,
29
30
  RegisterBrowserNotificationEndpointSchema,
31
+ SaveAvatarResponseSchema,
32
+ SaveAvatarSchema,
30
33
  ServiceAccountCredentialSchema,
31
34
  SignOutOthersResponseSchema,
32
- UpdateAvatarResponseSchema,
33
- UpdateAvatarSchema,
34
35
  UpdateNotificationQuietSettingsSchema,
35
36
  UpdateProfileSchema,
36
37
  UpdateUserNotificationPreferenceSchema,
@@ -323,7 +324,8 @@ const app = new Hono<AuthContext>()
323
324
  describeRoute({
324
325
  tags: ["Me"],
325
326
  summary: "List current user notification preferences",
326
- description: "List active user-facing notification kinds, app recommendations, required channels, and explicit preferences.",
327
+ description:
328
+ "List active user-facing notification kinds, app recommendations, required and excluded channels, and explicit preferences.",
327
329
  ...requiresAuth,
328
330
  responses: {
329
331
  200: jsonResponse(UserNotificationPreferencesResponseSchema, "Notification preferences"),
@@ -342,11 +344,12 @@ const app = new Hono<AuthContext>()
342
344
  describeRoute({
343
345
  tags: ["Me"],
344
346
  summary: "Set current user notification preference",
345
- description: "Replace optional channels for one notification kind. Required channels remain enforced by the definition.",
347
+ description:
348
+ "Replace optional channels for one notification kind. Required channels remain enforced; excluded channels are rejected.",
346
349
  ...requiresAuth,
347
350
  responses: {
348
351
  200: jsonResponse(UserNotificationPreferenceSchema, "Updated notification preference"),
349
- 400: jsonResponse(ErrorResponseSchema, "Invalid or unavailable channel"),
352
+ 400: jsonResponse(ErrorResponseSchema, "Invalid, unavailable, or excluded channel"),
350
353
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
351
354
  404: jsonResponse(ErrorResponseSchema, "Notification preference not found"),
352
355
  },
@@ -492,32 +495,50 @@ const app = new Hono<AuthContext>()
492
495
  },
493
496
  )
494
497
 
498
+ .get(
499
+ "/avatar",
500
+ describeRoute({
501
+ tags: ["Me"],
502
+ summary: "Read current user profile picture",
503
+ description:
504
+ "Which picture others see (photo, built avatar, or initials), the stored built avatar, and whether a photo is stored. The image itself is served under the account's avatar URL.",
505
+ ...requiresAuth,
506
+ responses: {
507
+ 200: jsonResponse(AvatarStateSchema, "Profile picture"),
508
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
509
+ 404: jsonResponse(ErrorResponseSchema, "User not found"),
510
+ },
511
+ }),
512
+ async (c) =>
513
+ respond(c, async () => {
514
+ const state = await accountsService.user.getAvatarState({ id: c.get("user").id });
515
+ return state ? ok(state) : fail(err.notFound("User"));
516
+ }),
517
+ )
518
+
495
519
  .put(
496
520
  "/avatar",
497
521
  describeRoute({
498
522
  tags: ["Me"],
499
- summary: "Update current user avatar",
500
- description: "Store a small profile picture for the authenticated account. Avatars are visible to user-backed authenticated actors.",
523
+ summary: "Update current user profile picture",
524
+ description:
525
+ "Store and show a photo (`dataUrl`) or a built avatar (`built`), or show a stored picture (`use`: photo, built, or initials). A stored photo stays until it is deleted; only the shown picture is served. Avatars are visible to user-backed authenticated actors.",
501
526
  ...requiresAuth,
502
527
  responses: {
503
- 200: jsonResponse(UpdateAvatarResponseSchema, "Avatar updated"),
528
+ 200: jsonResponse(SaveAvatarResponseSchema, "Avatar updated"),
504
529
  400: jsonResponse(ErrorResponseSchema, "Failed to update avatar"),
505
530
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
531
+ 409: jsonResponse(ErrorResponseSchema, "The picture to show is not stored"),
506
532
  422: jsonResponse(ErrorResponseSchema, "Malformed image container or EXIF"),
507
533
  },
508
534
  }),
509
- v("json", UpdateAvatarSchema),
535
+ v("json", SaveAvatarSchema),
510
536
  async (c) =>
511
537
  respond(c, async () => {
512
538
  const user = c.get("user");
513
- const data = c.req.valid("json");
514
- const result = await accountsService.user.setAvatar({
515
- actor: toAccountsActor(user),
516
- id: user.id,
517
- dataUrl: data.dataUrl,
518
- });
539
+ const result = await accountsService.user.updateAvatar({ actor: toAccountsActor(user), id: user.id, change: c.req.valid("json") });
519
540
  if (!result.ok) return result;
520
- return ok({ message: "Avatar updated.", avatarHash: result.data.avatarHash });
541
+ return ok({ message: "Avatar updated.", ...result.data });
521
542
  }),
522
543
  )
523
544
 
@@ -799,7 +820,7 @@ const app = new Hono<AuthContext>()
799
820
  tags: ["Me"],
800
821
  summary: "Sign out every other sign-in",
801
822
  description:
802
- "End other sign-ins and rotate this browser's cookie while preserving its sign-in time and expiry; signInAgain is true if this browser could not be kept. Cloud Login phones stay paired. API keys, passkeys and mandates stay valid.",
823
+ "End other sign-ins and rotate this browser's cookie while preserving its sign-in time and expiry; signInAgain is true if this browser could not be kept. Cloud Login devices stay paired. API keys, passkeys and mandates stay valid.",
803
824
  ...requiresAuth,
804
825
  responses: {
805
826
  200: jsonResponse(SignOutOthersResponseSchema, "Other sign-ins ended"),
@@ -1,11 +1,26 @@
1
1
  import { type Context, Hono } from "hono";
2
2
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
+ import { describeRoute } from "hono-openapi";
4
+ import { registeredPwaBuilds } from "../_internal/runtime-watcher";
3
5
  import { env } from "../config/env";
4
- import { PWA_AUTH_PATH, PWA_COOKIES, PWA_LIMITS, PWA_SCOPE, PwaClaimSchema, type PwaLaunchState, PwaRenameSchema } from "../contracts/pwa";
5
- import { type AuthContext, auth, getLocale, rateLimit, v } from "../server";
6
+ import {
7
+ PWA_AUTH_PATH,
8
+ PWA_COOKIES,
9
+ PWA_LIMITS,
10
+ PWA_SCOPE,
11
+ PwaClaimSchema,
12
+ PwaCompleteResultSchema,
13
+ PwaDeviceDecisionSchema,
14
+ type PwaLaunchState,
15
+ PwaRenameSchema,
16
+ } from "../contracts/pwa";
17
+ import { launchTarget } from "../contracts/pwa-location";
18
+ import { type AuthContext, auth, getLocale, jsonResponse, rateLimit, v } from "../server";
6
19
  import { type PwaCredentials, type PwaDeviceNotifier, PwaError, pwaDevices } from "../services/pwa-devices";
7
20
  import { defaultShellAvailable, handlePwaError, type PwaRouteOptions, pwaErrorResponse, pwaInvalidRequest, pwaTransport } from "./pwa";
8
21
 
22
+ export { launchTarget } from "../contracts/pwa-location";
23
+
9
24
  const PAIRING_COOKIE_PATH = `${PWA_AUTH_PATH}/pairings`;
10
25
  const cookie = { httpOnly: true, secure: !env.IS_DEVELOPMENT } as const;
11
26
 
@@ -19,7 +34,9 @@ const setAppCredentials = (c: Context, credentials: PwaCredentials) => {
19
34
  maxAge: PWA_LIMITS.idleDays * 86_400,
20
35
  });
21
36
  };
37
+ const clearLastAddress = (c: Context) => deleteCookie(c, PWA_COOKIES.lastAddress, { path: PWA_SCOPE, secure: true, sameSite: "Lax" });
22
38
  const clearAppCredentials = (c: Context) => {
39
+ clearLastAddress(c);
23
40
  deleteCookie(c, PWA_COOKIES.session, { ...cookie, path: "/" });
24
41
  deleteCookie(c, PWA_COOKIES.device, { ...cookie, path: PWA_AUTH_PATH });
25
42
  };
@@ -35,27 +52,6 @@ const webSession = async (c: Context) => {
35
52
  return session?.data.kind === "web" ? session : null;
36
53
  };
37
54
 
38
- /**
39
- * A path inside the app to return to after the launch bounce; anything else becomes `/pwa/`.
40
- * The `pwa_launch` marker lets the page stop instead of bouncing again.
41
- */
42
- export const launchTarget = (to: string | undefined): string => {
43
- const fallback = `${PWA_SCOPE}?pwa_launch=1`;
44
- if (!to || to.length > 2048 || !to.startsWith(PWA_SCOPE) || to.includes("//") || to.includes("\\")) return fallback;
45
- const url = new URL(to, "http://launch.invalid");
46
- // Judge the path the browser will request: dot segments and escapes are resolved by then.
47
- let path: string;
48
- try {
49
- path = decodeURIComponent(url.pathname);
50
- } catch {
51
- return fallback;
52
- }
53
- const inside = (value: string) => value.startsWith(PWA_SCOPE) && value !== PWA_AUTH_PATH && !value.startsWith(`${PWA_AUTH_PATH}/`);
54
- if (url.origin !== "http://launch.invalid" || !inside(url.pathname) || !inside(path) || path.includes("//")) return fallback;
55
- url.searchParams.set("pwa_launch", "1");
56
- return url.pathname + url.search;
57
- };
58
-
59
55
  const stateUrl = (state: PwaLaunchState) => `${PWA_SCOPE}?pwa=${state}`;
60
56
 
61
57
  export type PwaPhoneRouteOptions = PwaRouteOptions & {
@@ -98,29 +94,68 @@ export const createPwaPhoneRoutes = (options: PwaPhoneRouteOptions) => {
98
94
  });
99
95
  return c.json({ code: claimed.code, account: claimed.account, expiresAt: claimed.expiresAt });
100
96
  })
101
- .post("/pairings/complete", async (c) => {
102
- requireShell(c);
103
- const web = await webSession(c);
104
- try {
105
- const result = await service.completePairing(c, {
106
- completionSecret: getCookie(c, PWA_COOKIES.pairing),
107
- deviceKey: getCookie(c, PWA_COOKIES.device),
108
- appSession: await appSession(c),
109
- webUserId: web?.user.id ?? null,
110
- locale: getLocale(c),
111
- notify: options.notify,
112
- });
113
- if (result.state === "waiting") return c.json(result, 202);
114
- if (result.credentials) setAppCredentials(c, result.credentials);
115
- clearPairing(c);
116
- // A Home Screen app on iOS has its own cookie jar; a web session there is only a copy from Safari.
117
- if (result.platform === "ios" && auth.session.getWebToken(c)) deleteCookie(c, "session_token", { path: "/" });
118
- return c.json({ state: "paired" as const });
119
- } catch (error) {
120
- if (error instanceof PwaError && error.code === "EXPIRED") clearPairing(c);
121
- throw error;
122
- }
123
- })
97
+ .post(
98
+ "/pairings/complete",
99
+ describeRoute({
100
+ tags: ["Cloud app"],
101
+ summary: "Poll or complete a claimed pairing",
102
+ description:
103
+ "Requires the HttpOnly completion cookie from claim. Administrative code confirmation returns confirmation_required with the target, administrator and a pairing-bound decision token; polling alone never completes it. Only a subsequent explicit device decision permits credentials. Self-pairing is unchanged.",
104
+ responses: {
105
+ 200: jsonResponse(PwaCompleteResultSchema, "Paired"),
106
+ 202: jsonResponse(PwaCompleteResultSchema, "Waiting or device decision required"),
107
+ },
108
+ }),
109
+ async (c) => {
110
+ requireShell(c);
111
+ const web = await webSession(c);
112
+ try {
113
+ const result = await service.completePairing(c, {
114
+ completionSecret: getCookie(c, PWA_COOKIES.pairing),
115
+ deviceKey: getCookie(c, PWA_COOKIES.device),
116
+ appSession: await appSession(c),
117
+ webUserId: web?.user.id ?? null,
118
+ locale: getLocale(c),
119
+ notify: options.notify,
120
+ });
121
+ if (result.state === "waiting" || result.state === "confirmation_required") return c.json(result, 202);
122
+ if (result.state === "rejected") {
123
+ clearPairing(c);
124
+ return c.json(result);
125
+ }
126
+ if (result.credentials) setAppCredentials(c, result.credentials);
127
+ clearLastAddress(c);
128
+ clearPairing(c);
129
+ // A Home Screen app on iOS has its own cookie jar; a web session there is only a copy from Safari.
130
+ if (result.platform === "ios" && auth.session.getWebToken(c)) deleteCookie(c, "session_token", { path: "/" });
131
+ return c.json({ state: "paired" as const });
132
+ } catch (error) {
133
+ if (error instanceof PwaError && error.code === "EXPIRED") clearPairing(c);
134
+ throw error;
135
+ }
136
+ },
137
+ )
138
+ .post(
139
+ "/pairings/decision",
140
+ describeRoute({
141
+ tags: ["Cloud app"],
142
+ summary: "Accept or reject administrative Cloud app pairing on the device",
143
+ description:
144
+ "Requires both the claim's HttpOnly completion cookie and the confirmationToken returned for this pairing after the administrator matched the code. decision is confirm or reject. Repeating the same decision is idempotent. Confirm permits a subsequent complete request; reject issues no credentials.",
145
+ responses: {
146
+ 204: { description: "Device decision recorded" },
147
+ 403: { description: "Invalid decision token" },
148
+ 409: { description: "Pairing is not awaiting this decision" },
149
+ 410: { description: "Pairing expired" },
150
+ },
151
+ }),
152
+ v("json", PwaDeviceDecisionSchema, pwaInvalidRequest),
153
+ async (c) => {
154
+ requireShell(c);
155
+ await service.decidePairing(getCookie(c, PWA_COOKIES.pairing), c.req.valid("json"));
156
+ return c.body(null, 204);
157
+ },
158
+ )
124
159
  .get("/session/launch", async (c) => {
125
160
  const to = launchTarget(c.req.query("to"));
126
161
  if (!shellAvailable(c)) return c.redirect(stateUrl("unavailable"), 302);
@@ -129,7 +164,11 @@ export const createPwaPhoneRoutes = (options: PwaPhoneRouteOptions) => {
129
164
  if (result.outcome === "renewed") setAppCredentials(c, result.credentials);
130
165
  if (result.outcome === "renewed" || result.outcome === "current") return c.redirect(to, 302);
131
166
  if (result.outcome === "blocked") return c.redirect(stateUrl("blocked"), 302);
132
- if (result.outcome === "ended") clearAppCredentials(c);
167
+ if (result.outcome === "ended") {
168
+ clearAppCredentials(c);
169
+ // Only this answer may delete the drafts: "?pwa=ended" alone is an address anyone can open.
170
+ setCookie(c, PWA_COOKIES.ended, "1", { ...cookie, sameSite: "Lax", path: PWA_SCOPE, maxAge: 300 });
171
+ }
133
172
  return c.redirect(stateUrl(result.outcome === "missing" ? "new" : "ended"), 302);
134
173
  } catch (error) {
135
174
  // Logs an unexpected failure by name only; a navigation always gets a page, never JSON.
@@ -152,7 +191,7 @@ export const createPwaPhoneRoutes = (options: PwaPhoneRouteOptions) => {
152
191
  if (result.outcome === "renewed") setAppCredentials(c, result.credentials);
153
192
  // Android shares Chrome's cookies with the app: fetches may run as that other web account.
154
193
  const otherAccount = web && web.user.id !== result.userId ? { name: web.user.displayName || web.user.uid } : undefined;
155
- return c.json({ renewed: result.outcome === "renewed", ...(otherAccount ? { otherAccount } : {}) });
194
+ return c.json({ builds: registeredPwaBuilds(), renewed: result.outcome === "renewed", ...(otherAccount ? { otherAccount } : {}) });
156
195
  })
157
196
  .patch("/session", v("json", PwaRenameSchema, pwaInvalidRequest), async (c) => {
158
197
  await service.rename(getCookie(c, PWA_COOKIES.device), c.req.valid("json").name);
package/src/api/pwa.ts CHANGED
@@ -28,7 +28,7 @@ const log = logger("pwa");
28
28
 
29
29
  const MESSAGES: Record<PwaError["code"], string> = {
30
30
  UNAVAILABLE: "The mobile app is not available right now.",
31
- REAUTHENTICATE: "Sign in again to pair a phone.",
31
+ REAUTHENTICATE: "Sign in again to pair a device.",
32
32
  FORBIDDEN: "Use Cloud on the web for this.",
33
33
  ACCOUNT_BLOCKED: "This account cannot use the mobile app right now.",
34
34
  INVALID_REQUEST: "The request is not valid.",
@@ -40,7 +40,7 @@ const MESSAGES: Record<PwaError["code"], string> = {
40
40
  ALREADY_PAIRED: "This app is paired with another account.",
41
41
  ACCOUNT_MISMATCH: "This browser is signed in to another account.",
42
42
  LIMIT_REACHED: "The limit for this account was reached.",
43
- UNPAIRED: "This phone is not paired.",
43
+ UNPAIRED: "This device is not paired.",
44
44
  };
45
45
 
46
46
  /** `{ code, message }` with a safe English message; the UI maps codes to its own catalog. */
@@ -95,7 +95,7 @@ const webActor = async (c: Context) => {
95
95
  const session = token ? await auth.session.authenticateRequest(c, token) : null;
96
96
  if (!session) throw new PwaError("REAUTHENTICATE", 403);
97
97
  if (session.data.kind !== "web") throw new PwaError("FORBIDDEN", 403);
98
- return { userId: session.user.id, sid: session.data.sid };
98
+ return { userId: session.user.id, sid: session.data.sid, admin: session.user.roles.includes("admin") };
99
99
  };
100
100
 
101
101
  /** The phone this request also comes from (Chrome on a paired Android phone shares the app's cookies). */
@@ -119,7 +119,7 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
119
119
  "/pairings",
120
120
  describeRoute({
121
121
  tags,
122
- summary: "Start pairing a phone",
122
+ summary: "Start pairing a device",
123
123
  description: "Needs a web sign-in from the last ten minutes. The link secret is returned once.",
124
124
  responses: { 201: jsonResponse(PwaPairingStartResultSchema, "Pairing started") },
125
125
  }),
@@ -129,13 +129,33 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
129
129
  return c.json(await service.startPairing(actor), 201);
130
130
  },
131
131
  )
132
+ .post(
133
+ "/users/:userId/pairings",
134
+ describeRoute({
135
+ tags,
136
+ summary: "Pair the Cloud app for another person",
137
+ description:
138
+ "Administrator web session only, signed in within ten minutes. App sessions and API keys are refused. The target account must be active. Uses the existing account pairing and device limits. After the administrator confirms the device code, the device must explicitly accept before credentials are issued.",
139
+ responses: {
140
+ 201: jsonResponse(PwaPairingStartResultSchema, "Administrative pairing started"),
141
+ 403: { description: "Administrator, fresh web sign-in or active account required" },
142
+ },
143
+ }),
144
+ v("param", z.object({ userId: z.string().uuid() }), pwaInvalidRequest),
145
+ async (c) => {
146
+ const actor = await webActor(c);
147
+ if (!actor.admin) throw new PwaError("FORBIDDEN", 403);
148
+ if (!shellAvailable(c)) throw new PwaError("UNAVAILABLE", 503);
149
+ return c.json(await service.startUserPairing(actor, c.req.valid("param").userId), 201);
150
+ },
151
+ )
132
152
  .get(
133
153
  "/pairings/:id",
134
154
  describeRoute({
135
155
  tags,
136
156
  summary: "Read a pairing",
137
157
  description:
138
- "Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the phone claims the link.",
158
+ "Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the device claims the link.",
139
159
  responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
140
160
  }),
141
161
  v("param", IdParamSchema, pwaInvalidRequest),
@@ -147,7 +167,13 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
147
167
  )
148
168
  .post(
149
169
  "/pairings/:id/confirm",
150
- describeRoute({ tags, summary: "Confirm a pairing with the code the phone shows", responses: { 204: { description: "Confirmed" } } }),
170
+ describeRoute({
171
+ tags,
172
+ summary: "Confirm a pairing with the code the device shows",
173
+ description:
174
+ "Administrative pairings enter awaiting_device; only the claiming device can then accept or reject. Self-pairing proceeds as before.",
175
+ responses: { 204: { description: "Confirmed" } },
176
+ }),
151
177
  v("param", IdParamSchema, pwaInvalidRequest),
152
178
  v("json", PwaPairingConfirmSchema, pwaInvalidRequest),
153
179
  async (c) => {
@@ -166,7 +192,7 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
166
192
  )
167
193
  .get(
168
194
  "/devices",
169
- describeRoute({ tags, summary: "List paired phones", responses: { 200: jsonResponse(PwaDeviceListSchema, "Active phones") } }),
195
+ describeRoute({ tags, summary: "List paired devices", responses: { 200: jsonResponse(PwaDeviceListSchema, "Active devices") } }),
170
196
  async (c) => {
171
197
  const actor = await webActor(c);
172
198
  return c.json({ items: await service.list(actor, await currentDeviceId(c, actor.userId)) });
@@ -174,7 +200,7 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
174
200
  )
175
201
  .delete(
176
202
  "/devices/:id",
177
- describeRoute({ tags, summary: "Remove a paired phone", responses: { 204: { description: "Removed; idempotent" } } }),
203
+ describeRoute({ tags, summary: "Remove a paired device", responses: { 204: { description: "Removed; idempotent" } } }),
178
204
  v("param", IdParamSchema, pwaInvalidRequest),
179
205
  async (c) => {
180
206
  await service.revoke(await webActor(c), c.req.valid("param").id);