@k2b/cloud 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,92 @@
1
+ import type { TopicDeadLetter, TopicInventoryEntry } from "@k2b/sync";
2
+ import { type SQL, sql } from "bun";
3
+ import { lazySync } from "../_internal/process-sync";
4
+ import { getCapability, listCapabilities } from "../_internal/registry";
5
+ import type { AppEventEnvelope } from "../contracts/app-events";
6
+ import type { CapabilityRegistryEntry } from "../contracts/registry";
7
+ import { appEventTopic } from "../events/app-event-delivery";
8
+ import { createEventAcceptance } from "./app-events/accept";
9
+ import { createEventAdministration } from "./app-events/admin";
10
+ import { createDeclarationSync } from "./app-events/declarations";
11
+ import { createEventDelivery } from "./app-events/deliver";
12
+ import type { signAppEventInvocation } from "./identity/invocation-token";
13
+ import { logger } from "./logging";
14
+
15
+ export { APP_EVENT_DELIVERY_ATTEMPTS, APP_EVENT_DELIVERY_BACKOFF_MS } from "./app-events/deliver";
16
+
17
+ const log = logger("app-events:broker");
18
+ const topicInventory = lazySync((sync) => () => sync.listTopics({ idPrefix: "cloud:app-events" }));
19
+ type BrokerDependencies = {
20
+ db?: SQL;
21
+ registry?: () => Promise<CapabilityRegistryEntry[]>;
22
+ getApp?: typeof getCapability;
23
+ fetch?: typeof fetch;
24
+ sign?: typeof signAppEventInvocation;
25
+ deadLetters?: (after?: string) => Promise<TopicDeadLetter<AppEventEnvelope>[]>;
26
+ streams?: () => AsyncIterable<Pick<TopicInventoryEntry, "id" | "firstSequence">>;
27
+ };
28
+ const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).slice(0, 1000);
29
+
30
+ export const createAppEventBroker = (dependencies: BrokerDependencies = {}) => {
31
+ const db = dependencies.db ?? sql;
32
+ const registry = dependencies.registry ?? listCapabilities;
33
+ const declarations = createDeclarationSync(db, registry);
34
+ const admin = createEventAdministration(db, {
35
+ registry,
36
+ deadLetters: dependencies.deadLetters ?? ((after) => appEventTopic().deadLetters.list({ limit: 100, after })),
37
+ streams: dependencies.streams ?? (() => topicInventory()()),
38
+ });
39
+ const acceptance = createEventAcceptance(db, declarations.reconcileSubscriptions);
40
+ const delivery = createEventDelivery(db, dependencies.getApp ?? getCapability, declarations.reconcileSubscriptions, dependencies);
41
+ return { ...declarations, ...acceptance, ...delivery, ...admin };
42
+ };
43
+
44
+ export const appEventBroker = createAppEventBroker();
45
+ export const startAppEventBroker = async (): Promise<() => Promise<void>> => {
46
+ const worker = await appEventTopic().process(
47
+ {
48
+ consumer: "core-app-events-v1",
49
+ start: "earliest",
50
+ concurrency: 1,
51
+ recoverDeadLetters: true,
52
+ delivery: { maxAttempts: 8, backoffMs: [1000, 5000, 30000, 120000, 600000, 1800000, 3600000], maxInFlight: 1 },
53
+ },
54
+ async (event) => {
55
+ try {
56
+ await appEventBroker.accept(event.data, event.sequence, event.attempt);
57
+ } catch (error) {
58
+ if (event.attempt >= 8) {
59
+ await appEventBroker.markSeen(event.sequence);
60
+ }
61
+ throw error;
62
+ }
63
+ },
64
+ );
65
+ let stopping = false;
66
+ let active: Promise<void> | null = null;
67
+ let tickCount = 0;
68
+ const run = () => {
69
+ if (stopping || active) return;
70
+ active = (async () => {
71
+ await appEventBroker.work();
72
+ if (++tickCount % 60 === 0) {
73
+ await appEventBroker.checkGap();
74
+ await appEventBroker.retain();
75
+ }
76
+ })()
77
+ .catch((error) => log.error("App event broker pass failed", { error: message(error) }))
78
+ .finally(() => {
79
+ active = null;
80
+ });
81
+ };
82
+ const timer = setInterval(run, 1000);
83
+ timer.unref();
84
+ run();
85
+ return async () => {
86
+ stopping = true;
87
+ clearInterval(timer);
88
+ worker.stop();
89
+ await worker.drain();
90
+ await active;
91
+ };
92
+ };
@@ -0,0 +1,11 @@
1
+ export class ConfigConflict extends Error {
2
+ readonly code = "conflict";
3
+ constructor(
4
+ readonly current: unknown,
5
+ readonly changedBy: { id: string; name: string } | null,
6
+ readonly changedAt: string,
7
+ readonly key?: string,
8
+ ) {
9
+ super("This configuration changed. Read the current version before saving.");
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ export { coreUserEvents, coreUserEventTypes } from "../events/core-user-created";
@@ -1,6 +1,8 @@
1
1
  import { X509Certificate } from "node:crypto";
2
+ import type { SQL } from "bun";
2
3
  import { setFreeIpaTlsResolver } from "../server/services/freeipa/tls";
3
4
  import { coreSettings } from "./settings/api";
5
+ import { readKeyState, SettingUnreadableError } from "./settings/store";
4
6
 
5
7
  const MAX_CA_CERT_BYTES = 256 * 1024;
6
8
  const PEM_CERTIFICATE_PATTERN = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
@@ -82,6 +84,13 @@ const normalizeStringList = (value: unknown, fallback: string[] = []): string[]
82
84
  return normalized.length > 0 ? normalized : fallback;
83
85
  };
84
86
 
87
+ /** Read the role mapping on the caller's connection during authorization. */
88
+ export const getFreeIpaAdminGroups = async (db: SQL): Promise<string[]> => {
89
+ const state = await readKeyState("freeipa.groups.admin", db);
90
+ if (state.stored === "unreadable") throw new SettingUnreadableError("freeipa.groups.admin");
91
+ return normalizeStringList(state.value, ["admins"]);
92
+ };
93
+
85
94
  /**
86
95
  * Read the full FreeIPA config snapshot from settings (Redis cache-aside +
87
96
  * Postgres fallback). Always returns within Redis-TTL fresh data — no hidden
@@ -146,7 +146,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
146
146
  const config = await readGeoConfig();
147
147
  const periods = geoPeriods(now(), config.timezone);
148
148
  const plan = config.plan === "custom" ? { dailyCredits: config.customDailyCredits } : GEO_PLANS[config.plan];
149
- const [[month], [today], shares] = await Promise.all([
149
+ const [[month], [today], shares, [overruns]] = await Promise.all([
150
150
  db<
151
151
  { used: number }[]
152
152
  >`SELECT used::float8 AS used FROM geo.usage WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
@@ -155,6 +155,8 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
155
155
  FROM geo.usage WHERE scope = 'person' AND period = ${periods.person.period}`,
156
156
  db<{ function_group: string; used: number }[]>`
157
157
  SELECT function_group, used::float8 AS used FROM geo.shares WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
158
+ db<{ count: number }[]>`SELECT count(*)::int AS count FROM audit.events
159
+ WHERE action = 'core.geo.route_cost_overrun' AND metadata->>'period' = ${periods.cloud.period}`,
158
160
  ]);
159
161
  return {
160
162
  month: {
@@ -174,6 +176,7 @@ export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () =>
174
176
  credits: (shares.find((row) => row.function_group === group)?.used ?? 0) / 1000,
175
177
  })),
176
178
  peopleAtLimit: today?.at_limit ?? 0,
179
+ routeCostOverruns: overruns?.count ?? 0,
177
180
  timezone: config.timezone,
178
181
  };
179
182
  };
@@ -1,4 +1,5 @@
1
1
  import { type SQL, sql } from "bun";
2
+ import type { AppPwaActor } from "../../app-pwa/contracts";
2
3
  import type { AccessSubject, CapabilityExecutionContext } from "../../contracts";
3
4
  import { audit } from "../audit";
4
5
  import { GeoError } from "./errors";
@@ -52,9 +53,21 @@ export const createGeoLedger = (db: SQL = sql) => ({
52
53
  for (const row of rows)
53
54
  await tx`INSERT INTO geo.usage(scope,subject,period) VALUES(${row.scope},${row.subject},${row.period}) ON CONFLICT DO NOTHING`;
54
55
  for (const row of rows) {
56
+ // Keep the refusal's available/shortfall numbers in the same row-lock snapshot as admission.
57
+ const [balance] = await tx<{ used: number }[]>`SELECT used::float8 AS used FROM geo.usage
58
+ WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period} FOR UPDATE`;
55
59
  const changed = await tx`UPDATE geo.usage SET used=used+${cost} WHERE scope=${row.scope} AND subject=${row.subject}
56
60
  AND period=${row.period} AND used+${cost}<=${row.limit} RETURNING used`;
57
- if (!changed.length) throw new GeoError("GEO_BUDGET_EXHAUSTED", { scope: row.scope, resetsAt: row.resetsAt });
61
+ if (!changed.length) {
62
+ const available = row.limit - (balance?.used ?? 0);
63
+ throw new GeoError("GEO_BUDGET_EXHAUSTED", {
64
+ scope: row.scope,
65
+ resetsAt: row.resetsAt,
66
+ requiredCredits: cost / 1000,
67
+ availableCredits: available / 1000,
68
+ shortfallCredits: (cost - available) / 1000,
69
+ });
70
+ }
58
71
  }
59
72
  for (const row of rows)
60
73
  await tx`INSERT INTO geo.shares(scope,subject,period,function_group,used)
@@ -88,32 +101,52 @@ export const createGeoLedger = (db: SQL = sql) => ({
88
101
  return { cloud: balances[0]!, person: balances[1]! };
89
102
  },
90
103
  async recordTransition(
91
- event: "provider_key_rejected" | "monthly_budget_exhausted",
104
+ event: "provider_key_rejected" | "monthly_budget_exhausted" | "route_cost_overrun",
92
105
  timezone: string,
93
- context: Pick<CapabilityExecutionContext, "actor"> & Partial<Pick<CapabilityExecutionContext, "requestId">>,
106
+ context: { actor: CapabilityExecutionContext["actor"] | AppPwaActor; requestId?: string },
94
107
  now: Date,
108
+ metadata: Record<string, number> = {},
95
109
  ) {
96
110
  const period = geoPeriods(now, timezone).cloud.period;
97
111
  await db.begin(async (tx) => {
98
112
  const inserted =
99
- await tx`INSERT INTO geo.audit_transitions(event,period) VALUES(${event},${period}) ON CONFLICT DO NOTHING RETURNING event`;
113
+ event === "route_cost_overrun"
114
+ ? [true]
115
+ : await tx`INSERT INTO geo.audit_transitions(event,period) VALUES(${event},${period}) ON CONFLICT DO NOTHING RETURNING event`;
100
116
  if (!inserted.length) return;
101
117
  const actor =
102
- context.actor.kind === "user"
103
- ? {
104
- userId: context.actor.user.id,
105
- uid: context.actor.user.uid,
106
- provider: context.actor.user.provider,
107
- roles: context.actor.user.roles,
108
- }
109
- : { uid: `service-account:${context.actor.serviceAccount.id}`, provider: "service_account", roles: context.actor.scopes };
118
+ context.actor.kind === "device" || context.actor.kind === "visit"
119
+ ? null
120
+ : context.actor.kind === "user"
121
+ ? {
122
+ userId: context.actor.user.id,
123
+ uid: context.actor.user.uid,
124
+ provider: context.actor.user.provider,
125
+ roles: context.actor.user.roles,
126
+ }
127
+ : { uid: `service-account:${context.actor.serviceAccount.id}`, provider: "service_account", roles: context.actor.scopes };
110
128
  await audit.record(
111
129
  {
112
130
  action: `core.geo.${event}`,
113
- outcome: event === "provider_key_rejected" ? "failed" : "denied",
131
+ outcome: event === "monthly_budget_exhausted" ? "denied" : "failed",
114
132
  actor,
115
133
  requestId: context.requestId,
116
- metadata: { period },
134
+ metadata: {
135
+ period,
136
+ ...metadata,
137
+ ...(context.actor.kind === "device" || context.actor.kind === "visit"
138
+ ? {
139
+ appId: context.actor.appId,
140
+ subject: { type: "user", userId: context.actor.ownerUserId },
141
+ via: {
142
+ kind: context.actor.kind,
143
+ appId: context.actor.appId,
144
+ deviceId: context.actor.deviceId,
145
+ ...(context.actor.kind === "visit" ? { visitId: context.actor.visitId } : {}),
146
+ },
147
+ }
148
+ : {}),
149
+ },
117
150
  },
118
151
  tx,
119
152
  );
@@ -16,11 +16,16 @@ export type GeoRouteChainDependencies = {
16
16
  bucket: ReturnType<typeof createGeoBucket>;
17
17
  ledger: ReturnType<typeof createGeoLedger>;
18
18
  now: () => Date;
19
- recordTransition: (event: "monthly_budget_exhausted" | "provider_key_rejected", timezone: string, context: GeoContext) => Promise<void>;
19
+ recordTransition: (
20
+ event: "monthly_budget_exhausted" | "provider_key_rejected" | "route_cost_overrun",
21
+ timezone: string,
22
+ context: GeoContext,
23
+ metadata?: Record<string, number>,
24
+ ) => Promise<boolean | void>;
20
25
  };
21
- export type GeoRouteCallPricing = { pairs: number; avoids: number; optimize: boolean; distanceAllowanceMeters: number };
26
+ export type GeoRouteCallPricing = { pairs: number; avoids: number; optimize: boolean; estimatedDistanceMeters: number };
22
27
  export const reserveRouteCall = (pricing: GeoRouteCallPricing) =>
23
- geoRouteReferenceCost({ ...pricing, distanceMeters: pricing.pairs * pricing.distanceAllowanceMeters });
28
+ geoRouteReferenceCost({ ...pricing, distanceMeters: pricing.estimatedDistanceMeters });
24
29
  export type GeoRouteChainCall = <V>(
25
30
  fn: GeoFunction,
26
31
  input: unknown,
@@ -53,26 +58,28 @@ export const runGeoRouteChain = async <T>(
53
58
  let known = 0,
54
59
  unknown = 0;
55
60
  let result: T | undefined, failure: unknown;
61
+ let overrun:
62
+ | { estimatedDistanceMeters: number; actualDistanceMeters: number; reservedCredits: number; actualCredits: number }
63
+ | undefined;
56
64
  try {
57
65
  result = await work(async (fn, input, schema, request, pricing, distance) => {
58
66
  context.signal?.throwIfAborted();
59
67
  const key = geoCacheKey(fn, input, context.locale ?? "en");
60
68
  const hit = await cache.get(key, schema);
61
- if (hit !== null) {
62
- // A cached response still must respect this call's reference allowance.
63
- if (pricing && distance && distance(hit) > pricing.pairs * pricing.distanceAllowanceMeters)
64
- throw new GeoError("GEO_COST_OVERRUN", {
65
- distanceAllowanceMeters: pricing.distanceAllowanceMeters,
66
- actualDistanceMeters: distance(hit),
67
- });
68
- return hit;
69
- }
69
+ if (hit !== null) return hit; // Cached provider results incur no new cost.
70
70
  const allocated = pricing ? reserveRouteCall(pricing) : geoReferenceCost({ fn });
71
71
  const debitKnown = (meters: number) => {
72
72
  try {
73
73
  const cost = pricing ? geoRouteReferenceCost({ ...pricing, distanceMeters: meters }) : allocated;
74
74
  if (!Number.isSafeInteger(known + unknown + cost)) throw new Error("Unrepresentable reference cost");
75
75
  known += cost;
76
+ if (pricing && cost > allocated)
77
+ overrun = {
78
+ estimatedDistanceMeters: pricing.estimatedDistanceMeters,
79
+ actualDistanceMeters: meters,
80
+ reservedCredits: allocated / 1000,
81
+ actualCredits: cost / 1000,
82
+ };
76
83
  } catch {
77
84
  // A sent request never becomes free because the provider's distance cannot be priced safely.
78
85
  unknown += allocated;
@@ -106,13 +113,9 @@ export const runGeoRouteChain = async <T>(
106
113
  const actualDistance = pricing && distance ? distance(parsed.data) : 0;
107
114
  debitKnown(actualDistance);
108
115
  if (signal.aborted) throw new GeoError("GEO_PROVIDER_FAILED");
109
- // Retain valid paid results even on overrun, so an explicitly larger allowance can reuse them.
116
+ // Retain paid results even on overrun so an explicit retry can reuse them without paying again.
110
117
  await cache.put(key, fn, parsed.data, false);
111
- if (pricing && actualDistance > pricing.pairs * pricing.distanceAllowanceMeters)
112
- throw new GeoError("GEO_COST_OVERRUN", {
113
- distanceAllowanceMeters: pricing.distanceAllowanceMeters,
114
- actualDistanceMeters: actualDistance,
115
- });
118
+ if (overrun) throw new GeoError("GEO_COST_OVERRUN", overrun);
116
119
  return parsed.data;
117
120
  } finally {
118
121
  await release();
@@ -121,9 +124,13 @@ export const runGeoRouteChain = async <T>(
121
124
  } catch (error) {
122
125
  failure = error;
123
126
  }
127
+ if (overrun) failure = new GeoError("GEO_COST_OVERRUN", overrun);
128
+ const knownReferenceCredits = known / 1000;
129
+ let settlementConfirmed = true;
124
130
  try {
125
131
  await ledger.settle(reservation, known + unknown);
126
132
  } catch {
133
+ settlementConfirmed = false;
127
134
  // Keep the original admission debit, and make failed reconciliation visible without retaining a provider cause.
128
135
  failure = new GeoError("GEO_PROVIDER_FAILED", {
129
136
  adminHint:
@@ -139,6 +146,21 @@ export const runGeoRouteChain = async <T>(
139
146
  }
140
147
  if (failure) {
141
148
  const error = failure instanceof GeoError ? failure : new GeoError("GEO_PROVIDER_FAILED");
149
+ if (overrun) {
150
+ const recorded = await dependencies.recordTransition("route_cost_overrun", config.timezone, context, {
151
+ reservedCredits: reservation.cost / 1000,
152
+ settledCredits: known / 1000,
153
+ unknownCredits: unknown / 1000,
154
+ knownReferenceCredits,
155
+ settlementConfirmed: settlementConfirmed ? 1 : 0,
156
+ estimatedCallCredits: overrun.reservedCredits,
157
+ actualCallCredits: overrun.actualCredits,
158
+ });
159
+ if (recorded === false)
160
+ error.details.adminHint =
161
+ "The routing cost overrun could not be recorded in the audit log. Reconcile the reported costs before retrying. " +
162
+ (typeof error.details.adminHint === "string" ? error.details.adminHint : "");
163
+ }
142
164
  throw new GeoError(error.code, { ...error.details, costs: { settledCredits: known / 1000, unknownCredits: unknown / 1000 } });
143
165
  }
144
166
  return result!;
@@ -0,0 +1,19 @@
1
+ import type { GeoRouteResolvedStop } from "../../contracts/geo-route";
2
+ import { geoDistance } from "./polyline";
3
+
4
+ // Cloud technical budget policy, 2026-10-22: straight-line distance × 2.0.
5
+ // Source: places-and-maps.md#reserve-estimated-routing-costs (coordinator decision).
6
+ // This is an admission estimate, not a Geoapify guarantee or a measured detour bound.
7
+ // All checked routing fixtures are synthetic; no measured fixture supersedes this policy.
8
+ export const GEO_ROUTE_DETOUR_FACTOR = 2.0;
9
+ // Maximum great-circle distance on the same sphere used by geoDistance. Unknown addresses
10
+ // must not become zero-distance legs before geocoding; known coordinates avoid this reserve.
11
+ const MAX_STRAIGHT_LINE_METERS = geoDistance({ lat: 0, lon: 0 }, { lat: 0, lon: 180 });
12
+ type Stop = GeoRouteResolvedStop | undefined;
13
+ export const estimateRouteDistance = (stops: readonly Stop[], reorder = false) => {
14
+ const distance = (a: Stop, b: Stop) => (a && b ? geoDistance(a.point, b.point) : MAX_STRAIGHT_LINE_METERS);
15
+ const diameter = reorder ? Math.max(0, ...stops.flatMap((a, i) => stops.slice(i + 1).map((b) => distance(a, b)))) : 0;
16
+ return Math.ceil(
17
+ stops.slice(1).reduce((sum, stop, i) => sum + (reorder ? diameter : distance(stops[i], stop)), 0) * GEO_ROUTE_DETOUR_FACTOR,
18
+ );
19
+ };
@@ -84,14 +84,12 @@ export const assembleRoute = (
84
84
  defaults: GeoRouteOptions,
85
85
  originalLegs: GeoRouteLeg[],
86
86
  now: Date,
87
- distanceAllowanceMeters: number,
88
87
  ): GeoRoute => {
89
88
  let legs = originalLegs;
90
89
  const build = (): GeoRoute => ({
91
90
  stops,
92
91
  options: defaults,
93
92
  legs,
94
- distanceAllowanceMeters,
95
93
  distanceMeters: legs.reduce((sum, leg) => sum + leg.distanceMeters, 0),
96
94
  durationSeconds: legs.reduce((sum, leg) => sum + leg.durationSeconds, 0),
97
95
  polyline6: joinRouteLines(legs.map((leg) => leg.polyline6)),
@@ -16,7 +16,7 @@ import {
16
16
  GeoRouteUpdateInputSchema,
17
17
  } from "../../contracts/geo-route";
18
18
  import { geoCacheKey, normalizeGeoInput } from "./cache";
19
- import { GeoError, limitExceeded } from "./errors";
19
+ import { GeoError, GeoProviderError, limitExceeded } from "./errors";
20
20
  import type { GeoapifyAdapter } from "./geoapify";
21
21
  import { geoDistance } from "./polyline";
22
22
  import {
@@ -26,6 +26,7 @@ import {
26
26
  reserveRouteCall,
27
27
  runGeoRouteChain,
28
28
  } from "./route-chain";
29
+ import { estimateRouteDistance } from "./route-estimate";
29
30
  import {
30
31
  assembleRoute,
31
32
  insertionGaps,
@@ -120,18 +121,18 @@ export const createGeoRouteService = (
120
121
  },
121
122
  };
122
123
  };
123
- const pricing = (step: PlannedCall, allowance: number): GeoRouteCallPricing => ({
124
+ const pricing = (step: PlannedCall, stops: readonly (GeoRouteResolvedStop | undefined)[], reorder = false): GeoRouteCallPricing => ({
124
125
  pairs: step.ids.length - 1,
125
126
  avoids: step.options.avoid?.length ?? 0,
126
127
  optimize: step.optimize,
127
- distanceAllowanceMeters: allowance,
128
+ estimatedDistanceMeters: reorder
129
+ ? (estimateRouteDistance(stops, true) / (stops.length - 1)) * (step.ids.length - 1)
130
+ : estimateRouteDistance(
131
+ step.ids.map((id) => stops.find((stop) => stop?.id === id)),
132
+ step.optimize,
133
+ ),
128
134
  });
129
- const preflight = (
130
- stops: readonly (GeoRouteResolvedStop | undefined)[],
131
- options: GeoRouteOptions,
132
- allowance: number,
133
- optimize = false,
134
- ) => {
135
+ const preflight = (stops: readonly (GeoRouteResolvedStop | undefined)[], options: GeoRouteOptions, optimize = false) => {
135
136
  if (optimize) {
136
137
  // Every route connects all known stops. Their minimum spanning tree is an order-independent lower bound.
137
138
  const known = stops.filter((stop): stop is GeoRouteResolvedStop => !!stop);
@@ -153,7 +154,6 @@ export const createGeoRouteService = (
153
154
  lowerBound += shortest;
154
155
  }
155
156
  const pairs = stops.length - 1;
156
- if (lowerBound > pairs * allowance) limitExceeded("distanceAllowanceMeters", pairs * allowance, lowerBound);
157
157
  if (options.mode === "walk" && lowerBound > pairs * 100_000) limitExceeded("walk.legMeters", pairs * 100_000, lowerBound);
158
158
  return;
159
159
  }
@@ -161,27 +161,41 @@ export const createGeoRouteService = (
161
161
  const a = stops[i - 1],
162
162
  b = stops[i];
163
163
  if (!a || !b) continue;
164
- const distance = geoDistance(a.point, b.point);
165
- if (distance > allowance) limitExceeded("distanceAllowanceMeters", allowance, distance);
166
164
  walkDistance([a, b], options);
167
165
  }
168
166
  };
169
167
  const execute = async (
170
168
  step: PlannedCall,
171
169
  stops: readonly GeoRouteResolvedStop[],
172
- allowance: number,
173
170
  call: RouteCall,
174
171
  config: GeoConfig,
175
172
  context: GeoContext,
176
173
  ) => {
177
174
  const selected = step.ids.map((id) => stops.find((stop) => stop.id === id)!);
178
- preflight(selected, step.options, allowance, step.optimize);
175
+ preflight(selected, step.options, step.optimize);
179
176
  return call(
180
177
  "route",
181
178
  { stops: selected, options: step.options, optimize: step.optimize },
182
179
  providerData,
183
- (signal) => dependencies.adapter.route(selected, step.options, step.optimize, config.apiKey, context.locale ?? "en", signal),
184
- pricing(step, allowance),
180
+ async (signal) => {
181
+ const result = await dependencies.adapter.route(
182
+ selected,
183
+ step.options,
184
+ step.optimize,
185
+ config.apiKey,
186
+ context.locale ?? "en",
187
+ signal,
188
+ );
189
+ try {
190
+ // Intermediate paid responses (including overruns) must fit the same cache bound
191
+ // as final routes. Keep distance totals exact while compacting only geometry.
192
+ const compact = assembleRoute(result.stops, step.options, result.legs, dependencies.now());
193
+ return { stops: compact.stops, legs: compact.legs, distanceMeters: result.distanceMeters };
194
+ } catch {
195
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", "known", false, result.distanceMeters);
196
+ }
197
+ },
198
+ pricing(step, stops),
185
199
  (data) => data.distanceMeters,
186
200
  );
187
201
  };
@@ -189,14 +203,12 @@ export const createGeoRouteService = (
189
203
  const config = await dependencies.configured(context);
190
204
  const { plan } = parse(GeoRouteCalculateInputSchema, input);
191
205
  const { defaults, byDeparture } = validateRoutePlan(plan);
192
- const allowance = plan.distanceAllowanceMeters ?? 500_000;
193
206
  const key = geoCacheKey(
194
207
  "route",
195
208
  {
196
209
  calculate: {
197
210
  stops: plan.stops.map((stop) => (stop.address ? { ...stop, address: normalizeGeoInput(stop.address, "geocode") } : stop)),
198
211
  options: defaults,
199
- distanceAllowanceMeters: allowance,
200
212
  optimize: plan.optimize ?? false,
201
213
  legOptions: plan.stops.flatMap((stop) => {
202
214
  const options = byDeparture.get(stop.id);
@@ -229,27 +241,26 @@ export const createGeoRouteService = (
229
241
  preflight(
230
242
  step.ids.map((id) => prepared.known[plan.stops.findIndex((stop) => stop.id === id)]),
231
243
  step.options,
232
- allowance,
233
244
  step.optimize,
234
245
  );
235
246
  const result = await runGeoRouteChain(
236
247
  dependencies,
237
248
  context,
238
249
  config,
239
- prepared.cost + steps.reduce((sum, step) => sum + reserveRouteCall(pricing(step, allowance)), 0),
250
+ prepared.cost + steps.reduce((sum, step) => sum + reserveRouteCall(pricing(step, prepared.known, plan.optimize)), 0),
240
251
  async (call) => {
241
252
  let stops = await prepared.resolve(call, config);
242
253
  let legs: GeoRouteLeg[] = [];
243
254
  const first = steps[0]!;
244
255
  if (plan.optimize) {
245
- const optimized = await execute(first, stops, allowance, call, config, context);
256
+ const optimized = await execute(first, stops, call, config, context);
246
257
  stops = optimized.stops;
247
258
  legs = optimized.legs;
248
259
  for (let i = 0; i < legs.length; i++) {
249
260
  const options = byDeparture.get(stops[i]!.id);
250
261
  if (options && !sameRouteOptions(options, defaults)) {
251
262
  const step = { ids: [stops[i]!.id, stops[i + 1]!.id], options, optimize: false };
252
- legs[i] = (await execute(step, stops, allowance, call, config, context)).legs[0]!;
263
+ legs[i] = (await execute(step, stops, call, config, context)).legs[0]!;
253
264
  }
254
265
  }
255
266
  } else {
@@ -258,11 +269,10 @@ export const createGeoRouteService = (
258
269
  preflight(
259
270
  step.ids.map((id) => stops.find((stop) => stop.id === id)!),
260
271
  step.options,
261
- allowance,
262
272
  );
263
- for (const step of steps) legs.push(...(await execute(step, stops, allowance, call, config, context)).legs);
273
+ for (const step of steps) legs.push(...(await execute(step, stops, call, config, context)).legs);
264
274
  }
265
- return { route: assembleRoute(stops, defaults, legs, dependencies.now(), allowance) };
275
+ return { route: assembleRoute(stops, defaults, legs, dependencies.now()) };
266
276
  },
267
277
  );
268
278
  await dependencies.cache.put(key, "route", result, false);
@@ -270,9 +280,8 @@ export const createGeoRouteService = (
270
280
  };
271
281
  const update = async (input: GeoRouteUpdateInput, context: GeoContext): Promise<GeoRouteUpdateData> => {
272
282
  const config = await dependencies.configured(context);
273
- const { route, changes, distanceAllowanceMeters } = parse(GeoRouteUpdateInputSchema, input);
283
+ const { route, changes } = parse(GeoRouteUpdateInputSchema, input);
274
284
  validateSavedRoute(route);
275
- const allowance = distanceAllowanceMeters ?? route.distanceAllowanceMeters;
276
285
  const key = geoCacheKey("route", { update: input }, context.locale ?? "en");
277
286
  const cached = await dependencies.cache.get(key, GeoRouteUpdateDataSchema);
278
287
  if (cached) return cached;
@@ -298,12 +307,15 @@ export const createGeoRouteService = (
298
307
  options: route.legs[index]!.options,
299
308
  optimize: false,
300
309
  });
301
- steps.sort((a, b) => reserveRouteCall(pricing(b, allowance)) - reserveRouteCall(pricing(a, allowance)));
310
+ steps.sort(
311
+ (a, b) =>
312
+ reserveRouteCall(pricing(b, [...stops, inserted!.known[0]])) - reserveRouteCall(pricing(a, [...stops, inserted!.known[0]])),
313
+ );
302
314
  steps.splice(3);
303
315
  const point = inserted.known[0];
304
316
  if (point)
305
317
  for (const index of fixedGap !== undefined ? [fixedGap] : insertionGaps(route, point.point))
306
- preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options, allowance);
318
+ preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options);
307
319
  } else if (changes.type === "move") {
308
320
  const index = stops.findIndex((stop) => stop.id === changes.stopId);
309
321
  if (index < 0 || changes.toIndex >= stops.length) throw new GeoError("GEO_UNSUPPORTED_OPTION");
@@ -344,7 +356,6 @@ export const createGeoRouteService = (
344
356
  preflight(
345
357
  step.ids.map((id) => stops.find((stop) => stop.id === id)!),
346
358
  step.options,
347
- allowance,
348
359
  step.optimize,
349
360
  );
350
361
  const work = async (call: RouteCall) => {
@@ -353,7 +364,7 @@ export const createGeoRouteService = (
353
364
  const [point] = await inserted!.resolve(call, config);
354
365
  const gaps = fixedGap !== undefined ? [fixedGap] : insertionGaps(route, point!.point);
355
366
  // Resolve and validate every candidate before the first paid route request.
356
- for (const index of gaps) preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options, allowance);
367
+ for (const index of gaps) preflight([stops[index], point, stops[index + 1]], route.legs[index]!.options);
357
368
  let winner: { index: number; legs: GeoRouteLeg[]; detour: number; meters: number } | undefined;
358
369
  for (const index of gaps) {
359
370
  let result: z.infer<typeof providerData>;
@@ -361,7 +372,6 @@ export const createGeoRouteService = (
361
372
  result = await execute(
362
373
  { ids: [stops[index]!.id, point!.id, stops[index + 1]!.id], options: route.legs[index]!.options, optimize: false },
363
374
  [...stops, point!],
364
- allowance,
365
375
  call,
366
376
  config,
367
377
  context,
@@ -385,20 +395,19 @@ export const createGeoRouteService = (
385
395
  };
386
396
  stops.splice(winner!.index + 1, 0, point!);
387
397
  } else if (changes.type === "optimize") {
388
- const result = await execute(steps[0]!, stops, allowance, call, config, context);
398
+ const result = await execute(steps[0]!, stops, call, config, context);
389
399
  stops = result.stops;
390
400
  legs = result.legs;
391
401
  for (let i = 0; i < legs.length; i++) {
392
402
  const options = byDeparture.get(stops[i]!.id) ?? defaults;
393
403
  if (!sameRouteOptions(options, defaults))
394
- legs[i] = (
395
- await execute({ ids: [stops[i]!.id, stops[i + 1]!.id], options, optimize: false }, stops, allowance, call, config, context)
396
- ).legs[0]!;
404
+ legs[i] = (await execute({ ids: [stops[i]!.id, stops[i + 1]!.id], options, optimize: false }, stops, call, config, context))
405
+ .legs[0]!;
397
406
  }
398
407
  } else {
399
408
  const replacements = new Map<string, GeoRouteLeg>();
400
409
  for (const step of steps)
401
- for (const leg of (await execute(step, stops, allowance, call, config, context)).legs) replacements.set(leg.fromStopId, leg);
410
+ for (const leg of (await execute(step, stops, call, config, context)).legs) replacements.set(leg.fromStopId, leg);
402
411
  legs = stops
403
412
  .slice(0, -1)
404
413
  .map(
@@ -406,7 +415,11 @@ export const createGeoRouteService = (
406
415
  replacements.get(stop.id) ?? route.legs.find((leg) => leg.fromStopId === stop.id && leg.toStopId === stops[i + 1]!.id)!,
407
416
  );
408
417
  }
409
- const next = assembleRoute(stops, defaults, legs, dependencies.now(), allowance);
418
+ const unchanged =
419
+ sameRouteOptions(defaults, route.options) &&
420
+ stops.every((stop, i) => stop === route.stops[i]) &&
421
+ legs.every((leg, i) => leg === route.legs[i]);
422
+ const next = unchanged ? route : assembleRoute(stops, defaults, legs, dependencies.now());
410
423
  const before = routeSummary(route),
411
424
  after = routeSummary(next);
412
425
  return GeoRouteUpdateDataSchema.parse({
@@ -420,7 +433,12 @@ export const createGeoRouteService = (
420
433
  ...(insertion ? { insertion } : {}),
421
434
  });
422
435
  };
423
- const cost = (inserted?.cost ?? 0) + steps.reduce((sum, step) => sum + reserveRouteCall(pricing(step, allowance)), 0);
436
+ const cost =
437
+ (inserted?.cost ?? 0) +
438
+ steps.reduce(
439
+ (sum, step) => sum + reserveRouteCall(pricing(step, inserted ? [...stops, inserted.known[0]] : stops, changes.type === "optimize")),
440
+ 0,
441
+ );
424
442
  const result = cost
425
443
  ? await runGeoRouteChain(dependencies, context, config, cost, work)
426
444
  : await work(async () => {