@k2b/cloud 0.43.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 (267) hide show
  1. package/package.json +7 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/PermissionEditor.tsx +9 -3
  21. package/src/access/PrincipalPicker.tsx +14 -4
  22. package/src/access/ResourceApiKeys.tsx +3 -4
  23. package/src/access/app-access-messages.ts +6 -0
  24. package/src/account/ProfilePictureDialog.tsx +699 -0
  25. package/src/account/profile-picture-messages.ts +269 -0
  26. package/src/account/ui.ts +7 -0
  27. package/src/ai/admin.ts +3 -0
  28. package/src/ai/app-frame.ts +12 -0
  29. package/src/ai/application-tasks.ts +101 -0
  30. package/src/ai/assistant-models.ts +126 -64
  31. package/src/ai/capabilities.ts +19 -4
  32. package/src/ai/chart-block.ts +63 -30
  33. package/src/ai/chat/chart-block.tsx +12 -3
  34. package/src/ai/chat/composer-adapter.ts +8 -7
  35. package/src/ai/chat/message-actions.tsx +1 -1
  36. package/src/ai/chat/message-utils.ts +2 -2
  37. package/src/ai/chat/presentation.tsx +22 -1
  38. package/src/ai/chat-quotas.ts +5 -4
  39. package/src/ai/chat-tasks.ts +2 -2
  40. package/src/ai/client/controller.ts +11 -10
  41. package/src/ai/client/projection.ts +3 -3
  42. package/src/ai/compaction.ts +3 -3
  43. package/src/ai/credentials.ts +13 -114
  44. package/src/ai/default-tool-contracts.ts +4 -3
  45. package/src/ai/dictation-runtime.ts +4 -4
  46. package/src/ai/enrich.ts +2 -2
  47. package/src/ai/executor.ts +31 -22
  48. package/src/ai/http.ts +17 -29
  49. package/src/ai/index.ts +11 -5
  50. package/src/ai/inference-calls.ts +5 -4
  51. package/src/ai/maintenance.ts +1 -1
  52. package/src/ai/memory-learning-runs.ts +5 -5
  53. package/src/ai/memory-learning.ts +3 -3
  54. package/src/ai/message-queue.ts +1 -2
  55. package/src/ai/migrate.ts +75 -42
  56. package/src/ai/model-access-migrate.ts +3 -17
  57. package/src/ai/model-access.ts +44 -85
  58. package/src/ai/model-card.tsx +134 -0
  59. package/src/ai/model-catalog.ts +359 -0
  60. package/src/ai/model-chooser-messages.ts +88 -0
  61. package/src/ai/model-chooser.tsx +577 -0
  62. package/src/ai/model-delete.ts +234 -0
  63. package/src/ai/model-fixture.ts +39 -0
  64. package/src/ai/model-icon.tsx +36 -0
  65. package/src/ai/model-overview.ts +53 -0
  66. package/src/ai/model-usage.ts +45 -0
  67. package/src/ai/openai-api-messages.ts +0 -4
  68. package/src/ai/openai-api.ts +26 -9
  69. package/src/ai/personal-agent.ts +1 -1
  70. package/src/ai/prefs.ts +12 -10
  71. package/src/ai/projects-routes.ts +2 -1
  72. package/src/ai/projects.ts +13 -12
  73. package/src/ai/protocol.ts +2 -2
  74. package/src/ai/provider-connections.ts +161 -0
  75. package/src/ai/provider-errors.ts +55 -6
  76. package/src/ai/provider-fetch.ts +4 -0
  77. package/src/ai/provider.ts +1 -1
  78. package/src/ai/quota-limits.ts +36 -0
  79. package/src/ai/quota-provider.ts +4 -2
  80. package/src/ai/quota-report.ts +7 -4
  81. package/src/ai/quotas.ts +19 -8
  82. package/src/ai/recent-resources.ts +6 -0
  83. package/src/ai/request-headers.ts +14 -87
  84. package/src/ai/resource-refs.ts +6 -2
  85. package/src/ai/routes.ts +17 -18
  86. package/src/ai/runtime.ts +50 -22
  87. package/src/ai/settings.ts +93 -580
  88. package/src/ai/store.ts +45 -23
  89. package/src/ai/stream.ts +1 -1
  90. package/src/ai/structured-runs.ts +2 -2
  91. package/src/ai/structured.ts +21 -44
  92. package/src/ai/task-contracts.ts +25 -5
  93. package/src/ai/task-execution.ts +15 -6
  94. package/src/ai/task-model.ts +9 -0
  95. package/src/ai/transcription.ts +27 -22
  96. package/src/ai/types.ts +50 -62
  97. package/src/ai/ui.tsx +13 -0
  98. package/src/ai/usage.ts +8 -8
  99. package/src/ai/validate.ts +8 -7
  100. package/src/ai/view-block.ts +2 -2
  101. package/src/api/accounts-entities.ts +16 -0
  102. package/src/api/admin-ai-quotas.ts +34 -56
  103. package/src/api/admin-ai-usage.ts +1 -1
  104. package/src/api/admin-ai.ts +173 -0
  105. package/src/api/admin-core-settings.ts +68 -244
  106. package/src/api/app-approval.ts +3 -1
  107. package/src/api/app-event-delivery.ts +51 -0
  108. package/src/api/app-events.ts +123 -0
  109. package/src/api/app-resource-access.ts +48 -0
  110. package/src/api/auth/account-mismatch.ts +24 -0
  111. package/src/api/auth.ts +26 -3
  112. package/src/api/capabilities.ts +28 -4
  113. package/src/api/index.ts +7 -1
  114. package/src/api/mcp.ts +175 -2
  115. package/src/api/me.ts +6 -4
  116. package/src/api/pwa-phone.ts +75 -26
  117. package/src/api/pwa.ts +34 -8
  118. package/src/api/resource-links.ts +83 -6
  119. package/src/api/resource-resolve.ts +50 -0
  120. package/src/api/search/schemas.ts +3 -1
  121. package/src/api/search.ts +9 -7
  122. package/src/app-pwa/contracts.ts +4 -0
  123. package/src/app-pwa/core-query-error.ts +20 -0
  124. package/src/app-pwa/index.ts +1 -0
  125. package/src/app-pwa/service.ts +91 -0
  126. package/src/app-pwa/transport.ts +22 -0
  127. package/src/approvals/messages.ts +2 -2
  128. package/src/browser/cloud-id.ts +86 -0
  129. package/src/browser/resource-clipboard.ts +4 -0
  130. package/src/browser/resource-links.ts +52 -0
  131. package/src/capabilities/server-invoke.ts +2 -11
  132. package/src/capabilities/testing.ts +5 -2
  133. package/src/cli/access.ts +6 -5
  134. package/src/cli/admin/ai-models.ts +199 -0
  135. package/src/cli/admin/ai-quotas.ts +6 -102
  136. package/src/cli/admin/ai-usage.ts +4 -4
  137. package/src/cli/admin/app-credentials.ts +5 -3
  138. package/src/cli/admin/index.ts +5 -2
  139. package/src/cli/apps.ts +89 -26
  140. package/src/cli/capabilities.ts +22 -1
  141. package/src/cli/events.ts +320 -0
  142. package/src/config/env.ts +40 -0
  143. package/src/contracts/app-access.ts +26 -2
  144. package/src/contracts/app-details.ts +89 -0
  145. package/src/contracts/app-events.ts +51 -0
  146. package/src/contracts/app.ts +21 -5
  147. package/src/contracts/capabilities.ts +143 -3
  148. package/src/contracts/index.ts +2 -0
  149. package/src/contracts/notification-types.ts +36 -9
  150. package/src/contracts/pwa.ts +17 -1
  151. package/src/contracts/registry.ts +6 -0
  152. package/src/contracts/resource-clipboard.ts +35 -0
  153. package/src/contracts/resource-pages.ts +140 -0
  154. package/src/contracts/resource-resolution.ts +13 -0
  155. package/src/contracts/shared.ts +4 -0
  156. package/src/contracts/user-notifications.ts +2 -0
  157. package/src/events/app-event-delivery.ts +100 -0
  158. package/src/events/app-events.testing.ts +65 -0
  159. package/src/events/app-events.ts +134 -0
  160. package/src/events/core-user-created.ts +24 -0
  161. package/src/events/index.ts +9 -0
  162. package/src/events/testing.ts +2 -0
  163. package/src/index.ts +1 -1
  164. package/src/server/api/respond.ts +8 -3
  165. package/src/server/index.ts +1 -0
  166. package/src/server/middleware/app-access.ts +1 -1
  167. package/src/server/middleware/auth.ts +21 -7
  168. package/src/server/middleware/index.ts +1 -0
  169. package/src/server/middleware/invocation.ts +2 -0
  170. package/src/server/public-storage.ts +106 -0
  171. package/src/server/services/access.ts +8 -0
  172. package/src/services/accounts/app.ts +6 -0
  173. package/src/services/accounts/entities.ts +14 -3
  174. package/src/services/accounts/groups.ts +3 -1
  175. package/src/services/accounts/identities.ts +62 -10
  176. package/src/services/accounts/users.ts +7 -6
  177. package/src/services/app-access.ts +25 -7
  178. package/src/services/app-details.ts +224 -0
  179. package/src/services/app-discovery.ts +2 -1
  180. package/src/services/app-event-shapes.ts +139 -0
  181. package/src/services/app-events/accept.ts +116 -0
  182. package/src/services/app-events/admin.ts +250 -0
  183. package/src/services/app-events/declarations.ts +187 -0
  184. package/src/services/app-events/deliver.ts +218 -0
  185. package/src/services/app-events.ts +92 -0
  186. package/src/services/config-conflict.ts +11 -0
  187. package/src/services/core-user-events.ts +1 -0
  188. package/src/services/freeipa-config.ts +9 -0
  189. package/src/services/geo/ledger.ts +28 -10
  190. package/src/services/geo/service.ts +43 -24
  191. package/src/services/identity/core-origin.ts +12 -0
  192. package/src/services/identity/invocation-actor.ts +20 -2
  193. package/src/services/identity/invocation-authority.ts +4 -0
  194. package/src/services/identity/invocation-token.ts +238 -56
  195. package/src/services/identity/issue-invocation.ts +6 -1
  196. package/src/services/image-metadata.ts +65 -1
  197. package/src/services/index.ts +13 -1
  198. package/src/services/ipa/sync.ts +6 -2
  199. package/src/services/ipa/users.ts +4 -1
  200. package/src/services/mandates/policy.ts +19 -12
  201. package/src/services/notifications/browser.ts +4 -0
  202. package/src/services/notifications/catalog.ts +5 -2
  203. package/src/services/notifications/platform.ts +7 -2
  204. package/src/services/notifications/user.ts +18 -7
  205. package/src/services/oauth-tokens.ts +28 -2
  206. package/src/services/object-store.ts +217 -0
  207. package/src/services/providers/local/users.ts +5 -0
  208. package/src/services/pwa-devices.ts +147 -37
  209. package/src/services/session/index.ts +14 -0
  210. package/src/services/settings/core-settings.ts +71 -43
  211. package/src/services/settings/index.ts +8 -0
  212. package/src/services/settings/versions.ts +26 -0
  213. package/src/shared/ai-models.ts +274 -0
  214. package/src/shared/ai-quotas.ts +2 -2
  215. package/src/shared/ai-usage.ts +2 -2
  216. package/src/shared/index.ts +2 -0
  217. package/src/shared-text/documents.ts +25 -8
  218. package/src/shared-text/log.ts +1 -1
  219. package/src/shared-text/text-diff.ts +75 -0
  220. package/src/ssr/AdminArea.tsx +131 -0
  221. package/src/ssr/AdminLayout.tsx +9 -3
  222. package/src/ssr/AdminSidebar.tsx +7 -7
  223. package/src/ssr/AppAccessDenied.tsx +2 -0
  224. package/src/ssr/AppBadge.tsx +14 -0
  225. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  226. package/src/ssr/CloudShell.island.tsx +25 -4
  227. package/src/ssr/Layout.tsx +8 -6
  228. package/src/ssr/PwaLayout.tsx +16 -1
  229. package/src/ssr/PwaPartCount.island.tsx +23 -0
  230. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  231. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  232. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  233. package/src/ssr/admin-active-link.ts +16 -0
  234. package/src/ssr/admin-navigation.ts +25 -20
  235. package/src/ssr/app-badges.ts +31 -7
  236. package/src/ssr/index.ts +8 -0
  237. package/src/ssr/page-trail-runtime.ts +3 -1
  238. package/src/ssr/platform-messages.ts +9 -1
  239. package/src/ssr/pwa-messages.ts +2 -2
  240. package/src/ssr/rail-navigation.ts +5 -1
  241. package/src/ssr/settings-draft.ts +153 -0
  242. package/src/ssr/shell-data.tsx +2 -0
  243. package/src/styles/global.css +1 -0
  244. package/src/styles/profile-picture.css +270 -0
  245. package/src/styles/utilities-navigation.css +6 -0
  246. package/src/workflows/ai/policy.ts +49 -0
  247. package/src/workflows/ai/runtime.ts +131 -21
  248. package/src/workflows/ai/store.ts +283 -35
  249. package/src/workflows/ai/types.ts +8 -2
  250. package/src/workflows/ai-actions.ts +148 -120
  251. package/src/workflows/authoring.ts +31 -0
  252. package/src/workflows/contracts.ts +1 -0
  253. package/src/workflows/definition.ts +2 -0
  254. package/src/workflows/editor.ts +15 -0
  255. package/src/workflows/index.ts +2 -1
  256. package/src/workflows/language/compiler.ts +34 -3
  257. package/src/workflows/runtime/executor.ts +132 -33
  258. package/src/workflows/runtime/index.ts +2 -0
  259. package/src/workflows/runtime/ports.ts +5 -1
  260. package/src/workflows/store/actions.ts +1 -1
  261. package/src/workflows/store/definitions.ts +5 -0
  262. package/src/workflows/store/runs.ts +5 -3
  263. package/src/workflows/store/worker.ts +7 -2
  264. package/src/ai/model-pricing.ts +0 -43
  265. package/src/ai/model-request-settings.ts +0 -98
  266. package/src/ai/settings-messages.ts +0 -129
  267. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,24 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+ import type { Context } from "hono";
3
+ import { getLocale } from "../../server/locale";
4
+
5
+ const messages = i18n.define({
6
+ baseLocale: "en",
7
+ messages: {
8
+ en: {
9
+ recovery: "Use another sign-in method to confirm your identity. To recover an account, sign out first.",
10
+ mismatch: "Sign in with the account already open in this browser. If your session expired, open the sign-in page and start again.",
11
+ },
12
+ de: {
13
+ recovery: "Bestätige deine Identität mit einer anderen Anmeldemethode. Zur Kontowiederherstellung melde dich zuerst ab.",
14
+ mismatch:
15
+ "Melde dich mit dem Konto an, das in diesem Browser geöffnet ist. Falls deine Sitzung abgelaufen ist, öffne die Anmeldeseite und beginne erneut.",
16
+ },
17
+ },
18
+ });
19
+
20
+ export const accountMismatchResponse = (c: Context) =>
21
+ c.json({ code: "ACCOUNT_MISMATCH", message: messages.resolve([getLocale(c)]).t.mismatch }, 403);
22
+
23
+ export const confirmationRecoveryResponse = (c: Context) =>
24
+ c.json({ code: "CONFIRMATION_METHOD_UNAVAILABLE", message: messages.resolve([getLocale(c)]).t.recovery }, 403);
package/src/api/auth.ts CHANGED
@@ -4,15 +4,18 @@ import { describeRoute } from "hono-openapi";
4
4
  import { z } from "zod";
5
5
  import { env } from "../config";
6
6
  import { ChangeExpiredPasswordSchema } from "../contracts";
7
+ import { coreUserEventPublisher } from "../events/core-user-created";
7
8
  import { type AuthContext, auth, getLocale, jsonResponse, rateLimit, respond, v } from "../server";
8
9
  import { accounts, audit, authFlows, getFreeIpaConfig, logger, webauthn } from "../services";
9
10
  import { isAccountCategoryAllowed } from "../services/account-category-policy";
10
11
  import type { AuthNotificationSender } from "../services/auth-flows/notification-sender";
11
12
  import { legalConsent } from "../services/legal-consent";
12
- import { SessionEpochChangedError } from "../services/session";
13
+ import { SessionAccountMismatchError, SessionEpochChangedError } from "../services/session";
13
14
  import * as categorySettings from "../services/settings";
14
15
  import { publicCloudOrigin } from "../shared/app-url";
15
16
 
17
+ import { accountMismatchResponse, confirmationRecoveryResponse } from "./auth/account-mismatch";
18
+
16
19
  const log = logger("auth");
17
20
 
18
21
  import { ErrorResponseSchema, MessageResponseSchema } from "../contracts";
@@ -33,7 +36,19 @@ const jsonError = (c: Context, message: string, status: 400 | 401 | 500 | 503) =
33
36
  /** Authentication routes: login, logout. */
34
37
  export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
35
38
  new Hono<AuthContext>()
39
+ .onError((error, c) => {
40
+ if (error instanceof SessionAccountMismatchError) return accountMismatchResponse(c);
41
+ throw error;
42
+ })
36
43
  .use(rateLimit())
44
+ .use(async (c, next) => {
45
+ if (
46
+ c.req.header("X-Cloud-Reauthenticate") === "1" &&
47
+ ["/admin-login", "/change-expired-password", "/password-reset/complete"].some((path) => c.req.path.endsWith(path))
48
+ )
49
+ return confirmationRecoveryResponse(c);
50
+ return next();
51
+ })
37
52
  .post(
38
53
  "/legal-consent",
39
54
  describeRoute({ tags: ["Auth"], summary: "Explicitly accept terms after sign-in; unlock the pending session" }),
@@ -301,7 +316,7 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
301
316
  tags: ["Auth"],
302
317
  summary: "Complete password reset",
303
318
  description:
304
- "Use a one-time reset token to first sign the account out everywhere, including unpairing Cloud Login and mobile app phones, then set the password, sign out everywhere again and create the new browser session. If signing out everywhere fails before the change, the password stays unchanged; if it fails after the change, no session is issued. Both answer 500, and a new reset link is required. signOutOthersFailed is false whenever a session is issued.",
319
+ "Use a one-time reset token to first sign the account out everywhere, including unpairing Cloud Login and mobile app devices, then set the password, sign out everywhere again and create the new browser session. If signing out everywhere fails before the change, the password stays unchanged; if it fails after the change, no session is issued. Both answer 500, and a new reset link is required. signOutOthersFailed is false whenever a session is issued.",
305
320
  responses: {
306
321
  200: jsonResponse(PasswordChangeAuthResponseSchema, "Password reset completed and session created"),
307
322
  400: jsonResponse(ErrorResponseSchema, "Password reset failed"),
@@ -399,14 +414,22 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
399
414
  // prior `DO NOTHING` could log into a pre-existing `uid='admin'` row that
400
415
  // was locally demoted (non-admin local user, or even an IPA row named
401
416
  // admin), silently bypassing the intent of the emergency endpoint.
402
- await sql`
417
+ await sql.begin(async (tx) => {
418
+ const [created] = await tx<{ id: string; inserted: boolean }[]>`
403
419
  INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name)
404
420
  VALUES ('admin', 'local', 'user', true, 'Admin', '', 'Admin')
405
421
  ON CONFLICT (uid) DO UPDATE SET
406
422
  provider = 'local',
407
423
  profile = 'user',
408
424
  admin = true
425
+ RETURNING id, (xmax = 0) AS inserted
409
426
  `;
427
+ if (created?.inserted)
428
+ await coreUserEventPublisher().publish(tx, "user.created", {
429
+ subject: created.id,
430
+ data: { userId: created.id, provider: "local" },
431
+ });
432
+ });
410
433
 
411
434
  const user = await accounts.users.get({ uid: "admin" });
412
435
  if (!user) return jsonError(c, "Failed to resolve admin user.", 500);
@@ -1,6 +1,7 @@
1
1
  import { Hono, type MiddlewareHandler } from "hono";
2
2
  import { describeRoute } from "hono-openapi";
3
3
  import { z } from "zod";
4
+ import { type CatalogSubscription, projectAppEventCatalog, readCatalogSubscriptions } from "../_internal/app-event-catalog";
4
5
  import { readBoundedJson } from "../_internal/bounded-json";
5
6
  import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
6
7
  import {
@@ -112,6 +113,7 @@ const CapabilityCatalogQuerySchema = z
112
113
  .strict();
113
114
  export type CapabilityRouteDependencies = {
114
115
  listApps?: () => Promise<AppRegistryEntry[]>;
116
+ eventSubscriptions?: () => Promise<CatalogSubscription[]>;
115
117
  getCapability?: (appId: string) => Promise<CapabilityRegistryEntry | null>;
116
118
  fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
117
119
  authenticate?: MiddlewareHandler<AuthContext>;
@@ -165,7 +167,7 @@ const invocationAuthorityFromMandate = (authority: {
165
167
 
166
168
  export const loadCapabilityCatalogPage = async (
167
169
  query: { cursor?: string; limit: number },
168
- dependencies: Pick<CapabilityRouteDependencies, "listApps" | "getCapability"> = {},
170
+ dependencies: Pick<CapabilityRouteDependencies, "listApps" | "getCapability" | "eventSubscriptions"> = {},
169
171
  locale?: string,
170
172
  authority: RequestAuthority | null = null,
171
173
  ): Promise<CapabilityCatalog> => {
@@ -178,6 +180,21 @@ export const loadCapabilityCatalogPage = async (
178
180
  const liveApps = entries
179
181
  .filter((entry) => entry.capabilities?.protocolVersion === CAPABILITY_PROTOCOL_VERSION)
180
182
  .sort((left, right) => left.id.localeCompare(right.id));
183
+ const resolved = new Map<string, CapabilityRegistryEntry | null>();
184
+ const lookup = async (appId: string) => {
185
+ if (!resolved.has(appId)) resolved.set(appId, await capabilityLookup(appId));
186
+ return resolved.get(appId) ?? null;
187
+ };
188
+ let subscriptions: CatalogSubscription[] | undefined;
189
+ const loadSubscribers = async () => {
190
+ if (!subscriptions) {
191
+ const visibleApps = new Set(liveApps.map((entry) => entry.id));
192
+ subscriptions = (await (dependencies.eventSubscriptions ?? readCatalogSubscriptions)()).filter((subscription) =>
193
+ visibleApps.has(subscription.appId),
194
+ );
195
+ }
196
+ return subscriptions;
197
+ };
181
198
  const start = query.cursor ? liveApps.findIndex((entry) => entry.id > query.cursor!) : 0;
182
199
  const offset = start < 0 ? liveApps.length : start;
183
200
  const apps: CapabilityCatalog["apps"] = [];
@@ -192,22 +209,29 @@ export const loadCapabilityCatalogPage = async (
192
209
  ).byteLength;
193
210
  for (const entry of liveApps.slice(offset)) {
194
211
  if (apps.length === query.limit) break;
195
- const capability = await capabilityLookup(entry.id);
212
+ const capability = await lookup(entry.id);
196
213
  if (!capability || capability.manifest.manifestHash !== entry.capabilities?.manifestHash) {
197
214
  lastScannedAppId = entry.id;
198
215
  continue;
199
216
  }
200
217
  const presentedEntry = locale ? resolveAppPresentation(entry, locale) : entry;
218
+ const manifest = locale
219
+ ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale)
220
+ : capability.manifest;
201
221
  const projected = {
202
222
  appId: capability.appId,
203
223
  appName: presentedEntry.name,
204
224
  appIcon: capability.appIcon,
205
225
  appDescription: presentedEntry.description,
206
- manifest: locale ? resolveCapabilityManifestPresentation(capability.manifest, capability.presentation, locale) : capability.manifest,
226
+ manifest,
227
+ ...(manifest.events ? { events: projectAppEventCatalog(manifest, manifest.events.length ? await loadSubscribers() : []) } : {}),
207
228
  };
208
229
  const projectedBytes = new TextEncoder().encode(JSON.stringify(projected)).byteLength;
209
230
  const separatingCommas = apps.length;
210
- if (apps.length > 0 && envelopeBytes + bytes + separatingCommas + projectedBytes > CAPABILITY_MAX_CATALOG_BYTES) break;
231
+ if (envelopeBytes + bytes + separatingCommas + projectedBytes > CAPABILITY_MAX_CATALOG_BYTES) {
232
+ if (!apps.length) throw new RangeError("An app's event catalog exceeds the catalog response byte limit");
233
+ break;
234
+ }
211
235
  apps.push(projected);
212
236
  bytes += projectedBytes;
213
237
  lastScannedAppId = entry.id;
package/src/api/index.ts CHANGED
@@ -21,6 +21,7 @@ import { aiProjectsRoutes } from "../ai/projects-routes";
21
21
  import { aiRoutes } from "../ai/routes";
22
22
  import { aiSkillsRoutes } from "../ai/skills-routes";
23
23
  import accountsEntitiesRoutes from "./accounts-entities";
24
+ import adminAiRoutes from "./admin-ai";
24
25
  import adminAiProjectsRoutes from "./admin-ai-projects";
25
26
  import adminAiQuotaRoutes from "./admin-ai-quotas";
26
27
  import adminAiSkillsRoutes from "./admin-ai-skills";
@@ -36,6 +37,7 @@ import adminOutgoingMailRoutes from "./admin-outgoing-mail";
36
37
  import adminRailRoutes from "./admin-rail";
37
38
  import { adminAnnouncementRoutes, announcementRoutes } from "./announcements";
38
39
  import { createAppApprovalRoutes } from "./app-approval";
40
+ import { createAppEventRoutes } from "./app-events";
39
41
  import { approvalRoutes } from "./approvals";
40
42
  import { appDiscoveryRoutes } from "./apps";
41
43
  import { createAuthRoutes } from "./auth";
@@ -45,6 +47,7 @@ import { createHelpRoutes } from "./help";
45
47
  import { createMcpRoutes } from "./mcp";
46
48
  import meRoutes from "./me";
47
49
  import { createPwaRoutes } from "./pwa";
50
+ import { createResourceResolveRoutes } from "./resource-resolve";
48
51
  import { createSearchRoutes } from "./search";
49
52
  import { createSyncOpsProxyRoutes } from "./sync-ops";
50
53
  import { createWidgetRoutes } from "./widgets";
@@ -85,8 +88,11 @@ const buildCoreApi = (options: CoreApiOptions) => {
85
88
  .route("/accounts", accountsEntitiesRoutes)
86
89
  .route("/geo", geoRoutes)
87
90
  .route("/apps", appDiscoveryRoutes)
91
+ .route("/app-events", createAppEventRoutes())
92
+ .route("/resources", createResourceResolveRoutes())
88
93
  .route("/approvals", approvalRoutes)
89
94
  .route("/announcements", announcementRoutes)
95
+ .route("/admin/core/ai", adminAiRoutes)
90
96
  .route("/admin/core/ai-usage", adminAiUsageRoutes)
91
97
  .route("/admin/core/ai-quotas", adminAiQuotaRoutes)
92
98
  .route("/admin/core/ai-projects", adminAiProjectsRoutes)
@@ -136,4 +142,4 @@ export {
136
142
  createMcpRoutes,
137
143
  publicCloudOrigin,
138
144
  } from "./mcp";
139
- export { resolveCloudResourceHref } from "./resource-links";
145
+ export { resolveCloudResourceHref, resolveCloudResourceUrl } from "./resource-links";
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,
@@ -64,6 +70,27 @@ const IDEMPOTENCY_KEY_FIELD = "idempotencyKey";
64
70
  const RESOURCE_READ_TOOL = "cloud__resource__read";
65
71
  const HELP_SEARCH_TOOL = "cloud__help__search";
66
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
+ });
67
94
  export const CLOUD_MCP_PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource/api/mcp/v1";
68
95
  const MCP_TOOL_NAME_MAX_LENGTH = 128;
69
96
  const MCP_INSTRUCTIONS =
@@ -73,6 +100,7 @@ const log = logger("mcp");
73
100
  type McpRouteDependencies = CapabilityDispatchDependencies & {
74
101
  listApps?: () => Promise<AppRegistryEntry[]>;
75
102
  help?: HelpReaderFactory;
103
+ eventSubscriptions?: (type: string, version: number) => Promise<CatalogSubscription[]>;
76
104
  getOperatorLocale?: () => Promise<string | undefined>;
77
105
  getOperatorTimeZone?: () => Promise<string | undefined>;
78
106
  getAppUrl?: () => Promise<string>;
@@ -206,6 +234,38 @@ const platformTools: Tool[] = [
206
234
  annotations: { title: "Read Cloud Help", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
207
235
  _meta: { "cloud/kind": "help-read" },
208
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
+ },
209
269
  ];
210
270
 
211
271
  const projectTool = (
@@ -343,10 +403,12 @@ const parseResponse = async (response: Response): Promise<Record<string, unknown
343
403
  : { code: CAPABILITY_FRAMEWORK_ERROR_CODES.invalidAppResponse, message: "Capability returned a non-object result" };
344
404
  };
345
405
 
346
- 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 => {
347
409
  const error = {
348
410
  code: CAPABILITY_FRAMEWORK_ERROR_CODES.responseTooLarge,
349
- message: "Capability result is too large for an MCP tool response; narrow the query and retry",
411
+ message,
350
412
  };
351
413
  return { isError: true, structuredContent: error, content: [{ type: "text", text: JSON.stringify(error) }] };
352
414
  };
@@ -490,6 +552,97 @@ const callHelpTool = async (name: string, argsValue: unknown, reader: HelpReader
490
552
  return null;
491
553
  };
492
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
+
493
646
  const createMcpServer = (
494
647
  request: Request,
495
648
  dependencies: McpRouteDependencies,
@@ -582,6 +735,26 @@ const createMcpServer = (
582
735
  });
583
736
 
584
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
+ }
585
758
  if (message.params.name === HELP_SEARCH_TOOL || message.params.name === HELP_READ_TOOL) {
586
759
  if (!hasScope("read")) {
587
760
  return boundedToolResult({ code: "FORBIDDEN", message: "OAuth scope read is required" }, true);
package/src/api/me.ts CHANGED
@@ -324,7 +324,8 @@ const app = new Hono<AuthContext>()
324
324
  describeRoute({
325
325
  tags: ["Me"],
326
326
  summary: "List current user notification preferences",
327
- 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.",
328
329
  ...requiresAuth,
329
330
  responses: {
330
331
  200: jsonResponse(UserNotificationPreferencesResponseSchema, "Notification preferences"),
@@ -343,11 +344,12 @@ const app = new Hono<AuthContext>()
343
344
  describeRoute({
344
345
  tags: ["Me"],
345
346
  summary: "Set current user notification preference",
346
- 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.",
347
349
  ...requiresAuth,
348
350
  responses: {
349
351
  200: jsonResponse(UserNotificationPreferenceSchema, "Updated notification preference"),
350
- 400: jsonResponse(ErrorResponseSchema, "Invalid or unavailable channel"),
352
+ 400: jsonResponse(ErrorResponseSchema, "Invalid, unavailable, or excluded channel"),
351
353
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
352
354
  404: jsonResponse(ErrorResponseSchema, "Notification preference not found"),
353
355
  },
@@ -818,7 +820,7 @@ const app = new Hono<AuthContext>()
818
820
  tags: ["Me"],
819
821
  summary: "Sign out every other sign-in",
820
822
  description:
821
- "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.",
822
824
  ...requiresAuth,
823
825
  responses: {
824
826
  200: jsonResponse(SignOutOthersResponseSchema, "Other sign-ins ended"),
@@ -1,10 +1,21 @@
1
1
  import { type Context, Hono } from "hono";
2
2
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
+ import { describeRoute } from "hono-openapi";
3
4
  import { registeredPwaBuilds } from "../_internal/runtime-watcher";
4
5
  import { env } from "../config/env";
5
- import { PWA_AUTH_PATH, PWA_COOKIES, PWA_LIMITS, PWA_SCOPE, PwaClaimSchema, type PwaLaunchState, PwaRenameSchema } from "../contracts/pwa";
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";
6
17
  import { launchTarget } from "../contracts/pwa-location";
7
- import { type AuthContext, auth, getLocale, rateLimit, v } from "../server";
18
+ import { type AuthContext, auth, getLocale, jsonResponse, rateLimit, v } from "../server";
8
19
  import { type PwaCredentials, type PwaDeviceNotifier, PwaError, pwaDevices } from "../services/pwa-devices";
9
20
  import { defaultShellAvailable, handlePwaError, type PwaRouteOptions, pwaErrorResponse, pwaInvalidRequest, pwaTransport } from "./pwa";
10
21
 
@@ -83,30 +94,68 @@ export const createPwaPhoneRoutes = (options: PwaPhoneRouteOptions) => {
83
94
  });
84
95
  return c.json({ code: claimed.code, account: claimed.account, expiresAt: claimed.expiresAt });
85
96
  })
86
- .post("/pairings/complete", async (c) => {
87
- requireShell(c);
88
- const web = await webSession(c);
89
- try {
90
- const result = await service.completePairing(c, {
91
- completionSecret: getCookie(c, PWA_COOKIES.pairing),
92
- deviceKey: getCookie(c, PWA_COOKIES.device),
93
- appSession: await appSession(c),
94
- webUserId: web?.user.id ?? null,
95
- locale: getLocale(c),
96
- notify: options.notify,
97
- });
98
- if (result.state === "waiting") return c.json(result, 202);
99
- if (result.credentials) setAppCredentials(c, result.credentials);
100
- clearLastAddress(c);
101
- clearPairing(c);
102
- // A Home Screen app on iOS has its own cookie jar; a web session there is only a copy from Safari.
103
- if (result.platform === "ios" && auth.session.getWebToken(c)) deleteCookie(c, "session_token", { path: "/" });
104
- return c.json({ state: "paired" as const });
105
- } catch (error) {
106
- if (error instanceof PwaError && error.code === "EXPIRED") clearPairing(c);
107
- throw error;
108
- }
109
- })
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
+ )
110
159
  .get("/session/launch", async (c) => {
111
160
  const to = launchTarget(c.req.query("to"));
112
161
  if (!shellAvailable(c)) return c.redirect(stateUrl("unavailable"), 302);