@k2b/cloud 0.41.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 (227) hide show
  1. package/package.json +6 -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-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+ import type { GeoProvenance } from "../../contracts/geo";
3
+ import type { GeoRouteLeg, GeoRouteOptions, GeoRouteResolvedStop } from "../../contracts/geo-route";
4
+ import { GEO_MAX_ROUTE_RESPONSE_BYTES } from "./cache";
5
+ import { GeoError, GeoProviderError } from "./errors";
6
+ import { compactRouteLine } from "./route-geometry";
7
+
8
+ const coordinate = z.tuple([z.number().min(-180).max(180), z.number().min(-90).max(90)]);
9
+ const point = z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) });
10
+ const resultSchema = z.object({
11
+ results: z
12
+ .array(
13
+ z.object({
14
+ units: z.literal("metric"),
15
+ distance_units: z.literal("meters"),
16
+ distance: z.number().nonnegative(),
17
+ time: z.number().nonnegative(),
18
+ waypoints: z
19
+ .array(z.object({ original_index: z.number().int().min(0).max(25), location: coordinate }))
20
+ .min(2)
21
+ .max(26),
22
+ legs: z
23
+ .array(
24
+ z.object({
25
+ distance: z.number().nonnegative(),
26
+ time: z.number().nonnegative(),
27
+ }),
28
+ )
29
+ .min(1)
30
+ .max(25),
31
+ // Even the shortest JSON coordinate pair takes at least 17 bytes. The response byte cap is authoritative.
32
+ geometry: z
33
+ .array(
34
+ z
35
+ .array(point)
36
+ .min(2)
37
+ .max(Math.floor(GEO_MAX_ROUTE_RESPONSE_BYTES / 17)),
38
+ )
39
+ .max(25),
40
+ }),
41
+ )
42
+ .max(1),
43
+ });
44
+ const pricedDistanceSchema = z.object({
45
+ results: z
46
+ .array(z.object({ units: z.literal("metric"), distance_units: z.literal("meters"), distance: z.number().nonnegative() }))
47
+ .length(1),
48
+ });
49
+ export const parseGeoapifyRoute = (
50
+ raw: unknown,
51
+ input: readonly GeoRouteResolvedStop[],
52
+ options: GeoRouteOptions,
53
+ optimize: boolean,
54
+ provenance: GeoProvenance,
55
+ ) => {
56
+ const priced = pricedDistanceSchema.safeParse(raw);
57
+ const actualDistance = priced.success ? priced.data.results[0]!.distance : undefined;
58
+ const failed = () =>
59
+ new GeoProviderError("GEO_PROVIDER_FAILED", actualDistance === undefined ? "unknown" : "known", false, actualDistance);
60
+ const parsed = resultSchema.safeParse(raw);
61
+ if (!parsed.success) throw failed();
62
+ const feature = parsed.data.results[0];
63
+ if (!feature) throw new GeoError("GEO_NO_ROUTE");
64
+ const p = feature,
65
+ indexes = p.waypoints.map((w) => w.original_index);
66
+ if (
67
+ p.legs.length !== input.length - 1 ||
68
+ indexes.length !== input.length ||
69
+ new Set(indexes).size !== input.length ||
70
+ indexes.some((i) => i >= input.length) ||
71
+ indexes[0] !== 0 ||
72
+ indexes.at(-1) !== input.length - 1 ||
73
+ (!optimize && indexes.some((value, i) => value !== i)) ||
74
+ feature.geometry.length !== p.legs.length ||
75
+ Math.abs(p.legs.reduce((sum, leg) => sum + leg.distance, 0) - p.distance) > 1 ||
76
+ Math.abs(p.legs.reduce((sum, leg) => sum + leg.time, 0) - p.time) > 1
77
+ )
78
+ throw failed();
79
+ const stops = indexes.map((i) => input[i]!);
80
+ const legs: GeoRouteLeg[] = p.legs.map((leg, i) => {
81
+ const line = feature.geometry[i]!;
82
+ return {
83
+ fromStopId: stops[i]!.id,
84
+ toStopId: stops[i + 1]!.id,
85
+ options,
86
+ distanceMeters: leg.distance,
87
+ durationSeconds: leg.time,
88
+ ...compactRouteLine(line),
89
+ provenance,
90
+ };
91
+ });
92
+ return { stops, legs, distanceMeters: p.distance };
93
+ };
@@ -0,0 +1,281 @@
1
+ import { z } from "zod";
2
+ import { readBoundedJson } from "../../_internal/bounded-json";
3
+ import {
4
+ GEO_CATEGORIES,
5
+ type GeoAddress,
6
+ type GeoAutocompleteInput,
7
+ type GeoFunction,
8
+ type GeoGeocodeInput,
9
+ type GeoPlace,
10
+ type GeoPlaceSearchInput,
11
+ type GeoPoint,
12
+ type GeoProvenance,
13
+ type GeoReverseInput,
14
+ } from "../../contracts/geo";
15
+ import type { GeoRouteOptions, GeoRouteResolvedStop } from "../../contracts/geo-route";
16
+ import { EMPTY_NETWORK_ALLOWLIST } from "../../shared/network-address";
17
+ import { createAllowedNetworkFetch } from "../allowed-network-fetch";
18
+ import { GEO_TIMEOUT_MS } from "./bucket";
19
+ import { GEO_MAX_JSON_BYTES, GEO_MAX_ROUTE_RESPONSE_BYTES } from "./cache";
20
+ import { GeoProviderError } from "./errors";
21
+ import { parseGeoapifyRoute } from "./geoapify-route";
22
+
23
+ const GEOAPIFY_ORIGIN = "https://api-eu.geoapify.com";
24
+ const guardedFetch = createAllowedNetworkFetch(EMPTY_NETWORK_ALLOWLIST);
25
+ export type GeoFetch = (url: URL, init: RequestInit) => Promise<Response>;
26
+ const text = z.string().max(300).optional();
27
+ const rawProperties = z.object({
28
+ lat: z.number().min(-90).max(90),
29
+ lon: z.number().min(-180).max(180),
30
+ address_line1: text,
31
+ postcode: text,
32
+ city: text,
33
+ town: text,
34
+ village: text,
35
+ state: text,
36
+ region: text,
37
+ country_code: z.string().max(2).optional(),
38
+ formatted: z.string().max(600).optional(),
39
+ street: text,
40
+ housenumber: text,
41
+ name: text,
42
+ rank: z
43
+ .object({
44
+ confidence: z.number().min(0).max(1).optional(),
45
+ confidence_building_level: z.number().min(0).max(1).optional(),
46
+ match_type: z.string().optional(),
47
+ })
48
+ .optional(),
49
+ result_type: z.string().optional(),
50
+ place_id: z.string().min(1).max(2048).optional(),
51
+ categories: z.array(z.string().max(100)).max(20).optional(),
52
+ distance: z.number().nonnegative().optional(),
53
+ opening_hours: z.string().max(1000).optional(),
54
+ website: z.string().max(1000).optional(),
55
+ contact: z.object({ phone: text }).optional(),
56
+ datasource: z
57
+ .object({
58
+ raw: z.object({ opening_hours: z.string().max(1000).optional(), website: z.string().max(1000).optional(), phone: text }).optional(),
59
+ })
60
+ .optional(),
61
+ });
62
+ type RawProperties = z.infer<typeof rawProperties>;
63
+ const collection = z.object({
64
+ type: z.literal("FeatureCollection"),
65
+ features: z
66
+ .array(
67
+ z.object({
68
+ type: z.literal("Feature"),
69
+ properties: rawProperties,
70
+ geometry: z.object({ type: z.string(), coordinates: z.unknown() }).nullable(),
71
+ }),
72
+ )
73
+ .max(20),
74
+ });
75
+ export const geoProvenance = (now: Date): GeoProvenance => ({
76
+ provider: "geoapify",
77
+ attribution: { text: "Geoapify · OpenStreetMap", links: ["https://geoapify.com", "https://www.openstreetmap.org/copyright"] },
78
+ computedAt: now.toISOString(),
79
+ });
80
+ const address = (p: RawProperties, provenance: GeoProvenance): GeoAddress => ({
81
+ line1: p.address_line1 ?? [p.street, p.housenumber].filter(Boolean).join(" "),
82
+ postalCode: p.postcode ?? "",
83
+ city: p.city ?? p.town ?? p.village ?? "",
84
+ stateRegion: p.state ?? p.region ?? "",
85
+ countryCode: p.country_code?.toUpperCase() ?? "",
86
+ point: { lat: p.lat, lon: p.lon },
87
+ formatted: p.formatted ?? p.address_line1 ?? "",
88
+ confidence: p.rank?.confidence ?? null,
89
+ provenance,
90
+ });
91
+ const areaFilter = (area: NonNullable<GeoAutocompleteInput["area"]>) =>
92
+ area.type === "bbox"
93
+ ? `rect:${area.west},${area.south},${area.east},${area.north}`
94
+ : `circle:${area.point.lon},${area.point.lat},${area.radiusMeters}`;
95
+ /** Only these transport failures prove no HTTP request could have been delivered. Never retry resets, timeouts or aborts. */
96
+ export const geoNeverSent = (error: unknown): boolean => {
97
+ const parsed = z.object({ code: z.string() }).safeParse(error);
98
+ return (
99
+ parsed.success &&
100
+ [
101
+ "ENOTFOUND",
102
+ "EAI_AGAIN",
103
+ "ECONNREFUSED",
104
+ "ERR_TLS_CERT_ALTNAME_INVALID",
105
+ "CERT_HAS_EXPIRED",
106
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
107
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
108
+ ].includes(parsed.data.code)
109
+ );
110
+ };
111
+ export class GeoapifyAdapter {
112
+ constructor(private readonly options: { fetch?: GeoFetch; now?: () => Date } = {}) {}
113
+ private async requestJson(
114
+ fn: GeoFunction,
115
+ params: URLSearchParams,
116
+ key: string,
117
+ locale: string,
118
+ callerSignal?: AbortSignal,
119
+ body?: unknown,
120
+ ): Promise<unknown> {
121
+ const url = new URL(
122
+ fn === "route" ? "/v1/routing" : fn === "places" ? "/v2/places" : `/v1/geocode/${fn === "geocode" ? "search" : fn}`,
123
+ GEOAPIFY_ORIGIN,
124
+ );
125
+ const lang = new Intl.Locale(locale).language;
126
+ params.set("lang", lang);
127
+ params.set("format", fn === "route" ? "json" : "geojson");
128
+ // Places v2 has one GeoJSON representation and does not accept a format parameter.
129
+ if (fn === "places") params.delete("format");
130
+ url.search = params.toString();
131
+ const deadline = AbortSignal.timeout(GEO_TIMEOUT_MS[fn]);
132
+ const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
133
+ const init: RequestInit = {
134
+ method: body ? "POST" : "GET",
135
+ credentials: "omit",
136
+ redirect: "error",
137
+ signal,
138
+ headers: { "x-api-key": key, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) },
139
+ ...(body ? { body: JSON.stringify({ ...z.record(z.string(), z.unknown()).parse(body), lang }) } : {}),
140
+ };
141
+ for (let attempt = 0; attempt < 2; attempt++) {
142
+ if (signal.aborted) throw new GeoProviderError("GEO_PROVIDER_FAILED", "none");
143
+ let response: Response;
144
+ try {
145
+ response = await (this.options.fetch ?? guardedFetch)(url, init);
146
+ } catch (error) {
147
+ const neverSent = !signal.aborted && geoNeverSent(error);
148
+ if (neverSent && attempt === 0) continue;
149
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", neverSent ? "none" : "unknown");
150
+ }
151
+ if (!response.ok) {
152
+ await response.body?.cancel().catch(() => {});
153
+ throw new GeoProviderError(
154
+ response.status === 429 ? "GEO_BUSY" : "GEO_PROVIDER_FAILED",
155
+ "known",
156
+ [401, 403].includes(response.status),
157
+ );
158
+ }
159
+ // Bound raw provider responses; routing geometry is compacted to the smaller public result budget.
160
+ const json = await readBoundedJson(response, fn === "route" ? GEO_MAX_ROUTE_RESPONSE_BYTES : GEO_MAX_JSON_BYTES);
161
+ if (signal.aborted) throw new GeoProviderError("GEO_PROVIDER_FAILED", "unknown");
162
+ if (!json.ok || JSON.stringify(json.data).includes(JSON.stringify(key).slice(1, -1)))
163
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", fn === "route" ? "unknown" : "known");
164
+ return json.data;
165
+ }
166
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", "none");
167
+ }
168
+ private async request(fn: GeoFunction, params: URLSearchParams, key: string, locale: string, signal?: AbortSignal, body?: unknown) {
169
+ const parsed = collection.safeParse(await this.requestJson(fn, params, key, locale, signal, body));
170
+ if (!parsed.success) throw new GeoProviderError("GEO_PROVIDER_FAILED", "known");
171
+ return parsed.data.features.map((feature) => feature.properties);
172
+ }
173
+ async route(
174
+ stops: readonly GeoRouteResolvedStop[],
175
+ options: GeoRouteOptions,
176
+ optimize: boolean,
177
+ key: string,
178
+ locale: string,
179
+ signal?: AbortSignal,
180
+ ) {
181
+ const params = new URLSearchParams({
182
+ waypoints: stops.map((stop) => `${stop.point.lat},${stop.point.lon}`).join("|"),
183
+ mode: options.mode === "bike" ? "bicycle" : options.mode === "walk" ? "walk" : "drive",
184
+ units: "metric",
185
+ traffic: "free_flow",
186
+ intermediate_waypoint_mode: "stopover",
187
+ optimize_stops: String(optimize),
188
+ });
189
+ if (options.avoid?.length) params.set("avoid", options.avoid.join("|"));
190
+ return parseGeoapifyRoute(await this.requestJson("route", params, key, locale, signal), stops, options, optimize, this.provenance());
191
+ }
192
+ private provenance() {
193
+ return geoProvenance((this.options.now ?? (() => new Date()))());
194
+ }
195
+ async autocomplete(input: GeoAutocompleteInput, key: string, locale: string, signal?: AbortSignal) {
196
+ const params = new URLSearchParams({ text: input.text, limit: String(input.limit ?? 5) });
197
+ if (input.type) params.set("type", input.type);
198
+ const filters = [
199
+ input.countryCode ? `countrycode:${input.countryCode.toLowerCase()}` : null,
200
+ input.area ? areaFilter(input.area) : null,
201
+ ].filter((v) => v !== null);
202
+ if (filters.length) params.set("filter", filters.join("|"));
203
+ if (input.bias) params.set("bias", `proximity:${input.bias.lon},${input.bias.lat}`);
204
+ const values = await this.request("autocomplete", params, key, locale, signal);
205
+ const provenance = this.provenance();
206
+ return { suggestions: values.slice(0, input.limit ?? 5).map((p) => address(p, provenance)), provenance };
207
+ }
208
+ async geocode(input: GeoGeocodeInput, key: string, locale: string, signal?: AbortSignal) {
209
+ const params = new URLSearchParams({ limit: "5" });
210
+ if (input.text) params.set("text", input.text);
211
+ if (input.fields)
212
+ for (const [field, name] of [
213
+ ["line1", "street"],
214
+ ["postalCode", "postcode"],
215
+ ["city", "city"],
216
+ ["stateRegion", "state"],
217
+ ["country", "country"],
218
+ ] as const) {
219
+ const value = input.fields[field];
220
+ if (value) params.set(name, value);
221
+ }
222
+ if (input.countryCode) params.set("filter", `countrycode:${input.countryCode.toLowerCase()}`);
223
+ const raw = await this.request("geocode", params, key, locale, signal);
224
+ const provenance = this.provenance();
225
+ // No unverified uniqueness claim: all candidates with confidence >= .5 are plausible.
226
+ const candidates = raw
227
+ .slice(0, 5)
228
+ .filter((p) => (p.rank?.confidence ?? 0) >= 0.5)
229
+ .map((p) => address(p, provenance));
230
+ return { candidates, provenance };
231
+ }
232
+ async reverse(input: GeoReverseInput, key: string, locale: string, signal?: AbortSignal) {
233
+ const raw = await this.request(
234
+ "reverse",
235
+ new URLSearchParams({ lat: String(input.point.lat), lon: String(input.point.lon), limit: "1" }),
236
+ key,
237
+ locale,
238
+ signal,
239
+ );
240
+ const provenance = this.provenance();
241
+ return { address: raw[0] ? address(raw[0], provenance) : null, provenance };
242
+ }
243
+ async places(input: GeoPlaceSearchInput, key: string, locale: string, signal?: AbortSignal, line?: readonly GeoPoint[]) {
244
+ const categories = input.categories.map((category) => GEO_CATEGORIES[category]);
245
+ const params = new URLSearchParams({ limit: String(input.limit ?? 20) });
246
+ let body: unknown;
247
+ if (input.along && line)
248
+ body = {
249
+ categories,
250
+ limit: input.limit ?? 20,
251
+ filter: {
252
+ type: "polyline",
253
+ geometry: { type: "LineString", coordinates: line.map((p) => [p.lon, p.lat]) },
254
+ buffer: input.along.bufferMeters ?? 1000,
255
+ },
256
+ };
257
+ else {
258
+ params.set("categories", categories.join(","));
259
+ if (input.near) params.set("bias", `proximity:${input.near.lon},${input.near.lat}`);
260
+ if (input.area) params.set("filter", areaFilter(input.area));
261
+ }
262
+ const raw = await this.request("places", params, key, locale, signal, body);
263
+ const provenance = this.provenance();
264
+ const places: GeoPlace[] = raw.slice(0, input.limit ?? 20).map((p) => {
265
+ if (!p.place_id) throw new GeoProviderError("GEO_PROVIDER_FAILED", "known");
266
+ return {
267
+ id: p.place_id,
268
+ name: p.name ?? p.address_line1 ?? "",
269
+ categories: p.categories ?? [],
270
+ point: { lat: p.lat, lon: p.lon },
271
+ address: address(p, provenance),
272
+ distanceMeters: p.distance,
273
+ openingHours: p.opening_hours ?? p.datasource?.raw?.opening_hours,
274
+ website: p.website ?? p.datasource?.raw?.website,
275
+ phone: p.contact?.phone ?? p.datasource?.raw?.phone,
276
+ provenance,
277
+ };
278
+ });
279
+ return { places, provenance };
280
+ }
281
+ }
@@ -0,0 +1,122 @@
1
+ import { type SQL, sql } from "bun";
2
+ import type { AccessSubject, CapabilityExecutionContext } from "../../contracts";
3
+ import { audit } from "../audit";
4
+ import { GeoError } from "./errors";
5
+ import type { GeoConfig } from "./settings";
6
+
7
+ export type GeoBudgetConfig = Pick<GeoConfig, "timezone" | "monthlyCredits" | "personDailyCredits">;
8
+ export type GeoPeriod = { period: string; resetsAt: string };
9
+ export const geoPeriods = (now: Date, timezone: string): { person: GeoPeriod; cloud: GeoPeriod } => {
10
+ const format = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" });
11
+ const local = (at: number) => {
12
+ const parts = format.formatToParts(new Date(at));
13
+ return ["year", "month", "day"].map((type) => parts.find((p) => p.type === type)!.value).join("-");
14
+ };
15
+ const day = local(now.getTime());
16
+ const boundary = (width: number, length: number): string => {
17
+ const current = day.slice(0, length);
18
+ let low = now.getTime(),
19
+ high = low + width;
20
+ while (high - low > 1) {
21
+ const mid = Math.floor((low + high) / 2);
22
+ if (local(mid).slice(0, length) === current) low = mid;
23
+ else high = mid;
24
+ }
25
+ return new Date(high).toISOString();
26
+ };
27
+ return {
28
+ person: { period: day, resetsAt: boundary(2 * 86400_000, 10) },
29
+ cloud: { period: day.slice(0, 7), resetsAt: boundary(32 * 86400_000, 7) },
30
+ };
31
+ };
32
+ export const geoSubjectKey = (subject: AccessSubject) =>
33
+ subject.type === "user" ? `user:${subject.userId}` : `service_account:${subject.serviceAccountId}`;
34
+ export type GeoReservation = {
35
+ cost: number;
36
+ group: string;
37
+ rows: Array<{ scope: "cloud" | "person"; subject: string; period: string; resetsAt: string; limit: number }>;
38
+ };
39
+ const rowsFor = (subject: AccessSubject, config: GeoBudgetConfig, now: Date) => {
40
+ const periods = geoPeriods(now, config.timezone);
41
+ return [
42
+ { scope: "cloud", subject: "*", ...periods.cloud, limit: config.monthlyCredits * 1000 },
43
+ { scope: "person", subject: geoSubjectKey(subject), ...periods.person, limit: config.personDailyCredits * 1000 },
44
+ ] satisfies GeoReservation["rows"];
45
+ };
46
+ export const createGeoLedger = (db: SQL = sql) => ({
47
+ async reserve(subject: AccessSubject, config: GeoBudgetConfig, cost: number, group: string, now: Date): Promise<GeoReservation> {
48
+ if (!Number.isSafeInteger(cost) || cost < 0) throw new Error("Invalid geo reservation");
49
+ const rows = rowsFor(subject, config, now);
50
+ await db.begin(async (tx) => {
51
+ // Fixed order across upserts, updates, shares and settlement: Cloud first, person second.
52
+ for (const row of rows)
53
+ await tx`INSERT INTO geo.usage(scope,subject,period) VALUES(${row.scope},${row.subject},${row.period}) ON CONFLICT DO NOTHING`;
54
+ for (const row of rows) {
55
+ const changed = await tx`UPDATE geo.usage SET used=used+${cost} WHERE scope=${row.scope} AND subject=${row.subject}
56
+ 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 });
58
+ }
59
+ for (const row of rows)
60
+ await tx`INSERT INTO geo.shares(scope,subject,period,function_group,used)
61
+ VALUES(${row.scope},${row.subject},${row.period},${group},${cost})
62
+ ON CONFLICT(scope,subject,period,function_group) DO UPDATE SET used=geo.shares.used+EXCLUDED.used`;
63
+ });
64
+ return { cost, group, rows };
65
+ },
66
+ async settle(reservation: GeoReservation, referenceCost: number) {
67
+ const refund = reservation.cost - referenceCost;
68
+ if (!Number.isSafeInteger(referenceCost) || referenceCost < 0 || !Number.isSafeInteger(refund))
69
+ throw new Error("Invalid geo settlement");
70
+ if (!refund) return;
71
+ // Negative refunds truthfully debit known routing overruns. Admission still uses the original
72
+ // bounded CAS transaction; future reservations see the overdrawn counters and are refused.
73
+ // Settle once per reservation, in the same fixed row order as admission.
74
+ await db.begin(async (tx) => {
75
+ for (const row of reservation.rows) {
76
+ await tx`UPDATE geo.usage SET used=used-${refund} WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period}`;
77
+ await tx`UPDATE geo.shares SET used=used-${refund} WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period} AND function_group=${reservation.group}`;
78
+ }
79
+ });
80
+ },
81
+ async availability(subject: AccessSubject, config: GeoBudgetConfig, now: Date) {
82
+ const balances = [];
83
+ for (const row of rowsFor(subject, config, now)) {
84
+ const [used] = await db<{ used: number }[]>`SELECT used::float8 AS used FROM geo.usage
85
+ WHERE scope=${row.scope} AND subject=${row.subject} AND period=${row.period}`;
86
+ balances.push({ exhausted: (used?.used ?? 0) >= row.limit, resetsAt: row.resetsAt });
87
+ }
88
+ return { cloud: balances[0]!, person: balances[1]! };
89
+ },
90
+ async recordTransition(
91
+ event: "provider_key_rejected" | "monthly_budget_exhausted",
92
+ timezone: string,
93
+ context: Pick<CapabilityExecutionContext, "actor"> & Partial<Pick<CapabilityExecutionContext, "requestId">>,
94
+ now: Date,
95
+ ) {
96
+ const period = geoPeriods(now, timezone).cloud.period;
97
+ await db.begin(async (tx) => {
98
+ const inserted =
99
+ await tx`INSERT INTO geo.audit_transitions(event,period) VALUES(${event},${period}) ON CONFLICT DO NOTHING RETURNING event`;
100
+ if (!inserted.length) return;
101
+ 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 };
110
+ await audit.record(
111
+ {
112
+ action: `core.geo.${event}`,
113
+ outcome: event === "provider_key_rejected" ? "failed" : "denied",
114
+ actor,
115
+ requestId: context.requestId,
116
+ metadata: { period },
117
+ },
118
+ tx,
119
+ );
120
+ });
121
+ },
122
+ });
@@ -0,0 +1,160 @@
1
+ import { mapAttribution } from "@k2b/ui";
2
+ import { GEO_MAP_MAX_ZOOM, GEO_MAX_TILE_BYTES, GeoMapStyleInputSchema } from "../../contracts/geo-map";
3
+ import { EMPTY_NETWORK_ALLOWLIST } from "../../shared/network-address";
4
+ import { createAllowedNetworkFetch } from "../allowed-network-fetch";
5
+ import { createGeoBucket, GEO_TIMEOUT_MS } from "./bucket";
6
+ import { GeoError, GeoProviderError } from "./errors";
7
+ import { type GeoFetch, geoNeverSent } from "./geoapify";
8
+ import { createGeoLedger } from "./ledger";
9
+ import { type GeoContext, geoIdentity } from "./service";
10
+ import { type GeoConfig, geoPlan, readGeoConfig } from "./settings";
11
+
12
+ const guardedFetch = createAllowedNetworkFetch(EMPTY_NETWORK_ALLOWLIST);
13
+
14
+ export const createGeoMapService = (
15
+ options: {
16
+ config?: () => Promise<GeoConfig>;
17
+ now?: () => Date;
18
+ fetch?: GeoFetch;
19
+ bucket?: ReturnType<typeof createGeoBucket>;
20
+ ledger?: ReturnType<typeof createGeoLedger>;
21
+ } = {},
22
+ ) => {
23
+ const config = options.config ?? readGeoConfig;
24
+ const now = options.now ?? (() => new Date());
25
+ const bucket = options.bucket ?? createGeoBucket();
26
+ const ledger = options.ledger ?? createGeoLedger();
27
+ const configured = async (context: GeoContext) => {
28
+ geoIdentity(context);
29
+ const settings = await config();
30
+ if (!settings.apiKey.trim()) throw new GeoError("GEO_NOT_CONFIGURED");
31
+ return settings;
32
+ };
33
+ return {
34
+ async style(input: { theme?: "light" | "dark" }, context: GeoContext) {
35
+ await configured(context);
36
+ const parsed = GeoMapStyleInputSchema.safeParse(input);
37
+ if (!parsed.success) throw new GeoError("GEO_UNSUPPORTED_OPTION");
38
+ const locale = new Intl.Locale(context.locale ?? "en").language;
39
+ return { styleUrl: `/api/geo/style?theme=${parsed.data.theme}&locale=${locale}`, attribution: [...mapAttribution] };
40
+ },
41
+ async tile(input: { z: number; x: number; y: number }, context: GeoContext): Promise<Uint8Array<ArrayBuffer>> {
42
+ const settings = await configured(context);
43
+ const identity = geoIdentity(context);
44
+ const { z, x, y } = input;
45
+ if (![z, x, y].every(Number.isInteger) || z < 0 || z > GEO_MAP_MAX_ZOOM || x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z)
46
+ throw new GeoError("GEO_UNSUPPORTED_OPTION");
47
+ const deadline = AbortSignal.timeout(GEO_TIMEOUT_MS.tiles);
48
+ const signal = context.signal ? AbortSignal.any([context.signal, deadline]) : deadline;
49
+ const release = await bucket.acquire("tiles", geoPlan(settings).rps * 4);
50
+ let reservation: Awaited<ReturnType<typeof ledger.reserve>> | undefined;
51
+ let charge: "none" | "known" | "unknown" = "none";
52
+ try {
53
+ signal.throwIfAborted();
54
+ reservation = await ledger.reserve(identity.accessSubject, settings, 250, "tiles", now());
55
+ const url = new URL(`/v1/tile/osm-bright/${z}/${x}/${y}.pbf`, "https://api-eu.geoapify.com");
56
+ let response: Response | undefined;
57
+ for (let attempt = 0; attempt < 2; attempt++) {
58
+ signal.throwIfAborted();
59
+ charge = "unknown";
60
+ try {
61
+ response = await (options.fetch ?? guardedFetch)(url, {
62
+ headers: { "x-api-key": settings.apiKey, accept: "application/x-protobuf" },
63
+ credentials: "omit",
64
+ redirect: "error",
65
+ signal,
66
+ });
67
+ break;
68
+ } catch (cause) {
69
+ if (!signal.aborted && geoNeverSent(cause)) {
70
+ charge = "none";
71
+ if (attempt === 0) continue;
72
+ }
73
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", charge);
74
+ }
75
+ }
76
+ if (!response) throw new GeoProviderError("GEO_PROVIDER_FAILED", charge);
77
+ charge = "known";
78
+ if (!response.ok) {
79
+ void response.body?.cancel().catch(() => {});
80
+ throw new GeoProviderError(
81
+ response.status === 429 ? "GEO_BUSY" : "GEO_PROVIDER_FAILED",
82
+ charge,
83
+ [401, 403].includes(response.status),
84
+ );
85
+ }
86
+ const type = response.headers.get("content-type")?.split(";")[0]?.trim();
87
+ if (
88
+ !type ||
89
+ !["application/x-protobuf", "application/vnd.mapbox-vector-tile", "application/octet-stream"].includes(type) ||
90
+ Number(response.headers.get("content-length")) > GEO_MAX_TILE_BYTES
91
+ ) {
92
+ void response.body?.cancel().catch(() => {});
93
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", charge);
94
+ }
95
+ const reader = response.body?.getReader();
96
+ if (!reader) throw new GeoProviderError("GEO_PROVIDER_FAILED", charge);
97
+ const abort = () => {
98
+ void reader.cancel().catch(() => {});
99
+ };
100
+ signal.addEventListener("abort", abort, { once: true });
101
+ const buffer = new Uint8Array(GEO_MAX_TILE_BYTES);
102
+ let size = 0;
103
+ try {
104
+ if (signal.aborted) abort();
105
+ while (true) {
106
+ const next = await reader.read();
107
+ if (next.done) break;
108
+ if (size + next.value.byteLength > GEO_MAX_TILE_BYTES) {
109
+ abort();
110
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", charge);
111
+ }
112
+ buffer.set(next.value, size);
113
+ size += next.value.byteLength;
114
+ }
115
+ signal.throwIfAborted();
116
+ } finally {
117
+ signal.removeEventListener("abort", abort);
118
+ reader.releaseLock();
119
+ }
120
+ const bytes = buffer.subarray(0, size);
121
+ // Never forward an echoed credential, including one spanning streamed chunks.
122
+ if (Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).includes(Buffer.from(settings.apiKey)))
123
+ throw new GeoProviderError("GEO_PROVIDER_FAILED", charge);
124
+ return bytes;
125
+ } catch (cause) {
126
+ if (reservation && charge === "none") {
127
+ try {
128
+ await ledger.settle(reservation, 0);
129
+ } catch {
130
+ charge = "unknown";
131
+ }
132
+ }
133
+ if (reservation && charge !== "none" && (!(cause instanceof GeoError) || signal.aborted)) charge = "unknown";
134
+ const error = cause instanceof GeoError ? cause : new GeoError("GEO_PROVIDER_FAILED");
135
+ const transition =
136
+ cause instanceof GeoProviderError && cause.keyRejected
137
+ ? "provider_key_rejected"
138
+ : error.code === "GEO_BUDGET_EXHAUSTED" && error.details.scope === "cloud"
139
+ ? "monthly_budget_exhausted"
140
+ : null;
141
+ if (transition)
142
+ await ledger.recordTransition(transition, settings.timezone, { ...context, ...identity }, now()).catch(() => {
143
+ console.error("[geo] Map audit transition could not be recorded", { event: transition });
144
+ });
145
+ if (error.code === "GEO_PROVIDER_FAILED" || error.code === "GEO_BUSY")
146
+ throw new GeoError(error.code, {
147
+ ...error.details,
148
+ costs: {
149
+ settledCredits: reservation && charge === "known" ? 0.25 : 0,
150
+ unknownCredits: reservation && charge === "unknown" ? 0.25 : 0,
151
+ },
152
+ });
153
+ throw error;
154
+ } finally {
155
+ await release();
156
+ }
157
+ },
158
+ };
159
+ };
160
+ export const coreGeoMap = createGeoMapService();
@@ -0,0 +1,25 @@
1
+ import { type SQL, sql } from "bun";
2
+ export const migrate = async (db: SQL = sql): Promise<void> => {
3
+ await db.begin(async (tx) => {
4
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('core.geo.migrations', 0))`;
5
+ await tx`CREATE SCHEMA IF NOT EXISTS geo`.simple();
6
+ await tx`CREATE TABLE IF NOT EXISTS geo.usage (
7
+ scope text NOT NULL CHECK (scope IN ('cloud', 'person')),
8
+ subject text NOT NULL,
9
+ period text NOT NULL,
10
+ used bigint NOT NULL DEFAULT 0 CHECK (used >= 0),
11
+ PRIMARY KEY (scope, subject, period)
12
+ )`.simple();
13
+ // The administration reads one local day across all people.
14
+ await tx`CREATE INDEX IF NOT EXISTS geo_usage_scope_period ON geo.usage (scope, period)`.simple();
15
+ await tx`CREATE TABLE IF NOT EXISTS geo.shares (
16
+ scope text NOT NULL, subject text NOT NULL, period text NOT NULL,
17
+ function_group text NOT NULL, used bigint NOT NULL DEFAULT 0 CHECK (used >= 0),
18
+ PRIMARY KEY (scope, subject, period, function_group),
19
+ FOREIGN KEY (scope, subject, period) REFERENCES geo.usage(scope, subject, period)
20
+ )`.simple();
21
+ await tx`CREATE TABLE IF NOT EXISTS geo.audit_transitions (
22
+ event text NOT NULL, period text NOT NULL, PRIMARY KEY (event, period)
23
+ )`.simple();
24
+ });
25
+ };