@k2b/cloud 0.43.0 → 0.45.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 (308) hide show
  1. package/package.json +11 -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/GroupCoverage.tsx +20 -5
  21. package/src/access/PermissionEditor.tsx +20 -5
  22. package/src/access/PrincipalAvatar.tsx +30 -21
  23. package/src/access/PrincipalPicker.tsx +14 -4
  24. package/src/access/ResourceApiKeys.tsx +3 -4
  25. package/src/access/app-access-messages.ts +6 -0
  26. package/src/access/messages.ts +4 -4
  27. package/src/account/EntitySearch.tsx +12 -0
  28. package/src/account/ProfilePictureDialog.tsx +699 -0
  29. package/src/account/profile-picture-messages.ts +269 -0
  30. package/src/account/ui.ts +7 -0
  31. package/src/ai/admin.ts +3 -0
  32. package/src/ai/app-frame.ts +12 -0
  33. package/src/ai/application-tasks.ts +101 -0
  34. package/src/ai/assistant-models.ts +126 -64
  35. package/src/ai/capabilities.ts +19 -4
  36. package/src/ai/chart-block.ts +63 -30
  37. package/src/ai/chat/chart-block.tsx +12 -3
  38. package/src/ai/chat/composer-adapter.ts +8 -7
  39. package/src/ai/chat/message-actions.tsx +1 -1
  40. package/src/ai/chat/message-utils.ts +2 -2
  41. package/src/ai/chat/presentation.tsx +22 -1
  42. package/src/ai/chat-quotas.ts +5 -4
  43. package/src/ai/chat-tasks.ts +2 -2
  44. package/src/ai/client/controller.ts +11 -10
  45. package/src/ai/client/projection.ts +3 -3
  46. package/src/ai/compaction.ts +3 -3
  47. package/src/ai/credentials.ts +13 -114
  48. package/src/ai/default-tool-contracts.ts +4 -3
  49. package/src/ai/dictation-runtime.ts +10 -6
  50. package/src/ai/enrich.ts +2 -2
  51. package/src/ai/executor.ts +40 -25
  52. package/src/ai/http.ts +17 -29
  53. package/src/ai/index.ts +12 -5
  54. package/src/ai/inference-calls.ts +5 -4
  55. package/src/ai/maintenance.ts +1 -1
  56. package/src/ai/memory-learning-runs.ts +5 -5
  57. package/src/ai/memory-learning.ts +3 -3
  58. package/src/ai/message-queue.ts +1 -2
  59. package/src/ai/migrate.ts +75 -42
  60. package/src/ai/model-access-migrate.ts +3 -17
  61. package/src/ai/model-access.ts +44 -85
  62. package/src/ai/model-card.tsx +134 -0
  63. package/src/ai/model-catalog.ts +359 -0
  64. package/src/ai/model-chooser-messages.ts +88 -0
  65. package/src/ai/model-chooser.tsx +577 -0
  66. package/src/ai/model-delete.ts +234 -0
  67. package/src/ai/model-fixture.ts +39 -0
  68. package/src/ai/model-icon.tsx +36 -0
  69. package/src/ai/model-overview.ts +53 -0
  70. package/src/ai/model-usage.ts +45 -0
  71. package/src/ai/openai-api-messages.ts +0 -4
  72. package/src/ai/openai-api.ts +26 -9
  73. package/src/ai/personal-agent.ts +1 -1
  74. package/src/ai/prefs.ts +12 -10
  75. package/src/ai/project-tool.ts +4 -4
  76. package/src/ai/projects-authorization.ts +53 -0
  77. package/src/ai/projects-routes.ts +58 -47
  78. package/src/ai/projects.ts +110 -88
  79. package/src/ai/protocol.ts +2 -2
  80. package/src/ai/provider-connections.ts +161 -0
  81. package/src/ai/provider-errors.ts +55 -6
  82. package/src/ai/provider-fetch.ts +4 -0
  83. package/src/ai/provider.ts +1 -1
  84. package/src/ai/quota-limits.ts +36 -0
  85. package/src/ai/quota-provider.ts +4 -2
  86. package/src/ai/quota-report.ts +7 -4
  87. package/src/ai/quotas.ts +19 -8
  88. package/src/ai/recent-resources.ts +9 -3
  89. package/src/ai/request-headers.ts +14 -87
  90. package/src/ai/resource-refs.ts +6 -2
  91. package/src/ai/routes.ts +47 -48
  92. package/src/ai/runtime.ts +50 -22
  93. package/src/ai/settings.ts +93 -580
  94. package/src/ai/skill-tool.ts +4 -4
  95. package/src/ai/skills-routes.ts +27 -26
  96. package/src/ai/skills.ts +120 -81
  97. package/src/ai/store.ts +45 -23
  98. package/src/ai/stream.ts +1 -1
  99. package/src/ai/structured-runs.ts +2 -2
  100. package/src/ai/structured.ts +21 -44
  101. package/src/ai/task-contracts.ts +25 -5
  102. package/src/ai/task-execution.ts +15 -6
  103. package/src/ai/task-model.ts +9 -0
  104. package/src/ai/transcription.ts +27 -22
  105. package/src/ai/types.ts +50 -62
  106. package/src/ai/ui.tsx +13 -0
  107. package/src/ai/usage.ts +8 -8
  108. package/src/ai/validate.ts +8 -7
  109. package/src/ai/view-block.ts +2 -2
  110. package/src/api/accounts-entities.ts +22 -3
  111. package/src/api/admin-ai-quotas.ts +34 -56
  112. package/src/api/admin-ai-skills.ts +4 -1
  113. package/src/api/admin-ai-usage.ts +1 -1
  114. package/src/api/admin-ai.ts +173 -0
  115. package/src/api/admin-core-settings.ts +68 -244
  116. package/src/api/app-approval.ts +3 -1
  117. package/src/api/app-event-delivery.ts +51 -0
  118. package/src/api/app-events.ts +123 -0
  119. package/src/api/app-resource-access.ts +48 -0
  120. package/src/api/auth/account-mismatch.ts +24 -0
  121. package/src/api/auth.ts +35 -5
  122. package/src/api/capabilities.ts +28 -4
  123. package/src/api/index.ts +7 -1
  124. package/src/api/mcp.ts +175 -2
  125. package/src/api/me.ts +6 -4
  126. package/src/api/pwa-phone.ts +75 -26
  127. package/src/api/pwa.ts +34 -8
  128. package/src/api/resource-links.ts +83 -6
  129. package/src/api/resource-resolve.ts +50 -0
  130. package/src/api/search/schemas.ts +3 -1
  131. package/src/api/search.ts +9 -7
  132. package/src/app-pwa/contracts.ts +4 -0
  133. package/src/app-pwa/core-query-error.ts +20 -0
  134. package/src/app-pwa/index.ts +1 -0
  135. package/src/app-pwa/service.ts +91 -0
  136. package/src/app-pwa/transport.ts +22 -0
  137. package/src/approvals/messages.ts +2 -2
  138. package/src/browser/cloud-id.ts +86 -0
  139. package/src/browser/resource-clipboard.ts +4 -0
  140. package/src/browser/resource-links.ts +52 -0
  141. package/src/browser/session.ts +39 -0
  142. package/src/browser/shared-text-history-validation.ts +151 -0
  143. package/src/browser/shared-text-history.ts +464 -0
  144. package/src/browser/shared-text-offline-authorized.ts +29 -0
  145. package/src/browser/shared-text-offline.ts +351 -0
  146. package/src/browser/shared-text-provider.ts +714 -0
  147. package/src/browser/shared-text-table-contract.ts +150 -0
  148. package/src/browser/shared-text.ts +19 -0
  149. package/src/capabilities/server-invoke.ts +2 -11
  150. package/src/capabilities/testing.ts +5 -2
  151. package/src/cli/access.ts +6 -5
  152. package/src/cli/admin/ai-models.ts +199 -0
  153. package/src/cli/admin/ai-quotas.ts +6 -102
  154. package/src/cli/admin/ai-usage.ts +4 -4
  155. package/src/cli/admin/app-credentials.ts +5 -3
  156. package/src/cli/admin/index.ts +5 -2
  157. package/src/cli/apps.ts +89 -26
  158. package/src/cli/capabilities.ts +22 -1
  159. package/src/cli/events.ts +320 -0
  160. package/src/config/env.ts +40 -0
  161. package/src/contracts/app-access.ts +26 -2
  162. package/src/contracts/app-details.ts +89 -0
  163. package/src/contracts/app-events.ts +51 -0
  164. package/src/contracts/app.ts +21 -5
  165. package/src/contracts/capabilities.ts +143 -3
  166. package/src/contracts/index.ts +2 -0
  167. package/src/contracts/notification-types.ts +36 -9
  168. package/src/contracts/pwa.ts +17 -1
  169. package/src/contracts/registry.ts +6 -0
  170. package/src/contracts/resource-clipboard.ts +35 -0
  171. package/src/contracts/resource-pages.ts +140 -0
  172. package/src/contracts/resource-resolution.ts +13 -0
  173. package/src/contracts/shared.ts +6 -1
  174. package/src/contracts/user-notifications.ts +2 -0
  175. package/src/events/app-event-delivery.ts +100 -0
  176. package/src/events/app-events.testing.ts +65 -0
  177. package/src/events/app-events.ts +134 -0
  178. package/src/events/core-user-created.ts +24 -0
  179. package/src/events/index.ts +9 -0
  180. package/src/events/testing.ts +2 -0
  181. package/src/index.ts +1 -1
  182. package/src/server/api/respond.ts +17 -4
  183. package/src/server/index.ts +2 -0
  184. package/src/server/middleware/app-access.ts +1 -1
  185. package/src/server/middleware/auth.ts +21 -7
  186. package/src/server/middleware/index.ts +1 -0
  187. package/src/server/middleware/invocation.ts +2 -0
  188. package/src/server/public-storage.ts +106 -0
  189. package/src/server/services/access.ts +31 -10
  190. package/src/server/services/index.ts +1 -0
  191. package/src/services/accounts/app.ts +36 -13
  192. package/src/services/accounts/entities.ts +43 -19
  193. package/src/services/accounts/group-sql.ts +23 -0
  194. package/src/services/accounts/groups.ts +19 -14
  195. package/src/services/accounts/identities.ts +70 -11
  196. package/src/services/accounts/identity-reconciliation.ts +3 -1
  197. package/src/services/accounts/local-groups.ts +69 -4
  198. package/src/services/accounts/users.ts +45 -14
  199. package/src/services/app-access.ts +25 -7
  200. package/src/services/app-details.ts +224 -0
  201. package/src/services/app-discovery.ts +2 -1
  202. package/src/services/app-event-shapes.ts +139 -0
  203. package/src/services/app-events/accept.ts +116 -0
  204. package/src/services/app-events/admin.ts +250 -0
  205. package/src/services/app-events/declarations.ts +187 -0
  206. package/src/services/app-events/deliver.ts +218 -0
  207. package/src/services/app-events.ts +92 -0
  208. package/src/services/config-conflict.ts +11 -0
  209. package/src/services/core-user-events.ts +1 -0
  210. package/src/services/freeipa-config.ts +9 -0
  211. package/src/services/geo/ledger.ts +28 -10
  212. package/src/services/geo/service.ts +43 -24
  213. package/src/services/identity/core-origin.ts +12 -0
  214. package/src/services/identity/invocation-actor.ts +20 -2
  215. package/src/services/identity/invocation-authority.ts +4 -0
  216. package/src/services/identity/invocation-token.ts +238 -56
  217. package/src/services/identity/issue-invocation.ts +6 -1
  218. package/src/services/image-metadata.ts +65 -1
  219. package/src/services/index.ts +13 -1
  220. package/src/services/ipa/sync.ts +6 -2
  221. package/src/services/ipa/users.ts +4 -1
  222. package/src/services/mandates/index.ts +2 -2
  223. package/src/services/mandates/policy.ts +20 -12
  224. package/src/services/notifications/browser.ts +4 -0
  225. package/src/services/notifications/catalog.ts +5 -2
  226. package/src/services/notifications/platform.ts +7 -2
  227. package/src/services/notifications/user.ts +18 -7
  228. package/src/services/oauth-tokens.ts +28 -2
  229. package/src/services/object-store.ts +217 -0
  230. package/src/services/providers/local/users.ts +5 -0
  231. package/src/services/pwa-devices.ts +147 -37
  232. package/src/services/session/index.ts +26 -3
  233. package/src/services/settings/core-settings.ts +71 -43
  234. package/src/services/settings/index.ts +8 -0
  235. package/src/services/settings/versions.ts +26 -0
  236. package/src/shared/ai-models.ts +274 -0
  237. package/src/shared/ai-quotas.ts +2 -2
  238. package/src/shared/ai-usage.ts +2 -2
  239. package/src/shared/email-html.ts +1 -0
  240. package/src/shared/index.ts +2 -0
  241. package/src/shared/markdown/extensions/katex.ts +8 -1
  242. package/src/shared/markdown/extensions/tables.ts +14 -1
  243. package/src/shared/markdown/index.ts +22 -11
  244. package/src/shared-text/anchors.ts +29 -0
  245. package/src/shared-text/awareness.ts +87 -0
  246. package/src/shared-text/changes.ts +27 -0
  247. package/src/shared-text/documents.ts +433 -65
  248. package/src/shared-text/events.ts +12 -2
  249. package/src/shared-text/index.ts +16 -1
  250. package/src/shared-text/log.ts +24 -5
  251. package/src/shared-text/snapshots.ts +69 -23
  252. package/src/shared-text/structural-history.ts +377 -0
  253. package/src/shared-text/table-actions.ts +432 -0
  254. package/src/shared-text/text-diff.ts +75 -0
  255. package/src/shared-text/types.ts +24 -5
  256. package/src/ssr/AdminArea.tsx +131 -0
  257. package/src/ssr/AdminLayout.tsx +9 -3
  258. package/src/ssr/AdminSidebar.tsx +7 -7
  259. package/src/ssr/AppAccessDenied.tsx +2 -0
  260. package/src/ssr/AppBadge.tsx +14 -0
  261. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  262. package/src/ssr/CloudShell.island.tsx +44 -7
  263. package/src/ssr/Layout.tsx +9 -7
  264. package/src/ssr/MobileProfileActions.tsx +2 -2
  265. package/src/ssr/ProfilePreferences.tsx +2 -1
  266. package/src/ssr/PwaLayout.tsx +16 -1
  267. package/src/ssr/PwaPartCount.island.tsx +23 -0
  268. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  269. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  270. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  271. package/src/ssr/admin-active-link.ts +16 -0
  272. package/src/ssr/admin-navigation.ts +26 -31
  273. package/src/ssr/app-badges.ts +31 -7
  274. package/src/ssr/app-launchpad.tsx +6 -1
  275. package/src/ssr/index.ts +8 -0
  276. package/src/ssr/page-trail-runtime.ts +3 -1
  277. package/src/ssr/platform-messages.ts +11 -1
  278. package/src/ssr/profile-actions.ts +3 -6
  279. package/src/ssr/pwa-messages.ts +2 -2
  280. package/src/ssr/rail-navigation.ts +5 -1
  281. package/src/ssr/settings-draft.ts +153 -0
  282. package/src/ssr/shell-data.tsx +3 -1
  283. package/src/styles/global.css +1 -0
  284. package/src/styles/profile-picture.css +270 -0
  285. package/src/styles/utilities-navigation.css +6 -0
  286. package/src/workflows/ai/policy.ts +49 -0
  287. package/src/workflows/ai/runtime.ts +131 -21
  288. package/src/workflows/ai/store.ts +283 -35
  289. package/src/workflows/ai/types.ts +8 -2
  290. package/src/workflows/ai-actions.ts +148 -120
  291. package/src/workflows/authoring.ts +31 -0
  292. package/src/workflows/contracts.ts +1 -0
  293. package/src/workflows/definition.ts +2 -0
  294. package/src/workflows/editor.ts +15 -0
  295. package/src/workflows/index.ts +2 -1
  296. package/src/workflows/language/compiler.ts +34 -3
  297. package/src/workflows/language/expressions.ts +3 -2
  298. package/src/workflows/runtime/executor.ts +134 -34
  299. package/src/workflows/runtime/index.ts +2 -0
  300. package/src/workflows/runtime/ports.ts +5 -1
  301. package/src/workflows/store/actions.ts +1 -1
  302. package/src/workflows/store/definitions.ts +5 -0
  303. package/src/workflows/store/runs.ts +5 -3
  304. package/src/workflows/store/worker.ts +7 -2
  305. package/src/ai/model-pricing.ts +0 -43
  306. package/src/ai/model-request-settings.ts +0 -98
  307. package/src/ai/settings-messages.ts +0 -129
  308. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -10,47 +10,35 @@ import { sql } from "bun";
10
10
  import { Hono } from "hono";
11
11
  import { describeRoute } from "hono-openapi";
12
12
  import { z } from "zod";
13
+ import { ObjectStoreConfigurationError, ObjectStoreConnectionError, testObjectStoreConnection } from "../_internal/object-store-config";
13
14
  import { listApps } from "../_internal/registry";
14
- import { listAiCredentialProfileIds, pruneAiCredentials, setAiCredential, splitAiProfileCredentials } from "../ai/credentials";
15
15
  import { enrichDirtyAiConversations } from "../ai/enrich";
16
- import {
17
- type AiModelAccessChange,
18
- AiModelAccessConflict,
19
- AiModelAccessInvalid,
20
- aiModelAccess,
21
- splitAiModelAccess,
22
- } from "../ai/model-access";
23
- import { listAiRequestHeaderNames, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "../ai/request-headers";
24
- import {
25
- parseAiModelProfiles,
26
- planAiProfileCredentials,
27
- prepareAiModelProfileImages,
28
- validateAiSettingsConfiguration,
29
- } from "../ai/settings";
30
- import { type AiSettingsIssueWithMessage, aiSettingsNotSavedMessage, describeAiSettingsIssues } from "../ai/settings-messages";
31
16
  import { type AuthContext, auth, getLocale, jsonResponse, requiresAdmin, v } from "../server";
32
- import { settingsDeleteLegacyKeys, settingsListLegacyKeys } from "../services";
17
+ import { settingsDeleteLegacyKeys, settingsListLegacyKeys, settingsService } from "../services";
33
18
  import { readAccountCategoryPolicy } from "../services/account-category-policy";
34
19
  import { audit } from "../services/audit";
20
+ import { ConfigConflict } from "../services/config-conflict";
35
21
  import { validateFreeIpaCaCert } from "../services/freeipa-config";
36
22
  import { ImageMetadataError } from "../services/image-metadata";
37
23
  import { testFreeIpaConnection } from "../services/ipa/connection";
38
24
  import { GotenbergRenderError, testGotenberg } from "../services/pdf";
39
25
  import * as settings from "../services/settings";
40
26
  import { SETTINGS_MAP, validateSettingValue } from "../services/settings/defaults";
41
-
42
27
  import { assertGeoBudgetPair, GeoBudgetSettingError } from "../services/settings/geo-budget";
43
28
  import { invalidateSettingsCacheForAdmin } from "../services/settings/store";
29
+ import { assertSettingVersions } from "../services/settings/versions";
44
30
 
31
+ const BulkUpdatesSchema = z
32
+ .object({
33
+ updates: z.record(z.string(), z.unknown()).optional().default({}),
34
+ resets: z.array(z.string()).optional().default([]),
35
+ replaceUnreadable: z.array(z.string()).optional().default([]),
36
+ expectedUpdatedAt: z.record(z.string(), z.string().nullable()).optional().default({}),
37
+ })
38
+ .strict();
45
39
  const BulkSaveSchema = z.union([
46
- z
47
- .object({
48
- updates: z.record(z.string(), z.unknown()).optional().default({}),
49
- resets: z.array(z.string()).optional().default([]),
50
- replaceUnreadable: z.array(z.string()).optional().default([]),
51
- })
52
- .strict(),
53
- z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[], replaceUnreadable: [] as string[] })),
40
+ BulkUpdatesSchema,
41
+ z.record(z.string(), z.unknown()).transform((updates) => BulkUpdatesSchema.parse({ updates })),
54
42
  ]);
55
43
  const LEGAL_DOCUMENTS = [
56
44
  { kind: "terms", path: "/legal/terms" },
@@ -72,150 +60,8 @@ const isKnownSetting = (key: string): boolean => SETTINGS_MAP.has(key);
72
60
  const GEO_KEY = "geo.api_key";
73
61
  const GEO_KEY_DEDICATED = "Set or remove the Geoapify key with /api/admin/core/geo/key, which verifies it first.";
74
62
 
75
- const AI_PROFILES_KEY = "ai.model_profiles_json";
76
- const AI_ENABLED_KEY = "ai.enabled";
77
- const AI_DEFAULT_MODEL_KEY = "ai.default_model_id";
78
- const AI_BACKGROUND_MODEL_KEY = "ai.background_model_id";
79
- const AI_AUDIO_MODEL_KEY = "ai.audio_model_id";
80
- const AI_VISION_MODEL_KEY = "ai.vision_model_id";
81
- const AI_WORKFLOW_MODEL_KEY = "ai.workflow_model_id";
82
- const AI_CONFIGURATION_KEYS = new Set([
83
- AI_ENABLED_KEY,
84
- AI_DEFAULT_MODEL_KEY,
85
- AI_BACKGROUND_MODEL_KEY,
86
- AI_VISION_MODEL_KEY,
87
- AI_AUDIO_MODEL_KEY,
88
- AI_WORKFLOW_MODEL_KEY,
89
- AI_PROFILES_KEY,
90
- ]);
91
63
  const INTEGER_FREEIPA_SETTINGS = new Set(["freeipa.sync_guard.max_user_changes", "freeipa.sync_guard.max_group_deletions"]);
92
64
 
93
- /**
94
- * Move provider keys out of the model-profiles value before it is stored.
95
- *
96
- * The admin form posts profiles as one value, and a newly typed key rides along
97
- * on its profile. Keys must not land in the setting — it is a `text` setting and
98
- * would be handed back to the browser in full — so they go to
99
- * `ai.model_credentials` here and are stripped from what gets saved. A profile
100
- * whose key field was left empty keeps whatever is already stored.
101
- *
102
- * Doing this in the route rather than the settings service keeps the generic
103
- * store free of AI knowledge, and keeps the admin page at a single save request.
104
- */
105
- const storeAiCredentials = async (
106
- split: NonNullable<ReturnType<typeof splitAiProfileCredentials>>,
107
- keepCredentialProfileIds: readonly string[],
108
- db: typeof sql,
109
- ): Promise<void> => {
110
- for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret, db);
111
- await pruneAiCredentials(keepCredentialProfileIds, db);
112
- };
113
-
114
- type AiSettingsMutationPlan = {
115
- errors: FieldErrors;
116
- /** Structured validation issues with stable codes; `errors` carries the same messages keyed by setting. */
117
- issues?: AiSettingsIssueWithMessage[];
118
- keepCredentialProfileIds?: string[];
119
- headerPlan?: ReturnType<typeof planAiProfileRequestHeaders>;
120
- modelProfileIds?: string[];
121
- };
122
-
123
- const valueAfterMutation = <T>(key: string, current: T, updates: Record<string, unknown>, resets: readonly string[]): T => {
124
- if (key in updates) return updates[key] as T;
125
- if (resets.includes(key)) return SETTINGS_MAP.get(key)?.default as T;
126
- return current;
127
- };
128
-
129
- const prepareAiSettingsMutation = async (
130
- updates: Record<string, unknown>,
131
- resets: readonly string[],
132
- aiSplit: ReturnType<typeof splitAiProfileCredentials>,
133
- locale: string,
134
- ): Promise<AiSettingsMutationPlan> => {
135
- const keys = [...Object.keys(updates), ...resets];
136
- if (!keys.some((key) => AI_CONFIGURATION_KEYS.has(key))) return { errors: {} };
137
-
138
- const [
139
- currentEnabled,
140
- currentDefaultModelId,
141
- currentBackgroundModelId,
142
- currentVisionModelId,
143
- currentAudioModelId,
144
- currentWorkflowModelId,
145
- currentProfilesJson,
146
- ] = await Promise.all([
147
- settings.get<boolean>(AI_ENABLED_KEY),
148
- settings.get<string>(AI_DEFAULT_MODEL_KEY),
149
- settings.get<string>(AI_BACKGROUND_MODEL_KEY),
150
- settings.get<string>(AI_VISION_MODEL_KEY),
151
- settings.get<string>(AI_AUDIO_MODEL_KEY),
152
- settings.get<string>(AI_WORKFLOW_MODEL_KEY),
153
- settings.get<string>(AI_PROFILES_KEY),
154
- ]);
155
-
156
- const currentParsed = parseAiModelProfiles(currentProfilesJson ?? "[]");
157
- const profilesUpdated = AI_PROFILES_KEY in updates;
158
- const profilesReset = resets.includes(AI_PROFILES_KEY);
159
- const nextParsed: ReturnType<typeof parseAiModelProfiles> = profilesUpdated
160
- ? parseAiModelProfiles(aiSplit?.profilesJson ?? "[]")
161
- : profilesReset
162
- ? { profiles: [] }
163
- : currentParsed;
164
- if (nextParsed.error) return { errors: nextParsed.error.fields ?? { [AI_PROFILES_KEY]: nextParsed.error.message } };
165
-
166
- const nextEnabled = Boolean(valueAfterMutation(AI_ENABLED_KEY, currentEnabled, updates, resets));
167
- const existingCredentialProfileIds = profilesUpdated || (!profilesReset && nextEnabled) ? await listAiCredentialProfileIds() : [];
168
-
169
- let keepCredentialProfileIds: string[] | undefined;
170
- if (profilesUpdated || profilesReset) {
171
- const credentialPlan = planAiProfileCredentials({
172
- currentProfiles: currentParsed.profiles,
173
- nextProfiles: nextParsed.profiles,
174
- existingCredentialProfileIds,
175
- submittedCredentialProfileIds: aiSplit?.credentials.map(({ profileId }) => profileId) ?? [],
176
- });
177
- if (credentialPlan.unsupportedCredentialProfileId) {
178
- return {
179
- errors: {
180
- [AI_PROFILES_KEY]: `Profile "${credentialPlan.unsupportedCredentialProfileId}" does not support provider credentials.`,
181
- },
182
- };
183
- }
184
- keepCredentialProfileIds = credentialPlan.keepCredentialProfileIds;
185
- }
186
-
187
- const headerPlan =
188
- profilesUpdated || profilesReset
189
- ? planAiProfileRequestHeaders({
190
- currentProfiles: currentParsed.profiles,
191
- nextProfiles: nextParsed.profiles,
192
- existingNames: profilesUpdated ? await listAiRequestHeaderNames() : {},
193
- submitted: aiSplit?.requestHeaders ?? [],
194
- })
195
- : undefined;
196
- if (headerPlan?.error) return { errors: { [AI_PROFILES_KEY]: headerPlan.error } };
197
-
198
- const issues = validateAiSettingsConfiguration({
199
- enabled: nextEnabled,
200
- defaultModelId: String(valueAfterMutation(AI_DEFAULT_MODEL_KEY, currentDefaultModelId ?? "", updates, resets)),
201
- backgroundModelId: String(valueAfterMutation(AI_BACKGROUND_MODEL_KEY, currentBackgroundModelId ?? "", updates, resets)),
202
- audioModelId: String(valueAfterMutation(AI_AUDIO_MODEL_KEY, currentAudioModelId ?? "", updates, resets)),
203
- visionModelId: String(valueAfterMutation(AI_VISION_MODEL_KEY, currentVisionModelId ?? "", updates, resets)),
204
- workflowModelId: String(valueAfterMutation(AI_WORKFLOW_MODEL_KEY, currentWorkflowModelId ?? "", updates, resets)),
205
- profiles: nextParsed.profiles,
206
- credentialProfileIds: keepCredentialProfileIds ?? existingCredentialProfileIds,
207
- });
208
- // Stored profiles come second so a profile that is being removed keeps its label.
209
- const described = describeAiSettingsIssues(issues, { locale, profiles: [...nextParsed.profiles, ...currentParsed.profiles] });
210
- return {
211
- errors: described.errors,
212
- issues: described.issues,
213
- headerPlan,
214
- keepCredentialProfileIds,
215
- modelProfileIds: profilesUpdated || profilesReset ? nextParsed.profiles.map((profile) => profile.id) : undefined,
216
- };
217
- };
218
-
219
65
  const invalidateCommittedSettings = async (keys: readonly string[]): Promise<void> => {
220
66
  try {
221
67
  await settings.invalidateSettingsCache(keys);
@@ -316,6 +162,22 @@ const app = new Hono<AuthContext>()
316
162
  );
317
163
  return c.json({ items });
318
164
  })
165
+ // The current values and versions of one settings group, so a form can continue after a save without a reload.
166
+ .get(
167
+ "/entries",
168
+ auth.requireRole("admin"),
169
+ describeRoute({
170
+ tags: ["Administration"],
171
+ summary: "Read the settings of one group with their versions",
172
+ ...requiresAdmin,
173
+ }),
174
+ v("query", z.object({ group: z.string().min(1).max(64) })),
175
+ async (c) => {
176
+ const result = await settingsService.entry.list({ filter: { group: c.req.valid("query").group }, locale: getLocale(c) });
177
+ c.header("Cache-Control", "no-store");
178
+ return c.json({ items: result.items });
179
+ },
180
+ )
319
181
  .get("/legacy", auth.requireRole("admin"), async (c) => {
320
182
  return c.json(await settingsListLegacyKeys(await liveSettingKeys()));
321
183
  })
@@ -345,6 +207,33 @@ const app = new Hono<AuthContext>()
345
207
  return c.json({ message: "Failed to test Gotenberg PDF rendering" }, 500);
346
208
  }
347
209
  })
210
+ .post(
211
+ "/test-storage",
212
+ auth.requireRole("admin"),
213
+ describeRoute({
214
+ tags: ["Administration"],
215
+ summary: "Test the saved Garage storage connection",
216
+ ...requiresAdmin,
217
+ responses: { 200: jsonResponse(z.object({ ok: z.literal(true) }), "Object written, read and deleted") },
218
+ }),
219
+ async (c) => {
220
+ c.header("Cache-Control", "no-store");
221
+ try {
222
+ await testObjectStoreConnection();
223
+ return c.json({ ok: true as const });
224
+ } catch (error) {
225
+ return c.json(
226
+ {
227
+ message:
228
+ error instanceof ObjectStoreConfigurationError || error instanceof ObjectStoreConnectionError
229
+ ? error.message
230
+ : "Storage test failed. Check the saved settings, Garage availability and bucket permissions, then retry.",
231
+ },
232
+ 502,
233
+ );
234
+ }
235
+ },
236
+ )
348
237
  .post("/test-freeipa", auth.requireRole("admin"), async (c) => {
349
238
  const result = await testFreeIpaConnection();
350
239
  if (result.ok) return c.json({ ok: true });
@@ -368,12 +257,12 @@ const app = new Hono<AuthContext>()
368
257
  }),
369
258
  v("json", BulkSaveSchema),
370
259
  async (c) => {
371
- const { updates, resets, replaceUnreadable } = c.req.valid("json");
260
+ const { updates, resets, replaceUnreadable, expectedUpdatedAt } = c.req.valid("json");
372
261
  const replace = new Set(replaceUnreadable);
373
262
  const updateKeys = Object.keys(updates);
374
263
  const keys = [...updateKeys, ...resets];
375
264
 
376
- if (keys.length === 0 && replace.size === 0) {
265
+ if (keys.length === 0 && replace.size === 0 && Object.keys(expectedUpdatedAt).length === 0) {
377
266
  return c.body(null, 204);
378
267
  }
379
268
 
@@ -383,6 +272,9 @@ const app = new Hono<AuthContext>()
383
272
  ownership[key] = `Unknown setting "${key}"`;
384
273
  }
385
274
  }
275
+ for (const key of Object.keys(expectedUpdatedAt)) {
276
+ if (!keys.includes(key)) ownership[key] = `Setting "${key}" must be included in updates or resets`;
277
+ }
386
278
  for (const key of replace) {
387
279
  if (!Object.hasOwn(updates, key)) ownership[key] = `Setting "${key}" must be included in updates to replace its unreadable value`;
388
280
  }
@@ -405,38 +297,7 @@ const app = new Hono<AuthContext>()
405
297
  return c.json({ message: settingsNotSavedMessage(getLocale(c)), errors: unreadable, code: "setting_unreadable" }, 409);
406
298
  }
407
299
 
408
- // Strip AI provider keys before anything is validated or written, so the
409
- // value that gets checked is the value that gets stored.
410
- const aiSplit = AI_PROFILES_KEY in updates ? splitAiProfileCredentials(updates[AI_PROFILES_KEY]) : null;
411
- if (AI_PROFILES_KEY in updates && !aiSplit) {
412
- // Unsplittable means the keys could not be separated out, and storing the
413
- // value as posted would put them back in a setting the browser reads.
414
- return c.json({ message: "Invalid values", errors: { [AI_PROFILES_KEY]: "Model profiles must be a JSON array" } }, 400);
415
- }
416
- let accessChanges: AiModelAccessChange[] = [];
417
- if (aiSplit) {
418
- try {
419
- aiSplit.profilesJson = prepareAiModelProfileImages(aiSplit.profilesJson);
420
- } catch (error) {
421
- if (error instanceof ImageMetadataError)
422
- return c.json({ message: error.message, code: error.code, errors: { [AI_PROFILES_KEY]: error.message } }, 422);
423
- throw error;
424
- }
425
- try {
426
- const split = splitAiModelAccess(aiSplit.profilesJson);
427
- aiSplit.profilesJson = split.profilesJson;
428
- accessChanges = split.changes;
429
- } catch {
430
- return c.json(
431
- {
432
- message: "Invalid model permissions",
433
- errors: { [AI_PROFILES_KEY]: "Invalid Assistant model permissions. Reload the settings and try again." },
434
- },
435
- 400,
436
- );
437
- }
438
- }
439
- const finalValues: Record<string, unknown> = aiSplit ? { ...updates, [AI_PROFILES_KEY]: aiSplit.profilesJson } : updates;
300
+ const finalValues = updates;
440
301
 
441
302
  // Validate everything up front. settings.set validates as it writes, so a
442
303
  // single bad field used to leave the earlier keys already saved — with the
@@ -461,26 +322,17 @@ const app = new Hono<AuthContext>()
461
322
  return c.json({ message: "Invalid values", errors: invalid }, 400);
462
323
  }
463
324
 
464
- let aiPlan: AiSettingsMutationPlan;
465
- try {
466
- aiPlan = await prepareAiSettingsMutation(validatedValues, resets, aiSplit, getLocale(c));
467
- } catch (error) {
468
- console.error("[settings] failed to validate the prospective AI configuration", error);
469
- return c.json({ message: "Failed to validate AI settings" }, 500);
470
- }
471
- if (Object.keys(aiPlan.errors).length > 0) {
472
- return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
473
- }
474
-
475
325
  const fieldErrors: FieldErrors = {};
476
326
  try {
477
327
  // Every settings and AI credential write runs on the transaction's own
478
328
  // connection, so a failure rolls the whole save back.
479
329
  await sql.begin(async (tx) => {
330
+ await assertSettingVersions(expectedUpdatedAt, tx);
480
331
  await assertGeoBudgetPair(tx, validatedValues, resets);
481
332
  for (const [key, value] of Object.entries(validatedValues)) {
482
333
  try {
483
334
  await settings.set(key, value, tx, { replaceUnreadable: replace.has(key) });
335
+ await tx`UPDATE settings.entries SET updated_by=${c.get("user").id}::uuid WHERE key=${key}`;
484
336
  } catch (error) {
485
337
  fieldErrors[key] = `Failed to save ${key}`;
486
338
  throw error;
@@ -512,15 +364,6 @@ const app = new Hono<AuthContext>()
512
364
  },
513
365
  tx,
514
366
  );
515
- if (aiSplit && aiPlan.keepCredentialProfileIds) {
516
- await storeAiCredentials(aiSplit, aiPlan.keepCredentialProfileIds, tx);
517
- } else if (aiPlan.keepCredentialProfileIds) {
518
- await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
519
- }
520
- if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
521
- if (aiPlan.modelProfileIds) {
522
- await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
523
- }
524
367
  });
525
368
  } catch (error) {
526
369
  if (error instanceof GeoBudgetSettingError) return error.getResponse();
@@ -535,12 +378,11 @@ const app = new Hono<AuthContext>()
535
378
  );
536
379
  }
537
380
  if (error instanceof ImageMetadataError) return c.json({ message: error.message, code: error.code, errors: fieldErrors }, 422);
538
- if (error instanceof AiModelAccessConflict || error instanceof AiModelAccessInvalid) {
381
+ if (error instanceof ConfigConflict)
539
382
  return c.json(
540
- { message: error.message, errors: { [AI_PROFILES_KEY]: error.message } },
541
- error instanceof AiModelAccessConflict ? 409 : 400,
383
+ { code: error.code, current: error.current, key: error.key, changedBy: error.changedBy, changedAt: error.changedAt },
384
+ 409,
542
385
  );
543
- }
544
386
  console.error("[settings] failed to commit settings update", error);
545
387
  return c.json(
546
388
  {
@@ -565,17 +407,6 @@ const app = new Hono<AuthContext>()
565
407
  }
566
408
  if (key === GEO_KEY) return c.json({ message: GEO_KEY_DEDICATED, errors: { [GEO_KEY]: GEO_KEY_DEDICATED } }, 400);
567
409
 
568
- let aiPlan: AiSettingsMutationPlan;
569
- try {
570
- aiPlan = await prepareAiSettingsMutation({}, [key], null, getLocale(c));
571
- } catch (error) {
572
- console.error("[settings] failed to validate the prospective AI configuration", error);
573
- return c.json({ message: "Failed to validate AI settings" }, 500);
574
- }
575
- if (Object.keys(aiPlan.errors).length > 0) {
576
- return c.json({ message: aiSettingsNotSavedMessage(getLocale(c)), errors: aiPlan.errors, issues: aiPlan.issues ?? [] }, 400);
577
- }
578
-
579
410
  try {
580
411
  await sql.begin(async (tx) => {
581
412
  await assertGeoBudgetPair(tx, {}, [key]);
@@ -591,13 +422,6 @@ const app = new Hono<AuthContext>()
591
422
  },
592
423
  tx,
593
424
  );
594
- if (aiPlan.keepCredentialProfileIds) {
595
- await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
596
- }
597
- if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
598
- if (aiPlan.modelProfileIds) {
599
- await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, [], tx);
600
- }
601
425
  });
602
426
  } catch (error) {
603
427
  if (error instanceof GeoBudgetSettingError || error instanceof settings.SettingUnreadableError) return error.getResponse();
@@ -27,9 +27,10 @@ import {
27
27
  type createAppApprovalService,
28
28
  } from "../services/app-approval";
29
29
  import { type NewBrowserSignInNotifier, readBrowserCookie } from "../services/known-browsers";
30
- import { SessionAppDeviceInactiveError, SessionEpochChangedError } from "../services/session";
30
+ import { SessionAccountMismatchError, SessionAppDeviceInactiveError, SessionEpochChangedError } from "../services/session";
31
31
  import { appNameIn } from "../ssr/runtime";
32
32
  import { createDeviceActions } from "./app-approval-actions";
33
+ import { accountMismatchResponse } from "./auth/account-mismatch";
33
34
 
34
35
  const PairingIdSchema = z.object({ pairingId: z.string().uuid() }).strict();
35
36
  const DevicePageSchema = z.object({ after: z.string().uuid().optional() }).strict();
@@ -56,6 +57,7 @@ export const createAppApprovalRoutes = (
56
57
  .use("/device", noTransform())
57
58
  .use("/device/*", noTransform())
58
59
  .onError((error, c) => {
60
+ if (error instanceof SessionAccountMismatchError) return accountMismatchResponse(c);
59
61
  if (error instanceof AppApprovalError) return c.json({ code: error.code, message: error.code }, error.status);
60
62
  if (error instanceof HTTPException && error.status < 500) return c.json({ code: "INVALID_REQUEST" }, error.status);
61
63
  // Never include SQL, pairing secrets, signature bodies or keys in errors.
@@ -0,0 +1,51 @@
1
+ import { Hono } from "hono";
2
+ import { bodyLimit } from "hono/body-limit";
3
+ import { z } from "zod";
4
+ import type { CompiledCapabilities } from "../_internal/capabilities";
5
+ import { AppEventEnvelopeSchema, appEventSubscriptionId } from "../contracts/app-events";
6
+ import { deliverAppEvent } from "../events/app-events";
7
+ import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
8
+ import { verifyAppEventInvocation } from "../services/identity/invocation-token";
9
+
10
+ const DeliveryInput = z
11
+ .object({ envelope: AppEventEnvelopeSchema, subscriptionId: z.string().max(400), attempt: z.number().int().min(1).max(8) })
12
+ .strict();
13
+ export const createAppEventDeliveryRoutes = (compiled: CompiledCapabilities, verify = verifyAppEventInvocation) =>
14
+ new Hono().use(bodyLimit({ maxSize: 8192 })).post("/deliver", async (c) => {
15
+ const payload: unknown = await c.req.json().catch(() => null);
16
+ const parsed = DeliveryInput.safeParse(payload);
17
+ if (!parsed.success) return c.json({ code: "schema_mismatch" }, 422);
18
+ const { envelope, subscriptionId, attempt } = parsed.data;
19
+ const token = c.req.header("authorization")?.match(/^Bearer (\S+)$/i)?.[1];
20
+ if (!token || !(await verify(token, { appId: compiled.manifest.appId, subscriptionId, eventId: envelope.id, payload })))
21
+ return c.json({ code: "unauthorized" }, 401);
22
+ const sub = compiled.subscribes.get(envelope.type);
23
+ if (
24
+ !sub ||
25
+ sub.manifest.version !== envelope.version ||
26
+ subscriptionId !== appEventSubscriptionId(compiled.manifest.appId, envelope.type, envelope.version)
27
+ )
28
+ return c.json({ code: "subscription_removed" }, 410);
29
+ if (envelope.place) return c.json({ code: "resource_events_unsupported" }, 410);
30
+ if (!sub.definition.schema.safeParse(envelope.data).success) return c.json({ code: "schema_mismatch" }, 422);
31
+ const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(30000)]);
32
+ let onAbort: () => void = () => {};
33
+ const aborted = new Promise<never>((_, reject) => {
34
+ onAbort = () => reject(signal.reason);
35
+ signal.addEventListener("abort", onAbort, { once: true });
36
+ if (signal.aborted) onAbort();
37
+ });
38
+ setRequestTimeout(c, 40);
39
+ try {
40
+ await Promise.race([
41
+ deliverAppEvent({ compiled, subscriptionId, envelope, context: { subscriptionId, eventId: envelope.id, attempt, signal } }),
42
+ aborted,
43
+ ]);
44
+ return c.json({ done: true });
45
+ } catch {
46
+ return c.json({ code: signal.aborted && signal.reason?.name === "TimeoutError" ? "handler_timeout" : "handler_failed" }, 503);
47
+ } finally {
48
+ signal.removeEventListener("abort", onAbort);
49
+ setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
50
+ }
51
+ });
@@ -0,0 +1,123 @@
1
+ import { sql } from "bun";
2
+ import { Hono, type MiddlewareHandler } from "hono";
3
+ import { describeRoute } from "hono-openapi";
4
+ import { z } from "zod";
5
+ import { AppDetailsSchema } from "../contracts/app-details";
6
+ import { AppEventEnvelopeSchema } from "../contracts/app-events";
7
+ import { CapabilityQualifiedIdSchema } from "../contracts/capabilities";
8
+ import { type AuthContext, auth, expectUserBackedActor, getLocale, v } from "../server";
9
+ import { AppAccessManagementError } from "../services/app-access-management";
10
+ import { appDetails } from "../services/app-details";
11
+ import { appEventShape, appEventShapeId, recordedAppEventShape } from "../services/app-event-shapes";
12
+ import { appEventBroker } from "../services/app-events";
13
+
14
+ const App = z.string().regex(/^[a-z][a-z0-9-]{0,79}$/);
15
+ const Filter = z.object({
16
+ app: App.optional(),
17
+ state: z.enum(["pending", "retrying", "done", "failed", "cancelled"]).optional(),
18
+ eventType: CapabilityQualifiedIdSchema.optional(),
19
+ offset: z.coerce.number().int().min(0).default(0),
20
+ limit: z.coerce.number().int().min(1).max(100).default(50),
21
+ });
22
+ export const createAppEventRoutes = (
23
+ authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
24
+ service = appEventBroker,
25
+ details = appDetails,
26
+ ) =>
27
+ new Hono<AuthContext>()
28
+ .use(authenticate)
29
+ .use(auth.requireOAuthScope("admin"))
30
+ .get(
31
+ "/apps/:appId",
32
+ describeRoute({ tags: ["App events"], summary: "Inspect one application's access, events and notifications" }),
33
+ v("param", z.object({ appId: App })),
34
+ async (c) => {
35
+ try {
36
+ const result = await details(c.req.valid("param").appId, { authority: auth.getAuthority(c), locale: getLocale(c) });
37
+ return result ? c.json(AppDetailsSchema.parse(result)) : c.json({ code: "app_not_found" }, 404);
38
+ } catch (error) {
39
+ if (error instanceof AppAccessManagementError) return c.json({ code: error.code, message: error.message }, error.status);
40
+ throw error;
41
+ }
42
+ },
43
+ )
44
+ .get("/catalog", describeRoute({ tags: ["App events"], summary: "List declared event types and subscribers" }), async (c) =>
45
+ c.json(await service.catalog(getLocale(c))),
46
+ )
47
+ .get(
48
+ "/subscriptions",
49
+ describeRoute({ tags: ["App events"], summary: "Inspect declared subscriptions and delivery counts" }),
50
+ v("query", z.object({ app: App.optional() })),
51
+ async (c) => c.json(await service.subscriptions(c.req.valid("query").app)),
52
+ )
53
+ .get("/deliveries", describeRoute({ tags: ["App events"], summary: "List app event deliveries" }), v("query", Filter), async (c) =>
54
+ c.json(await service.deliveries(c.req.valid("query"))),
55
+ )
56
+ .post(
57
+ "/deliveries/:id/redeliver",
58
+ describeRoute({ tags: ["App events"], summary: "Retry one delivery preserving its event ID" }),
59
+ v("param", z.object({ id: z.uuid() })),
60
+ async (c) => {
61
+ const user = expectUserBackedActor(c);
62
+ const result = await service.redeliver(c.req.valid("param").id, {
63
+ userId: user.id,
64
+ uid: user.uid,
65
+ provider: user.provider,
66
+ roles: user.roles,
67
+ });
68
+ return result ? c.json(result) : c.json({ code: "not_redeliverable" }, 409);
69
+ },
70
+ )
71
+ .post("/status/acknowledge", describeRoute({ tags: ["App events"], summary: "Acknowledge a reconciled retention gap" }), async (c) => {
72
+ const user = expectUserBackedActor(c);
73
+ const result = await service.acknowledgeGap({ userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles });
74
+ return result ? c.json(result) : c.json({ code: "no_gap_detected" }, 409);
75
+ })
76
+ .get(
77
+ "/status",
78
+ describeRoute({ tags: ["App events"], summary: "Read broker progress, retention gaps and rejected-event metadata" }),
79
+ async (c) => c.json(await service.status()),
80
+ )
81
+ .get(
82
+ "/record/:eventType",
83
+ describeRoute({ tags: ["App events"], summary: "Record schema-only JSONL fixtures; no stored event values are returned" }),
84
+ v("param", z.object({ eventType: CapabilityQualifiedIdSchema })),
85
+ v("query", z.object({ shapeOnly: z.literal("true"), limit: z.coerce.number().int().min(1).max(100).default(10) })),
86
+ async (c) => {
87
+ const { eventType } = c.req.valid("param");
88
+ const { limit } = c.req.valid("query");
89
+ const catalog = await service.catalog();
90
+ const rows = await sql<
91
+ { version: number; envelope: { data: unknown } }[]
92
+ >`SELECT version, envelope FROM events.app_events WHERE type=${eventType} AND state='accepted' ORDER BY seq DESC LIMIT ${limit}`;
93
+ try {
94
+ const shapes = new Map<string, { version: number; shape: ReturnType<typeof recordedAppEventShape> }>();
95
+ for (const row of rows) {
96
+ const shape = recordedAppEventShape(row.envelope.data);
97
+ shapes.set(JSON.stringify([row.version, shape]), { version: row.version, shape });
98
+ }
99
+ const lines = [...shapes.values()].map((row) => {
100
+ const declaration = catalog.events.find((event) => event.type === eventType && event.version === row.version);
101
+ if (!declaration) throw new Error("shape_unavailable");
102
+ return JSON.stringify(
103
+ AppEventEnvelopeSchema.parse({
104
+ id: appEventShapeId(eventType, row.version, row.shape),
105
+ type: eventType,
106
+ version: row.version,
107
+ source: eventType.split(".")[0],
108
+ time: "2000-01-01T00:00:00.000Z",
109
+ subject: { type: declaration.subject, id: "00000000-0000-4000-8000-000000000001" },
110
+ ...(declaration.place ? { place: { type: declaration.place, id: "00000000-0000-4000-8000-000000000001" } } : {}),
111
+ data: appEventShape(declaration.dataSchema, 0, row.shape),
112
+ }),
113
+ );
114
+ });
115
+ return c.body(lines.length ? lines.join("\n") + "\n" : "", 200, {
116
+ "content-type": "application/x-ndjson",
117
+ "cache-control": "no-store",
118
+ });
119
+ } catch {
120
+ return c.json({ code: "shape_unavailable", message: "A deterministic fixture cannot be generated for this declaration." }, 422);
121
+ }
122
+ },
123
+ );
@@ -0,0 +1,48 @@
1
+ import { type Context, Hono } from "hono";
2
+ import { bindAppAccessRequest } from "../_internal/app-access-request";
3
+ import { hasLocalResourceAppAccess } from "../_internal/app-access-resources";
4
+ import { getApp } from "../_internal/registry";
5
+ import type { AppMeta } from "../contracts/app";
6
+ import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
7
+ import { rateLimit } from "../server/middleware/rate-limit";
8
+ import { canUseApp } from "../services/app-access";
9
+ import { resolveInvocationAuthority } from "../services/identity/invocation-actor";
10
+ import { isInvocationJwtCandidate, verifyAppAccessProbe } from "../services/identity/invocation-token";
11
+
12
+ /** Framework-only endpoint, mounted before the app gate to avoid recursive entry checks. */
13
+ export const createResourceAppAccessRoutes = (app: Pick<AppMeta, "id" | "access">) => {
14
+ const router = new Hono<AuthContext>();
15
+ if (app.access?.kind === "resources")
16
+ router.post("/check", auth.readOnly, async (c) => {
17
+ c.header("Cache-Control", "private, no-store");
18
+ const token = c.req.header("authorization")?.match(/^Bearer (\S+)$/i)?.[1];
19
+ if (!token || !isInvocationJwtCandidate(token)) return c.body(null, 401);
20
+ const claims = await verifyAppAccessProbe(token, app.id);
21
+ if (!claims) return c.body(null, 401);
22
+ if (!hasLocalResourceAppAccess(app.id)) return c.body(null, 403);
23
+ const authority = await resolveInvocationAuthority(claims);
24
+ if (!authority) return c.body(null, 401);
25
+ return c.body(null, (await canUseApp(bindAppAccessRequest(authority, c.req.raw), app)) ? 204 : 403);
26
+ });
27
+ if (app.id === "core")
28
+ router.post(
29
+ "/apps/:appId",
30
+ auth.readOnly,
31
+ async (c, next) => {
32
+ c.header("Cache-Control", "private, no-store");
33
+ const token = c.req.header("authorization")?.match(/^Bearer (\S+)$/i)?.[1];
34
+ if (token && isInvocationJwtCandidate(token)) return c.body(null, 401);
35
+ await next();
36
+ },
37
+ auth.requireRole("authenticated"),
38
+ rateLimit(),
39
+ (c) => answerCoreProbe(c, auth.getAuthority(c)),
40
+ );
41
+ return router;
42
+ };
43
+
44
+ const answerCoreProbe = async (c: Context<AuthContext>, authority: RequestAuthority): Promise<Response> => {
45
+ const target = await getApp(c.req.param("appId") ?? "");
46
+ if (!target || target.access?.kind !== "resources") return c.body(null, 404);
47
+ return c.body(null, (await canUseApp(bindAppAccessRequest(authority, c.req.raw), target)) ? 204 : 403);
48
+ };