@k2b/cloud 0.40.0 → 0.42.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 (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -0,0 +1,184 @@
1
+ import { GEO_MAX_POLYLINE_CHARS, type GeoPoint } from "../../contracts/geo";
2
+ import { GeoError, limitExceeded } from "./errors";
3
+
4
+ const R = 6_371_008.8;
5
+ const rad = Math.PI / 180;
6
+ const longitudeDelta = (delta: number) => ((delta + 540) % 360) - 180;
7
+ export const geoDistance = (a: GeoPoint, b: GeoPoint) => {
8
+ const sinA = Math.sin(a.lat * rad),
9
+ cosA = Math.cos(a.lat * rad);
10
+ const sinB = Math.sin(b.lat * rad),
11
+ cosB = Math.cos(b.lat * rad);
12
+ const delta = longitudeDelta(b.lon - a.lon) * rad,
13
+ cosDelta = Math.cos(delta);
14
+ // atan2 retains the angular distance when haversine would round nearly opposite points to exactly π.
15
+ return R * Math.atan2(Math.hypot(cosB * Math.sin(delta), cosA * sinB - sinA * cosB * cosDelta), sinA * sinB + cosA * cosB * cosDelta);
16
+ };
17
+ export const decodePolyline6 = (encoded: string): GeoPoint[] => {
18
+ if (encoded.length > GEO_MAX_POLYLINE_CHARS) limitExceeded("along.polyline6", GEO_MAX_POLYLINE_CHARS, encoded.length);
19
+ let index = 0,
20
+ lat = 0,
21
+ lon = 0;
22
+ const points: GeoPoint[] = [];
23
+ const value = () => {
24
+ let result = 0,
25
+ factor = 1;
26
+ for (let count = 0; count < 6 && index < encoded.length; count++) {
27
+ const part = encoded.charCodeAt(index++) - 63;
28
+ if (part < 0 || part > 63) throw new GeoError("GEO_UNSUPPORTED_OPTION");
29
+ result += (part & 31) * factor;
30
+ if (part < 32) return result % 2 ? -(result + 1) / 2 : result / 2;
31
+ factor *= 32;
32
+ }
33
+ throw new GeoError("GEO_UNSUPPORTED_OPTION");
34
+ };
35
+ while (index < encoded.length) {
36
+ lat += value();
37
+ lon += value();
38
+ const point = { lat: lat / 1e6, lon: lon / 1e6 };
39
+ if (Math.abs(point.lat) > 90 || Math.abs(point.lon) > 180) throw new GeoError("GEO_UNSUPPORTED_OPTION");
40
+ points.push(point);
41
+ }
42
+ if (points.length < 2) throw new GeoError("GEO_UNSUPPORTED_OPTION");
43
+ return points;
44
+ };
45
+ const interpolate = (a: GeoPoint, b: GeoPoint, t: number): GeoPoint => {
46
+ const vector = (p: GeoPoint) => ({
47
+ x: Math.cos(p.lat * rad) * Math.cos(p.lon * rad),
48
+ y: Math.cos(p.lat * rad) * Math.sin(p.lon * rad),
49
+ z: Math.sin(p.lat * rad),
50
+ });
51
+ const from = vector(a),
52
+ to = vector(b);
53
+ const nx = from.y * to.z - from.z * to.y,
54
+ ny = from.z * to.x - from.x * to.z,
55
+ nz = from.x * to.y - from.y * to.x;
56
+ const normal = Math.hypot(nx, ny, nz),
57
+ dot = from.x * to.x + from.y * to.y + from.z * to.z;
58
+ // Exactly opposite endpoints do not define a unique arc; the route must include an intermediate point.
59
+ if (dot < 0 && normal <= 8 * Number.EPSILON) throw new GeoError("GEO_UNSUPPORTED_OPTION");
60
+ if (t === 0 || !normal) return a;
61
+ if (t === 1) return b;
62
+ const angle = Math.atan2(normal, dot) * t,
63
+ cos = Math.cos(angle),
64
+ sin = Math.sin(angle);
65
+ // Rotate from the first endpoint in its tangent direction, without subtracting opposing endpoint weights.
66
+ const x = from.x * cos + ((ny * from.z - nz * from.y) / normal) * sin,
67
+ y = from.y * cos + ((nz * from.x - nx * from.z) / normal) * sin,
68
+ z = from.z * cos + ((nx * from.y - ny * from.x) / normal) * sin;
69
+ return { lat: Math.atan2(z, Math.hypot(x, y)) / rad, lon: Math.atan2(y, x) / rad };
70
+ };
71
+ export const cutGeoWindow = (points: readonly GeoPoint[], fromMeters: number, toMeters: number): GeoPoint[] => {
72
+ const output: GeoPoint[] = [];
73
+ let at = 0;
74
+ for (let i = 1; i < points.length; i++) {
75
+ const a = points[i - 1]!,
76
+ b = points[i]!,
77
+ length = geoDistance(a, b),
78
+ end = at + length;
79
+ if (length && end > fromMeters && at < toMeters) {
80
+ if (!output.length) output.push(interpolate(a, b, Math.max(0, (fromMeters - at) / length)));
81
+ output.push(interpolate(a, b, Math.min(1, (toMeters - at) / length)));
82
+ }
83
+ at = end;
84
+ if (at >= toMeters) break;
85
+ }
86
+ if (output.length < 2) throw new GeoError("GEO_UNSUPPORTED_OPTION");
87
+ return output;
88
+ };
89
+ const segmentProjection = (a: GeoPoint, b: GeoPoint) => {
90
+ const length = geoDistance(a, b) / R;
91
+ const sinA = Math.sin(a.lat * rad),
92
+ cosA = Math.cos(a.lat * rad);
93
+ const deltaB = longitudeDelta(b.lon - a.lon) * rad;
94
+ const bearing = Math.atan2(
95
+ Math.sin(deltaB) * Math.cos(b.lat * rad),
96
+ cosA * Math.sin(b.lat * rad) - sinA * Math.cos(b.lat * rad) * Math.cos(deltaB),
97
+ );
98
+ const sinBearing = Math.sin(bearing),
99
+ cosBearing = Math.cos(bearing);
100
+ return (point: GeoPoint) => {
101
+ if (!length) return { t: 0, distance: geoDistance(point, a) };
102
+ const delta = longitudeDelta(point.lon - a.lon) * rad;
103
+ const sinPoint = Math.sin(point.lat * rad),
104
+ cosPoint = Math.cos(point.lat * rad),
105
+ cosDelta = Math.cos(delta);
106
+ const east = cosPoint * Math.sin(delta),
107
+ north = cosA * sinPoint - sinA * cosPoint * cosDelta;
108
+ // Great-circle along-track and cross-track angles, using one prepared bearing per segment.
109
+ const along = Math.atan2(east * sinBearing + north * cosBearing, sinA * sinPoint + cosA * cosPoint * cosDelta);
110
+ if (along < 0 || along > length) {
111
+ const from = geoDistance(point, a),
112
+ to = geoDistance(point, b);
113
+ return from <= to ? { t: 0, distance: from } : { t: 1, distance: to };
114
+ }
115
+ return { t: along / length, distance: R * Math.asin(Math.min(1, Math.abs(east * cosBearing - north * sinBearing))) };
116
+ };
117
+ };
118
+ export const locateOnGeoLine = (point: GeoPoint, line: readonly GeoPoint[]) => {
119
+ let at = 0,
120
+ atMeters = 0,
121
+ distanceToRouteMeters = Infinity;
122
+ for (let i = 1; i < line.length; i++) {
123
+ const a = line[i - 1]!,
124
+ b = line[i]!,
125
+ length = geoDistance(a, b),
126
+ match = segmentProjection(a, b)(point);
127
+ if (match.distance < distanceToRouteMeters) {
128
+ distanceToRouteMeters = match.distance;
129
+ atMeters = at + match.t * length;
130
+ }
131
+ at += length;
132
+ }
133
+ return { atMeters, distanceToRouteMeters };
134
+ };
135
+ /** Linear minimum-spacing pass before the more expensive Douglas–Peucker stage. */
136
+ const reduceRadialDistance = (points: readonly GeoPoint[], tolerance: number): GeoPoint[] => {
137
+ const line = [points[0]!];
138
+ for (let i = 1; i < points.length - 1; i++) {
139
+ if (geoDistance(line.at(-1)!, points[i]!) > tolerance) line.push(points[i]!);
140
+ }
141
+ line.push(points.at(-1)!);
142
+ return line;
143
+ };
144
+ /** Iterative Douglas–Peucker: no recursion depth depends on untrusted line length. */
145
+ const simplify = (points: readonly GeoPoint[], tolerance: number, pointLimit: number): GeoPoint[] => {
146
+ const keep = new Set([0, points.length - 1]);
147
+ const stack: Array<[number, number]> = [[0, points.length - 1]];
148
+ while (stack.length) {
149
+ const [start, end] = stack.pop()!;
150
+ let furthest = -1,
151
+ distance = tolerance;
152
+ const project = segmentProjection(points[start]!, points[end]!);
153
+ for (let i = start + 1; i < end; i++) {
154
+ const next = project(points[i]!).distance;
155
+ if (next > distance) {
156
+ distance = next;
157
+ furthest = i;
158
+ }
159
+ }
160
+ if (furthest >= 0) {
161
+ keep.add(furthest);
162
+ // This tolerance already cannot fit: stop work and try the next tolerance.
163
+ if (keep.size > pointLimit) return [...points];
164
+ stack.push([start, furthest], [furthest, end]);
165
+ }
166
+ }
167
+ return [...keep].sort((a, b) => a - b).map((index) => points[index]!);
168
+ };
169
+ export const simplifyGeoLine = (points: readonly GeoPoint[], minimumToleranceMeters = 0, pointLimit = 2000) => {
170
+ let toleranceMeters = minimumToleranceMeters,
171
+ reduced = [...points];
172
+ // Bound quadratic Douglas–Peucker work independently of the decoded input size.
173
+ while (reduced.length > pointLimit * 2) {
174
+ toleranceMeters = toleranceMeters ? toleranceMeters * 2 : 1;
175
+ reduced = reduceRadialDistance(points, toleranceMeters);
176
+ }
177
+ let line = minimumToleranceMeters ? simplify(reduced, toleranceMeters, pointLimit) : reduced;
178
+ while (line.length > pointLimit) {
179
+ toleranceMeters ||= 1;
180
+ line = simplify(reduced, toleranceMeters, pointLimit);
181
+ if (line.length > pointLimit) toleranceMeters *= 2;
182
+ }
183
+ return { line, toleranceMeters };
184
+ };
@@ -0,0 +1,145 @@
1
+ import type { z } from "zod";
2
+ import type { GeoFunction } from "../../contracts/geo";
3
+ import type { createGeoBucket } from "./bucket";
4
+ import { GEO_TIMEOUT_MS } from "./bucket";
5
+ import type { createGeoCache } from "./cache";
6
+ import { geoCacheKey } from "./cache";
7
+ import { geoReferenceCost } from "./costs";
8
+ import { GeoError, GeoProviderError, limitExceeded } from "./errors";
9
+ import type { createGeoLedger } from "./ledger";
10
+ import { geoRouteReferenceCost } from "./route-costs";
11
+ import type { GeoContext } from "./service";
12
+ import { type GeoConfig, geoPlan } from "./settings";
13
+
14
+ export type GeoRouteChainDependencies = {
15
+ cache: ReturnType<typeof createGeoCache>;
16
+ bucket: ReturnType<typeof createGeoBucket>;
17
+ ledger: ReturnType<typeof createGeoLedger>;
18
+ now: () => Date;
19
+ recordTransition: (event: "monthly_budget_exhausted" | "provider_key_rejected", timezone: string, context: GeoContext) => Promise<void>;
20
+ };
21
+ export type GeoRouteCallPricing = { pairs: number; avoids: number; optimize: boolean; distanceAllowanceMeters: number };
22
+ export const reserveRouteCall = (pricing: GeoRouteCallPricing) =>
23
+ geoRouteReferenceCost({ ...pricing, distanceMeters: pricing.pairs * pricing.distanceAllowanceMeters });
24
+ export type GeoRouteChainCall = <V>(
25
+ fn: GeoFunction,
26
+ input: unknown,
27
+ schema: z.ZodType<V>,
28
+ request: (signal: AbortSignal) => Promise<V>,
29
+ pricing?: GeoRouteCallPricing,
30
+ distance?: (value: V) => number,
31
+ ) => Promise<V>;
32
+ export const runGeoRouteChain = async <T>(
33
+ dependencies: GeoRouteChainDependencies,
34
+ context: GeoContext,
35
+ config: GeoConfig,
36
+ reserveCost: number,
37
+ work: (call: GeoRouteChainCall) => Promise<T>,
38
+ ): Promise<T> => {
39
+ const { ledger, bucket, cache, now } = dependencies;
40
+ if (!context.accessSubject) throw new GeoError("GEO_FORBIDDEN");
41
+ context.signal?.throwIfAborted();
42
+ const maximumCredits = Math.min(config.personDailyCredits, config.monthlyCredits);
43
+ if (reserveCost > maximumCredits * 1000) limitExceeded("reservationCredits", maximumCredits, reserveCost / 1000);
44
+ let reservation: Awaited<ReturnType<typeof ledger.reserve>>;
45
+ try {
46
+ // One atomic admission for all known geocodes and every possible paid routing step.
47
+ reservation = await ledger.reserve(context.accessSubject, config, reserveCost, "route", now());
48
+ } catch (error) {
49
+ if (error instanceof GeoError && error.code === "GEO_BUDGET_EXHAUSTED" && error.details.scope === "cloud")
50
+ await dependencies.recordTransition("monthly_budget_exhausted", config.timezone, context);
51
+ throw error;
52
+ }
53
+ let known = 0,
54
+ unknown = 0;
55
+ let result: T | undefined, failure: unknown;
56
+ try {
57
+ result = await work(async (fn, input, schema, request, pricing, distance) => {
58
+ context.signal?.throwIfAborted();
59
+ const key = geoCacheKey(fn, input, context.locale ?? "en");
60
+ 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
+ }
70
+ const allocated = pricing ? reserveRouteCall(pricing) : geoReferenceCost({ fn });
71
+ const debitKnown = (meters: number) => {
72
+ try {
73
+ const cost = pricing ? geoRouteReferenceCost({ ...pricing, distanceMeters: meters }) : allocated;
74
+ if (!Number.isSafeInteger(known + unknown + cost)) throw new Error("Unrepresentable reference cost");
75
+ known += cost;
76
+ } catch {
77
+ // A sent request never becomes free because the provider's distance cannot be priced safely.
78
+ unknown += allocated;
79
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", "unknown");
80
+ }
81
+ };
82
+ const release = await bucket.acquire(fn, geoPlan(config).rps);
83
+ const deadline = AbortSignal.timeout(GEO_TIMEOUT_MS[fn]);
84
+ const signal = context.signal ? AbortSignal.any([context.signal, deadline]) : deadline;
85
+ try {
86
+ signal.throwIfAborted();
87
+ let response: unknown;
88
+ try {
89
+ response = await request(signal);
90
+ } catch (error) {
91
+ if (error instanceof GeoProviderError && error.charge === "none") {
92
+ /* provably not sent */
93
+ } else if (error instanceof GeoProviderError && error.charge === "unknown") unknown += allocated;
94
+ else if (error instanceof GeoError && !(error instanceof GeoProviderError)) debitKnown(0);
95
+ else if (error instanceof GeoProviderError) debitKnown(error.referenceDistanceMeters ?? 0);
96
+ else unknown += allocated;
97
+ if (error instanceof GeoProviderError && error.keyRejected)
98
+ await dependencies.recordTransition("provider_key_rejected", config.timezone, context);
99
+ throw error;
100
+ }
101
+ const parsed = schema.safeParse(response);
102
+ if (!parsed.success) {
103
+ unknown += allocated;
104
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", "unknown");
105
+ }
106
+ const actualDistance = pricing && distance ? distance(parsed.data) : 0;
107
+ debitKnown(actualDistance);
108
+ 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.
110
+ 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
+ });
116
+ return parsed.data;
117
+ } finally {
118
+ await release();
119
+ }
120
+ });
121
+ } catch (error) {
122
+ failure = error;
123
+ }
124
+ try {
125
+ await ledger.settle(reservation, known + unknown);
126
+ } catch {
127
+ // Keep the original admission debit, and make failed reconciliation visible without retaining a provider cause.
128
+ failure = new GeoError("GEO_PROVIDER_FAILED", {
129
+ adminHint:
130
+ "Geo accounting settlement failed; the final ledger debit is unconfirmed. Review the original reservation and reconcile accounting reference costs in Core geo usage before retrying.",
131
+ accounting: {
132
+ reservationCredits: reservation.cost / 1000,
133
+ knownReferenceCredits: known / 1000,
134
+ unknownReferenceCredits: unknown / 1000,
135
+ },
136
+ });
137
+ known = 0;
138
+ unknown = reservation.cost;
139
+ }
140
+ if (failure) {
141
+ const error = failure instanceof GeoError ? failure : new GeoError("GEO_PROVIDER_FAILED");
142
+ throw new GeoError(error.code, { ...error.details, costs: { settledCredits: known / 1000, unknownCredits: unknown / 1000 } });
143
+ }
144
+ return result!;
145
+ };
@@ -0,0 +1,9 @@
1
+ /** Conservative Cloud reference pricing, in integer millicredits; not a provider invoice quote. */
2
+ export type GeoRouteCost = { pairs: number; avoids: number; optimize: boolean; distanceMeters: number };
3
+ export const geoRouteReferenceCost = ({ pairs, avoids, optimize, distanceMeters }: GeoRouteCost) => {
4
+ const intermediate = pairs - 1;
5
+ const matrix = optimize && intermediate >= 2 ? intermediate * Math.min(intermediate, 10) : 0;
6
+ const cost = (pairs + avoids + matrix) * Math.max(1, Math.ceil(distanceMeters / 500_000)) * 1000;
7
+ if (!Number.isSafeInteger(cost) || cost < 0) throw new Error("Invalid geo routing reference cost");
8
+ return cost;
9
+ };
@@ -0,0 +1,39 @@
1
+ import type { GeoPoint } from "../../contracts/geo";
2
+ import { decodePolyline6, simplifyGeoLine } from "./polyline";
3
+
4
+ export const encodePolyline6 = (points: readonly GeoPoint[]) => {
5
+ let lat = 0,
6
+ lon = 0,
7
+ output = "";
8
+ const encode = (value: number) => {
9
+ let n = value < 0 ? -value * 2 - 1 : value * 2;
10
+ while (n >= 32) {
11
+ output += String.fromCharCode((n % 32) + 95);
12
+ n = Math.floor(n / 32);
13
+ }
14
+ output += String.fromCharCode(n + 63);
15
+ };
16
+ for (const point of points) {
17
+ const nextLat = Math.round(point.lat * 1e6),
18
+ nextLon = Math.round(point.lon * 1e6);
19
+ encode(nextLat - lat);
20
+ encode(nextLon - lon);
21
+ lat = nextLat;
22
+ lon = nextLon;
23
+ }
24
+ return output;
25
+ };
26
+ export const compactRouteLine = (line: readonly GeoPoint[], tolerance = 0, pointLimit = 2000) => {
27
+ const simplified = simplifyGeoLine([...line], tolerance, pointLimit);
28
+ return { polyline6: encodePolyline6(simplified.line), simplificationToleranceMeters: 2 * simplified.toleranceMeters };
29
+ };
30
+ export const joinRouteLines = (lines: readonly string[]) => {
31
+ const points: GeoPoint[] = [];
32
+ for (const line of lines) {
33
+ const next = decodePolyline6(line);
34
+ const last = points.at(-1),
35
+ first = next[0]!;
36
+ points.push(...(last && last.lat === first.lat && last.lon === first.lon ? next.slice(1) : next));
37
+ }
38
+ return encodePolyline6(points);
39
+ };
@@ -0,0 +1,172 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { GeoPoint } from "../../contracts/geo";
3
+ import {
4
+ type GeoRoute,
5
+ type GeoRouteLeg,
6
+ type GeoRouteOptions,
7
+ type GeoRoutePlan,
8
+ type GeoRouteResolvedStop,
9
+ GeoRouteSchema,
10
+ type GeoRouteStop,
11
+ } from "../../contracts/geo-route";
12
+ import { GEO_MAX_JSON_BYTES, normalizeGeoInput } from "./cache";
13
+ import { GeoError, limitExceeded } from "./errors";
14
+ import { geoProvenance } from "./geoapify";
15
+ import { decodePolyline6, geoDistance } from "./polyline";
16
+ import { compactRouteLine, joinRouteLines } from "./route-geometry";
17
+
18
+ export const routeOptions = (value: GeoRouteOptions = {}, base: GeoRouteOptions = {}): GeoRouteOptions => {
19
+ const mode = value.mode ?? base.mode ?? "car";
20
+ const avoid = [...new Set(value.avoid ?? base.avoid ?? [])].sort();
21
+ if (mode !== "car" && avoid.some((option) => option !== "ferries")) throw new GeoError("GEO_UNSUPPORTED_OPTION");
22
+ return { mode, avoid };
23
+ };
24
+ export const sameRouteOptions = (a: GeoRouteOptions, b: GeoRouteOptions) =>
25
+ JSON.stringify(routeOptions(a)) === JSON.stringify(routeOptions(b));
26
+ export const validateRouteStops = (stops: readonly GeoRouteStop[]) => {
27
+ if (stops.length < 2 || stops.length > 26) limitExceeded("stops", stops.length < 2 ? 2 : 26, stops.length);
28
+ if (new Set(stops.map((stop) => stop.id)).size !== stops.length) throw new GeoError("GEO_UNSUPPORTED_OPTION");
29
+ for (const stop of stops) {
30
+ if (!!stop.point === !!stop.address) throw new GeoError("GEO_UNSUPPORTED_OPTION");
31
+ if (!stop.address) continue;
32
+ const address = stop.address;
33
+ if (address.text !== undefined && (!address.text.trim() || address.text.length > 300))
34
+ limitExceeded("address.text", 300, address.text.length);
35
+ for (const [key, field] of Object.entries(address.fields ?? {}))
36
+ if (field !== undefined && field.length > 200) limitExceeded(`address.fields.${key}`, 200, field.length);
37
+ if (
38
+ !!address.text?.trim() === !!Object.values(address.fields ?? {}).some((field) => field?.trim()) ||
39
+ (address.countryCode !== undefined && !/^[a-z]{2}$/i.test(address.countryCode))
40
+ )
41
+ throw new GeoError("GEO_UNSUPPORTED_OPTION");
42
+ }
43
+ };
44
+ export const validateRoutePlan = (plan: GeoRoutePlan) => {
45
+ validateRouteStops(plan.stops);
46
+ const defaults = routeOptions(plan.options);
47
+ const byDeparture = new Map<string, GeoRouteOptions>();
48
+ for (const entry of plan.legOptions ?? []) {
49
+ const index = plan.stops.findIndex((stop) => stop.id === entry.fromStopId);
50
+ if (index < 0 || plan.stops[index + 1]?.id !== entry.toStopId || byDeparture.has(entry.fromStopId))
51
+ throw new GeoError("GEO_UNSUPPORTED_OPTION");
52
+ byDeparture.set(entry.fromStopId, routeOptions(entry.options, defaults));
53
+ }
54
+ return { defaults, byDeparture };
55
+ };
56
+ export const walkDistance = (stops: readonly GeoRouteResolvedStop[], options: GeoRouteOptions) => {
57
+ if (options.mode !== "walk") return;
58
+ for (let i = 1; i < stops.length; i++) {
59
+ const distance = geoDistance(stops[i - 1]!.point, stops[i]!.point);
60
+ if (distance > 100_000) limitExceeded("walk.legMeters", 100_000, distance);
61
+ }
62
+ };
63
+ const routeHash = (stops: readonly GeoRouteResolvedStop[], options: GeoRouteOptions, legs: readonly GeoRouteLeg[]) =>
64
+ createHash("sha256")
65
+ .update(
66
+ JSON.stringify(
67
+ normalizeGeoInput(
68
+ {
69
+ stops,
70
+ options,
71
+ legOptions: legs.map((leg) => ({
72
+ fromStopId: leg.fromStopId,
73
+ toStopId: leg.toStopId,
74
+ options: leg.options,
75
+ })),
76
+ },
77
+ "route",
78
+ ),
79
+ ),
80
+ )
81
+ .digest("hex");
82
+ export const assembleRoute = (
83
+ stops: GeoRouteResolvedStop[],
84
+ defaults: GeoRouteOptions,
85
+ originalLegs: GeoRouteLeg[],
86
+ now: Date,
87
+ distanceAllowanceMeters: number,
88
+ ): GeoRoute => {
89
+ let legs = originalLegs;
90
+ const build = (): GeoRoute => ({
91
+ stops,
92
+ options: defaults,
93
+ legs,
94
+ distanceAllowanceMeters,
95
+ distanceMeters: legs.reduce((sum, leg) => sum + leg.distanceMeters, 0),
96
+ durationSeconds: legs.reduce((sum, leg) => sum + leg.durationSeconds, 0),
97
+ polyline6: joinRouteLines(legs.map((leg) => leg.polyline6)),
98
+ simplificationToleranceMeters: Math.max(...legs.map((leg) => leg.simplificationToleranceMeters)),
99
+ inputHash: routeHash(stops, defaults, legs),
100
+ provenance: geoProvenance(now),
101
+ });
102
+ let route = build();
103
+ // Leave space for the capability envelope and update's before/after/delta metadata.
104
+ // Always simplify from the original geometry, so repeated iterations do not compound the stated tolerance.
105
+ const lines = originalLegs.map((leg) => decodePolyline6(leg.polyline6));
106
+ // Each polyline point uses at most 12 ASCII bytes, and appears in its leg and the whole route.
107
+ // Reserve the actual metadata size before dividing the available geometry budget across legs.
108
+ const metadataBytes = Buffer.byteLength(
109
+ JSON.stringify({ data: { route: { ...route, polyline6: "", legs: legs.map((leg) => ({ ...leg, polyline6: "" })) } } }),
110
+ );
111
+ const pointLimit = Math.max(
112
+ 2,
113
+ Math.min(2000, Math.floor((GEO_MAX_JSON_BYTES - 8192 - metadataBytes) / (24 * legs.length)), Math.floor(102400 / (12 * legs.length))),
114
+ );
115
+ let tolerance = 1;
116
+ while (Buffer.byteLength(JSON.stringify({ data: { route } })) > GEO_MAX_JSON_BYTES - 8192 || route.polyline6.length > 102400) {
117
+ legs = originalLegs.map((leg, i) => {
118
+ const compacted = compactRouteLine(lines[i]!, tolerance, pointLimit);
119
+ return {
120
+ ...leg,
121
+ ...compacted,
122
+ simplificationToleranceMeters: leg.simplificationToleranceMeters + compacted.simplificationToleranceMeters,
123
+ };
124
+ });
125
+ route = build();
126
+ tolerance *= 2;
127
+ if (tolerance > 40_100_000) throw new GeoError("GEO_PROVIDER_FAILED");
128
+ }
129
+ return GeoRouteSchema.parse(route);
130
+ };
131
+ export const validateSavedRoute = (route: GeoRoute) => {
132
+ if (Buffer.byteLength(JSON.stringify(route)) > GEO_MAX_JSON_BYTES)
133
+ limitExceeded("route.bytes", GEO_MAX_JSON_BYTES, Buffer.byteLength(JSON.stringify(route)));
134
+ if (new Set(route.stops.map((stop) => stop.id)).size !== route.stops.length || route.legs.length !== route.stops.length - 1)
135
+ throw new GeoError("GEO_UNSUPPORTED_OPTION");
136
+ const validProvenance = (value: GeoRoute["provenance"]) =>
137
+ value.attribution.links[0] === "https://geoapify.com" && value.attribution.links[1] === "https://www.openstreetmap.org/copyright";
138
+ if (!validProvenance(route.provenance) || route.stops.some((stop) => stop.address && !validProvenance(stop.address.provenance)))
139
+ throw new GeoError("GEO_UNSUPPORTED_OPTION");
140
+ routeOptions(route.options);
141
+ for (const [i, leg] of route.legs.entries()) {
142
+ if (leg.fromStopId !== route.stops[i]!.id || leg.toStopId !== route.stops[i + 1]!.id) throw new GeoError("GEO_UNSUPPORTED_OPTION");
143
+ if (!validProvenance(leg.provenance)) throw new GeoError("GEO_UNSUPPORTED_OPTION");
144
+ routeOptions(leg.options);
145
+ decodePolyline6(leg.polyline6);
146
+ }
147
+ decodePolyline6(route.polyline6);
148
+ if (
149
+ Math.abs(route.distanceMeters - route.legs.reduce((sum, leg) => sum + leg.distanceMeters, 0)) > 1e-6 ||
150
+ Math.abs(route.durationSeconds - route.legs.reduce((sum, leg) => sum + leg.durationSeconds, 0)) > 1e-6 ||
151
+ route.polyline6 !== joinRouteLines(route.legs.map((leg) => leg.polyline6)) ||
152
+ route.inputHash !== routeHash(route.stops, route.options, route.legs)
153
+ )
154
+ throw new GeoError("GEO_UNSUPPORTED_OPTION");
155
+ };
156
+ export const routeSummary = (route: GeoRoute) => ({
157
+ stopIds: route.stops.map((stop) => stop.id),
158
+ distanceMeters: route.distanceMeters,
159
+ durationSeconds: route.durationSeconds,
160
+ });
161
+ export const insertionGaps = (route: GeoRoute, point: GeoPoint) =>
162
+ route.legs
163
+ .map((_, index) => ({
164
+ index,
165
+ detour:
166
+ geoDistance(route.stops[index]!.point, point) +
167
+ geoDistance(point, route.stops[index + 1]!.point) -
168
+ geoDistance(route.stops[index]!.point, route.stops[index + 1]!.point),
169
+ }))
170
+ .sort((a, b) => a.detour - b.detour || a.index - b.index)
171
+ .slice(0, 3)
172
+ .map((gap) => gap.index);