@k2b/cloud 0.45.0 → 0.47.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 (131) hide show
  1. package/package.json +11 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-queries.ts +73 -0
  5. package/src/_internal/capabilities.ts +137 -9
  6. package/src/_internal/define-app.ts +40 -0
  7. package/src/_internal/heartbeat.ts +19 -0
  8. package/src/_internal/postgres-application-name.ts +11 -0
  9. package/src/_internal/process-sync.ts +9 -0
  10. package/src/_internal/readiness.ts +6 -1
  11. package/src/_internal/registry-validation.ts +17 -4
  12. package/src/_internal/registry.ts +32 -4
  13. package/src/_internal/resource-principals.ts +6 -0
  14. package/src/_internal/workload-credential.ts +153 -0
  15. package/src/_internal/workload-protocol.ts +36 -0
  16. package/src/ai/application-tasks.ts +5 -2
  17. package/src/ai/capabilities.ts +4 -2
  18. package/src/ai/chart-block.ts +36 -711
  19. package/src/ai/chat/map-messages.ts +270 -0
  20. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  21. package/src/ai/chat/map-notebook.tsx +301 -0
  22. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  23. package/src/ai/chat/map-widget.css +107 -0
  24. package/src/ai/chat/map-widget.tsx +1279 -0
  25. package/src/ai/chat/message-utils.ts +1 -0
  26. package/src/ai/chat/tool-names.ts +2 -0
  27. package/src/ai/chat/turn-layout.ts +2 -1
  28. package/src/ai/chat/turn-view.tsx +17 -1
  29. package/src/ai/default-tool-contracts.ts +21 -259
  30. package/src/ai/default-tools.ts +3 -0
  31. package/src/ai/executor.ts +13 -1
  32. package/src/ai/map-contracts.ts +209 -0
  33. package/src/ai/map-execution.ts +14 -0
  34. package/src/ai/map-notebook-save.ts +276 -0
  35. package/src/ai/map-notebook.ts +32 -0
  36. package/src/ai/map-opening-hours.ts +40 -0
  37. package/src/ai/map-routes.ts +84 -0
  38. package/src/ai/map-service.ts +288 -0
  39. package/src/ai/map-store.ts +137 -0
  40. package/src/ai/map-tool.ts +73 -0
  41. package/src/ai/migrate.ts +3 -0
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/routes.ts +2 -0
  44. package/src/ai/store.ts +19 -6
  45. package/src/ai/tools.ts +14 -0
  46. package/src/ai/types.ts +3 -0
  47. package/src/api/admin-app-keys.ts +66 -0
  48. package/src/api/admin-core-settings.ts +2 -0
  49. package/src/api/admin-identity.ts +27 -1
  50. package/src/api/admin-settings-env.ts +120 -0
  51. package/src/api/capabilities.ts +99 -15
  52. package/src/api/index.ts +3 -0
  53. package/src/api/mcp.ts +4 -2
  54. package/src/api/settings-ops.ts +35 -0
  55. package/src/app-key.ts +125 -0
  56. package/src/browser/CloudResourceSearch.tsx +48 -41
  57. package/src/browser/notifications.ts +25 -8
  58. package/src/capabilities/discussion-providers.ts +100 -0
  59. package/src/capabilities/executions.ts +2 -2
  60. package/src/capabilities/migrate.ts +10 -1
  61. package/src/capabilities/resource-readers.ts +69 -0
  62. package/src/capabilities/response.ts +4 -1
  63. package/src/capabilities/server-invoke.ts +64 -36
  64. package/src/capabilities/server.ts +17 -6
  65. package/src/capabilities/testing.ts +13 -0
  66. package/src/capabilities/types.ts +1 -1
  67. package/src/cli/admin/app-credentials.ts +32 -7
  68. package/src/cli/admin/app-keys.ts +226 -0
  69. package/src/cli/admin/index.ts +7 -1
  70. package/src/cli/admin/settings.ts +62 -0
  71. package/src/config/define-env.ts +63 -8
  72. package/src/config/env.ts +27 -21
  73. package/src/config/index.ts +12 -1
  74. package/src/config/workflow-ai.ts +28 -0
  75. package/src/contracts/capabilities.ts +137 -4
  76. package/src/contracts/discussions.ts +91 -0
  77. package/src/contracts/geo-document.ts +60 -0
  78. package/src/contracts/geo-snapshot.ts +86 -0
  79. package/src/contracts/geo.ts +14 -0
  80. package/src/contracts/index.ts +2 -0
  81. package/src/contracts/outgoing-mail.ts +1 -1
  82. package/src/contracts/resource-types.ts +21 -0
  83. package/src/contracts/settings-env.ts +21 -0
  84. package/src/contracts/settings-types.ts +6 -0
  85. package/src/geo/AddressAutocomplete.tsx +213 -0
  86. package/src/geo/messages.ts +29 -0
  87. package/src/geo/navigation.ts +25 -0
  88. package/src/geo/ui.ts +2 -0
  89. package/src/index.ts +9 -0
  90. package/src/runtime/clients.ts +72 -0
  91. package/src/runtime/connections.ts +37 -0
  92. package/src/runtime/resolve-connections.ts +91 -0
  93. package/src/server/client-ip.ts +1 -1
  94. package/src/services/accounts/app.ts +28 -6
  95. package/src/services/accounts/entities.ts +11 -4
  96. package/src/services/accounts/groups.ts +10 -4
  97. package/src/services/accounts/local-groups.ts +87 -38
  98. package/src/services/accounts/machine-group-management.ts +94 -0
  99. package/src/services/app-keys.ts +298 -0
  100. package/src/services/gateway.ts +6 -0
  101. package/src/services/geo/cache.ts +2 -1
  102. package/src/services/geo/geoapify.ts +3 -0
  103. package/src/services/identity/index.ts +3 -0
  104. package/src/services/identity/invocation-token.ts +110 -0
  105. package/src/services/identity/resource-readers.ts +80 -0
  106. package/src/services/identity/runtime-config.ts +14 -6
  107. package/src/services/identity/workload-runtime.ts +253 -0
  108. package/src/services/index.ts +4 -1
  109. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  110. package/src/services/pdf/geo-snapshot.ts +107 -0
  111. package/src/services/pdf/index.ts +2 -0
  112. package/src/services/service-account-credentials.ts +63 -21
  113. package/src/services/settings/api.ts +3 -3
  114. package/src/services/settings/binding.ts +45 -0
  115. package/src/services/settings/core-settings.ts +99 -1
  116. package/src/services/settings/defaults.ts +16 -2
  117. package/src/services/settings/env-status.ts +69 -0
  118. package/src/services/settings/index.ts +69 -26
  119. package/src/services/settings/store.ts +73 -8
  120. package/src/services/settings/validation.ts +40 -0
  121. package/src/shared/index.ts +11 -10
  122. package/src/shared/markdown/extensions/tables.ts +10 -1
  123. package/src/ssr/AdminArea.tsx +2 -2
  124. package/src/ssr/AdminLayout.tsx +1 -1
  125. package/src/ssr/AdminSidebar.tsx +1 -1
  126. package/src/start.ts +36 -0
  127. package/src/styles/global.css +2 -0
  128. package/src/workflows/ai/index.ts +1 -0
  129. package/src/workflows/ai/runtime.ts +16 -8
  130. package/src/workflows/ai/settings.ts +5 -0
  131. package/src/shared/markdown/formula.ts +0 -1283
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import { GeoPlaceSchema, GeoPointSchema, GeoProvenanceSchema } from "./geo";
3
+ import { GeoRouteSchema } from "./geo-route";
4
+
5
+ // One place.search result fits a saved map. Routes carry their own bounded stops.
6
+ export const GEO_DOCUMENT_MAX_PINS = 20;
7
+ const shortId = z.string().regex(/^[A-Za-z0-9]{6}$/);
8
+ const label = z.string().trim().min(1).max(300);
9
+ export const GeoDocumentLinkSchema = z.discriminatedUnion("type", [
10
+ z.object({ type: z.literal("url"), url: z.url({ protocol: /^https?$/ }).max(2000), label }).strict(),
11
+ z.object({ type: z.literal("note"), noteId: shortId, label }).strict(),
12
+ z.object({ type: z.literal("heading"), noteId: shortId, heading: z.string().min(1).max(300), label }).strict(),
13
+ ]);
14
+ export const GeoDocumentPinSchema = z
15
+ .object({
16
+ id: GeoPlaceSchema.shape.id,
17
+ label,
18
+ point: GeoPointSchema,
19
+ provenance: GeoProvenanceSchema,
20
+ link: GeoDocumentLinkSchema.optional(),
21
+ })
22
+ .strict();
23
+ export const GeoDocumentSchema = z
24
+ .object({
25
+ version: z.literal(1),
26
+ title: label,
27
+ savedAt: z.iso.datetime().optional(),
28
+ pins: z.array(GeoDocumentPinSchema).max(GEO_DOCUMENT_MAX_PINS),
29
+ route: GeoRouteSchema.optional(),
30
+ })
31
+ .strict()
32
+ .superRefine((document, context) => {
33
+ // Each coordinate consists of up to ten continuation bytes and one terminator.
34
+ // Complete latitude/longitude pairs keep malformed authored geometry out of map renderers.
35
+ if (document.route && !/^(?:[_-~]{0,10}[?-^][_-~]{0,10}[?-^])+$/.test(document.route.polyline6)) {
36
+ context.addIssue({
37
+ code: "custom",
38
+ path: ["route", "polyline6"],
39
+ message: "Route geometry must contain complete polyline6 coordinate pairs.",
40
+ });
41
+ }
42
+ const ids = new Set<string>();
43
+ document.pins.forEach((pin, index) => {
44
+ if (ids.has(pin.id)) context.addIssue({ code: "custom", path: ["pins", index, "id"], message: "Pin IDs must be unique." });
45
+ ids.add(pin.id);
46
+ });
47
+ });
48
+ export type GeoDocument = z.infer<typeof GeoDocumentSchema>;
49
+ export type GeoDocumentPin = z.infer<typeof GeoDocumentPinSchema>;
50
+ export type GeoDocumentLink = z.infer<typeof GeoDocumentLinkSchema>;
51
+
52
+ /** A portable Notebook block. Escaped JSON cannot close the directive or create HTML. */
53
+ export function serializeGeoDocument(input: GeoDocument): string {
54
+ const document = GeoDocumentSchema.parse(input);
55
+ const json = JSON.stringify(document).replace(
56
+ /[<>&\u2028\u2029]/g,
57
+ (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
58
+ );
59
+ return `:::map\n${json}\n:::`;
60
+ }
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+
3
+ // Matches the geo result JSON envelope and the existing print decoder budgets.
4
+ export const GEO_SNAPSHOT_MAX_METADATA_BYTES = 256 * 1024;
5
+ export const GEO_SNAPSHOT_MAX_PIXELS = 16 * 1024 * 1024;
6
+ export const GEO_SNAPSHOT_MAX_BYTES = 32 * 1024 * 1024;
7
+ export const GeoMapSnapshotIdSchema = z
8
+ .string()
9
+ .min(1)
10
+ .max(100)
11
+ .regex(/^[A-Za-z0-9_-]+$/);
12
+ export const GeoMapSnapshotImageSchema = z
13
+ .strictObject({
14
+ id: GeoMapSnapshotIdSchema,
15
+ fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
16
+ width: z.number().int().min(1).max(16383),
17
+ height: z.number().int().min(1).max(16383),
18
+ })
19
+ .refine((image) => image.width * image.height <= GEO_SNAPSHOT_MAX_PIXELS, "Map image exceeds decoded pixel budget");
20
+ export const GeoMapSnapshotManifestSchema = z
21
+ .strictObject({
22
+ version: z.literal(1),
23
+ revision: z.string().min(1).max(300),
24
+ // Even IDs with one character need a 64-byte SHA-256: this bounds schema work independently of input parsing.
25
+ images: z.array(GeoMapSnapshotImageSchema).max(GEO_SNAPSHOT_MAX_METADATA_BYTES / 64),
26
+ })
27
+ .refine((value) => new Set(value.images.map((image) => image.id)).size === value.images.length, "Duplicate map ID");
28
+ export type GeoMapSnapshotManifest = z.infer<typeof GeoMapSnapshotManifestSchema>;
29
+ export const geoMapSnapshotPart = (id: string) => `map-snapshot-${GeoMapSnapshotIdSchema.parse(id)}`;
30
+
31
+ /** Hash the validated saved GeoDocument, not a lossy geometry projection. Same algorithm in browser and server. */
32
+ export async function geoMapSnapshotFingerprint(value: unknown): Promise<string> {
33
+ // First enforce genuine JSON (and bound traversal) without invoking caller-defined toJSON/accessors.
34
+ let remaining = GEO_SNAPSHOT_MAX_METADATA_BYTES;
35
+ const ancestors = new Set<object>();
36
+ const canonical = (input: unknown): string => {
37
+ if (--remaining < 0) throw new RangeError("Map snapshot input exceeds geo JSON budget");
38
+ if (
39
+ input === null ||
40
+ typeof input === "boolean" ||
41
+ typeof input === "string" ||
42
+ (typeof input === "number" && Number.isFinite(input))
43
+ ) {
44
+ const result = JSON.stringify(input);
45
+ remaining -= result.length;
46
+ return result;
47
+ }
48
+ if (typeof input !== "object" || !input || ancestors.has(input)) throw new TypeError("Map snapshot input must be JSON");
49
+ if (!Array.isArray(input) && Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null)
50
+ throw new TypeError("Map snapshot input must be plain JSON");
51
+ ancestors.add(input);
52
+ try {
53
+ if (Array.isArray(input)) {
54
+ if (input.length > remaining) throw new RangeError("Map snapshot input exceeds geo JSON budget");
55
+ const items: string[] = [];
56
+ for (let index = 0; index < input.length; index++) {
57
+ const property = Object.getOwnPropertyDescriptor(input, String(index));
58
+ if (!property || !("value" in property)) throw new TypeError("Map snapshot input must be plain JSON");
59
+ items.push(canonical(property.value));
60
+ }
61
+ return `[${items.join(",")}]`;
62
+ }
63
+ return `{${Object.keys(input)
64
+ .sort()
65
+ .map((key) => {
66
+ const property = Object.getOwnPropertyDescriptor(input, key)!;
67
+ if (!("value" in property)) throw new TypeError("Map snapshot input must not contain accessors");
68
+ remaining -= key.length;
69
+ return `${JSON.stringify(key)}:${canonical(property.value)}`;
70
+ })
71
+ .join(",")}}`;
72
+ } finally {
73
+ ancestors.delete(input);
74
+ }
75
+ };
76
+ let serialized: string;
77
+ try {
78
+ serialized = canonical(value);
79
+ } catch (error) {
80
+ if (error instanceof RangeError) throw new RangeError("Map snapshot input exceeds geo JSON budget");
81
+ throw error;
82
+ }
83
+ const bytes = new TextEncoder().encode(serialized);
84
+ if (bytes.byteLength > GEO_SNAPSHOT_MAX_METADATA_BYTES) throw new RangeError("Map snapshot input exceeds geo JSON budget");
85
+ return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
86
+ }
@@ -30,6 +30,12 @@ export const GeoAddressSchema = z
30
30
  point: GeoPointSchema.describe("Resolved geographic point."),
31
31
  formatted: z.string().max(600).describe("Formatted provider address."),
32
32
  confidence: z.number().min(0).max(1).nullable().describe("Provider match confidence, or null when unavailable."),
33
+ buildingConfidence: z
34
+ .number()
35
+ .min(0)
36
+ .max(1)
37
+ .nullable()
38
+ .describe("Provider certainty that this point is the requested building; null without a house-number match."),
33
39
  provenance: GeoProvenanceSchema.describe("Provider source and required attribution."),
34
40
  })
35
41
  .strict();
@@ -128,6 +134,13 @@ export const GEO_MAX_POLYLINE_CHARS = 102_400;
128
134
  export const GeoPlaceSearchInputSchema = z
129
135
  .object({
130
136
  categories: z.array(GeoCategorySchema).describe("1–5 Cloud categories."),
137
+ name: z
138
+ .string()
139
+ .trim()
140
+ .min(1)
141
+ .max(300)
142
+ .optional()
143
+ .describe("Optional provider place-name filter, 1–300 characters after trimming; categories still apply."),
131
144
  near: GeoPointSchema.optional().describe("Rank by distance to this point; choose near, area, or along."),
132
145
  area: GeoAreaSchema.optional().describe("Optional geographic filter."),
133
146
  along: z
@@ -249,6 +262,7 @@ export type GeoAutocompleteInput = z.infer<typeof GeoAutocompleteInputSchema>;
249
262
  export type GeoGeocodeInput = z.infer<typeof GeoGeocodeInputSchema>;
250
263
  export type GeoReverseInput = z.infer<typeof GeoReverseInputSchema>;
251
264
  export type GeoPlaceSearchInput = z.infer<typeof GeoPlaceSearchInputSchema>;
265
+ export type GeoAvailabilityData = z.infer<typeof GeoAvailabilityDataSchema>;
252
266
  export type GeoErrorCode = z.infer<typeof GeoErrorDetailsSchema>["code"];
253
267
  export type GeoFunction = "autocomplete" | "geocode" | "reverse" | "places" | "route";
254
268
  export const geoContract = {
@@ -10,6 +10,7 @@ export * from "./capabilities";
10
10
  export * from "./capability-compatibility";
11
11
  export * from "./commands";
12
12
  export * from "./contact-directory";
13
+ export * from "./discussions";
13
14
  export * from "./file-provider";
14
15
  export * from "./geo";
15
16
  export * from "./geo-admin";
@@ -29,6 +30,7 @@ export type { RailShortcut } from "./rail-preferences";
29
30
  export * from "./registry";
30
31
  export * from "./resource-clipboard";
31
32
  export { type ResolvedCloudResourceUrl, ResolvedCloudResourceUrlSchema } from "./resource-resolution";
33
+ export * from "./resource-types";
32
34
  export * from "./seen-hints";
33
35
  export * from "./shared";
34
36
  export * from "./user-notifications";
@@ -2,7 +2,7 @@ import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
2
2
  import { z } from "zod";
3
3
  import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
4
4
 
5
- export const PlatformPermissionSchema = z.enum(["mail:send", "mail:read"]);
5
+ export const PlatformPermissionSchema = z.enum(["mail:send", "mail:read", "resources:readers"]);
6
6
  export type PlatformPermission = z.infer<typeof PlatformPermissionSchema>;
7
7
  export const MailRetentionSchema = z.object({ contentDays: z.int().min(1).max(36500), recordDays: z.int().min(1).max(36500) }).strict();
8
8
  export type MailRetention = z.infer<typeof MailRetentionSchema> & {
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import { type CapabilityManifest, CloudResourceReaderViewSchema } from "./capabilities";
3
+ import { type CapabilityContractIssue, capabilityContractIssues } from "./capability-compatibility";
4
+
5
+ /** Check the reader's guaranteed `data.view`; the type model supplies containment. */
6
+ export const typeViewIssues = (
7
+ manifest: Pick<CapabilityManifest, "types" | "queries" | "resourceTypes">,
8
+ localId: string,
9
+ ): CapabilityContractIssue[] => {
10
+ const type = manifest.types.find((type) => type.localId === localId);
11
+ const reader = manifest.queries.find((query) => query.localId === type?.reader);
12
+ if (!type || !reader) return [{ code: "kind", path: "$", message: `Type ${localId} needs a reader` }];
13
+ const container = manifest.resourceTypes?.find((model) => model.type === localId)?.container;
14
+ const view = container ? CloudResourceReaderViewSchema.required({ container: true }) : CloudResourceReaderViewSchema;
15
+ const issues = capabilityContractIssues(
16
+ { kind: "query", input: z.object({}).strict(), data: z.looseObject({ view }) },
17
+ { kind: "query", operation: reader },
18
+ ).filter((issue) => issue.code !== "input");
19
+ if (reader.appOnly) issues.push({ code: "kind", path: "$", message: "A resource reader must accept person authority" });
20
+ return issues;
21
+ };
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+
3
+ export const EnvSettingStatusSchema = z.object({
4
+ key: z.string(),
5
+ envVariable: z.string().nullable(),
6
+ storedValuePresent: z.boolean(),
7
+ envValuePresent: z.boolean(),
8
+ conflict: z.enum(["none", "differs", "unknown"]),
9
+ unreadableStoredValue: z.boolean(),
10
+ invalidEnvValue: z.boolean(),
11
+ needsAttention: z.boolean(),
12
+ });
13
+ export const EnvSettingsProcessSchema = z.object({
14
+ appId: z.string(),
15
+ instanceId: z.string().optional(),
16
+ sampledAt: z.number().optional(),
17
+ status: z.enum(["ok", "unavailable"]),
18
+ settings: z.array(EnvSettingStatusSchema),
19
+ });
20
+ export const EnvSettingsStatusSchema = z.object({ needsAttention: z.boolean(), processes: z.array(EnvSettingsProcessSchema) });
21
+ export type EnvSettingStatus = z.infer<typeof EnvSettingStatusSchema>;
@@ -36,6 +36,12 @@ type CommonDef = {
36
36
  presentation?: AppSettingPresentation;
37
37
  envBootstrap?: EnvResolver;
38
38
  envFallback?: EnvResolver;
39
+ /** Environment name for migration diagnostics; values are never returned. */
40
+ envVariable?: string;
41
+ /** Discard these stored settings when this normalized value changes. */
42
+ resetOnChange?: readonly string[];
43
+ /** Additional server-side validation after kind normalization. */
44
+ validate?: (value: unknown) => string | undefined;
39
45
  };
40
46
 
41
47
  type StringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
@@ -0,0 +1,213 @@
1
+ import { Combobox, type ComboboxOption, type FieldProps, TextInput, useLocale } from "@k2b/ui";
2
+ import { createSignal, type JSX, onCleanup, onMount, Show } from "solid-js";
3
+ import { invokeCapabilityWithDataSchema } from "../capabilities/client";
4
+ import { type GeoAddress, type GeoAvailabilityData, geoContract } from "../contracts/geo";
5
+ import { geoUiMessages } from "./messages";
6
+
7
+ type Budget = { scope: "person" | "cloud"; resetsAt: string };
8
+
9
+ export type AddressAutocompleteProps = Omit<FieldProps, "label"> & {
10
+ label: JSX.Element;
11
+ /** The street line as typed; the person's own text always stays possible. */
12
+ value: string;
13
+ onValueChange: (value: string) => void;
14
+ /** A chosen suggestion. The host fills its fields from it and keeps point and provenance. */
15
+ onPick: (address: GeoAddress) => void;
16
+ /** The person keeps the typed text instead of a suggestion. */
17
+ onOwnEntry?: (value: string) => void;
18
+ /** From the SSR snapshot. Without a configured geo service the field is a plain text input. */
19
+ availability?: GeoAvailabilityData;
20
+ placeholder?: string;
21
+ /** Icon of the plain text input when suggestions are off. */
22
+ icon?: string;
23
+ };
24
+
25
+ /** Suggestions cost a credit each: 3–300 characters, 250 ms pause, at most 5, one request per field in flight. */
26
+ const MIN_TEXT = 3;
27
+ const MAX_TEXT = 300;
28
+ const LIMIT = 5;
29
+ const DEBOUNCE_MS = 250;
30
+ const OWN = "own";
31
+
32
+ // The list only opens in the browser, so the browser's zone is the reader's zone here.
33
+ const budgetText = (budget: Budget, locale: string, t: ReturnType<typeof geoUiMessages.resolve>["t"]) => {
34
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
35
+ const at = new Date(budget.resetsAt);
36
+ return budget.scope === "person"
37
+ ? t.budgetPerson({ time: new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone }).format(at) })
38
+ : t.budgetCloud({ date: new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", timeZone }).format(at) });
39
+ };
40
+
41
+ /** Address field with Cloud's budgeted suggestions; picking fills nothing by itself, the host owns its fields. */
42
+ export function AddressAutocomplete(props: AddressAutocompleteProps): JSX.Element {
43
+ const locale = useLocale();
44
+ const t = () => geoUiMessages.resolve([locale()]).t;
45
+ const initialBudget = (): Budget | null => {
46
+ const value = props.availability;
47
+ if (value?.person.exhausted) return { scope: "person", ...value.person };
48
+ if (value?.cloud.exhausted) return { scope: "cloud", ...value.cloud };
49
+ return null;
50
+ };
51
+ const [budget, setBudget] = createSignal<Budget | null>(initialBudget());
52
+ const [phone, setPhone] = createSignal(false);
53
+ // The budget line names a time in the reader's browser zone; it exists only once the list can open.
54
+ const [mounted, setMounted] = createSignal(false);
55
+ onMount(() => {
56
+ setMounted(true);
57
+ const query = window.matchMedia("(max-width: 639px)");
58
+ const update = () => setPhone(query.matches);
59
+ update();
60
+ query.addEventListener("change", update);
61
+ onCleanup(() => query.removeEventListener("change", update));
62
+ });
63
+
64
+ // Options carry the id of the answer they came from, so a pick always resolves to the address it showed.
65
+ let suggestions = new Map<string, GeoAddress>();
66
+ let responses = 0;
67
+ // The keyboard hint belongs to a list of choices, not to an empty, failed, or budget answer.
68
+ const [choices, setChoices] = createSignal(false);
69
+ let inflight: Promise<unknown> | undefined;
70
+ const ownOption = (text: string): ComboboxOption => ({
71
+ id: OWN,
72
+ label: t().ownEntry({ text: text.trim() }),
73
+ description: t().ownEntryDetail,
74
+ icon: "ti ti-keyboard",
75
+ });
76
+ const withOwn = (text: string, options: ComboboxOption[]) => (phone() ? [ownOption(text), ...options] : [...options, ownOption(text)]);
77
+
78
+ const countryName = (code: string) => {
79
+ if (!code) return "";
80
+ try {
81
+ return new Intl.DisplayNames([locale()], { type: "region" }).of(code) ?? code;
82
+ } catch {
83
+ return code;
84
+ }
85
+ };
86
+
87
+ const fetchData = async (text: string, signal: AbortSignal): Promise<ComboboxOption[]> => {
88
+ if (text.trim().length > MAX_TEXT) return withOwn(text, []);
89
+ // A newer query waits for the open request instead of cancelling a call Core may already have paid for.
90
+ while (inflight) await inflight.catch(() => {});
91
+ signal.throwIfAborted();
92
+ const request = invokeCapabilityWithDataSchema(
93
+ { kind: "query", appId: "core", capabilityId: geoContract.autocomplete.id, input: { text, limit: LIMIT } },
94
+ geoContract.autocomplete.data,
95
+ );
96
+ inflight = request;
97
+ const result = await request.finally(() => {
98
+ if (inflight === request) inflight = undefined;
99
+ });
100
+ // A superseded answer never touches the state behind the options a newer answer shows.
101
+ signal.throwIfAborted();
102
+ setChoices(result.ok && result.data.data.suggestions.length > 0);
103
+ if (!result.ok) {
104
+ const details = result.error.details;
105
+ if (
106
+ result.error.code === "GEO_BUDGET_EXHAUSTED" &&
107
+ (details?.scope === "person" || details?.scope === "cloud") &&
108
+ typeof details.resetsAt === "string"
109
+ ) {
110
+ setBudget({ scope: details.scope, resetsAt: details.resetsAt });
111
+ return [];
112
+ }
113
+ throw new Error(t().unavailable);
114
+ }
115
+ responses += 1;
116
+ const answer = responses;
117
+ const found = result.data.data.suggestions.slice(0, LIMIT);
118
+ suggestions = new Map(found.map((address, index) => [`${answer}:${index}`, address]));
119
+ if (!found.length) return [];
120
+ return withOwn(
121
+ text,
122
+ found.map((address, index) => ({
123
+ id: `${answer}:${index}`,
124
+ label: address.line1 || address.formatted,
125
+ description: [[address.postalCode, address.city].filter(Boolean).join(" "), countryName(address.countryCode)]
126
+ .filter(Boolean)
127
+ .join(", "),
128
+ icon: "ti ti-map-pin",
129
+ })),
130
+ );
131
+ };
132
+
133
+ const budgetStatus = () => {
134
+ const value = budget();
135
+ if (!value) return undefined;
136
+ return (
137
+ <span class="flex items-start gap-2">
138
+ <i class="ti ti-hourglass" aria-hidden="true" />
139
+ <span>
140
+ <strong class="block font-medium">{budgetText(value, locale(), t())}</strong>
141
+ {t().budgetDetail}
142
+ </span>
143
+ </span>
144
+ );
145
+ };
146
+
147
+ return (
148
+ <Show
149
+ when={props.availability?.configured !== false}
150
+ fallback={
151
+ <TextInput
152
+ id={props.id}
153
+ class={props.class}
154
+ label={props.label}
155
+ description={props.description}
156
+ error={props.error}
157
+ required={props.required}
158
+ disabled={props.disabled}
159
+ placeholder={props.placeholder}
160
+ icon={props.icon}
161
+ value={() => props.value}
162
+ onValueChange={props.onValueChange}
163
+ />
164
+ }
165
+ >
166
+ <Combobox
167
+ id={props.id}
168
+ class={props.class}
169
+ label={props.label}
170
+ description={props.description}
171
+ error={props.error}
172
+ required={props.required}
173
+ disabled={props.disabled}
174
+ aria-label={props["aria-label"]}
175
+ aria-describedby={props["aria-describedby"]}
176
+ placeholder={props.placeholder}
177
+ freeText
178
+ autoHighlight={phone() ? 1 : 0}
179
+ minQueryLength={MIN_TEXT}
180
+ debounceMs={DEBOUNCE_MS}
181
+ query={() => props.value}
182
+ onQueryChange={props.onValueChange}
183
+ status={mounted() ? budgetStatus() : undefined}
184
+ empty={
185
+ <span>
186
+ <strong class="block font-medium">{t().noMatch}</strong>
187
+ {t().noMatchDetail}
188
+ </span>
189
+ }
190
+ footer={
191
+ <>
192
+ <span>{phone() || budget() || !choices() ? "" : t().keyboardHint}</span>
193
+ <span>
194
+ <a href="https://www.geoapify.com/" target="_blank" rel="noopener noreferrer">
195
+ Geoapify
196
+ </a>
197
+ {" · "}
198
+ <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">
199
+ OpenStreetMap
200
+ </a>
201
+ </span>
202
+ </>
203
+ }
204
+ fetchData={fetchData}
205
+ onSelect={(option) => {
206
+ if (option.id === OWN) return props.onOwnEntry?.(props.value);
207
+ const address = suggestions.get(option.id);
208
+ if (address) props.onPick(address);
209
+ }}
210
+ />
211
+ </Show>
212
+ );
213
+ }
@@ -0,0 +1,29 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ export const geoUiMessages = i18n.define({
4
+ baseLocale: "en",
5
+ messages: {
6
+ en: {
7
+ ownEntry: ({ text }: { text: string }) => `Use “${text}” as typed`,
8
+ ownEntryDetail: "You fill in street, postal code, and city yourself",
9
+ noMatch: "No matching address found.",
10
+ noMatchDetail: "Enter it yourself. It is saved exactly as you write it.",
11
+ unavailable: "Suggestions are unavailable right now. You can enter the address yourself.",
12
+ budgetPerson: ({ time }: { time: string }) => `Suggestions are used up for today, back at ${time}.`,
13
+ budgetCloud: ({ date }: { date: string }) => `Suggestions are used up until ${date}.`,
14
+ budgetDetail: "You can enter the address yourself.",
15
+ keyboardHint: "↑↓ choose · Enter accepts",
16
+ },
17
+ de: {
18
+ ownEntry: ({ text }) => `„${text}“ so übernehmen`,
19
+ ownEntryDetail: "Straße, PLZ und Ort trägst du selbst ein",
20
+ noMatch: "Keine passende Adresse gefunden.",
21
+ noMatchDetail: "Trag sie selbst ein. Sie wird so gespeichert, wie du sie schreibst.",
22
+ unavailable: "Vorschläge gerade nicht verfügbar. Du kannst die Adresse selbst eintragen.",
23
+ budgetPerson: ({ time }) => `Vorschläge heute aufgebraucht, ab ${time} Uhr wieder.`,
24
+ budgetCloud: ({ date }) => `Vorschläge bis ${date} aufgebraucht.`,
25
+ budgetDetail: "Du kannst die Adresse selbst eintragen.",
26
+ keyboardHint: "↑↓ wählen · Enter übernehmen",
27
+ },
28
+ },
29
+ });
@@ -0,0 +1,25 @@
1
+ import type { GeoPoint } from "../contracts/geo";
2
+
3
+ export type GeoNavigationPlatform = "android" | "ios" | "desktop";
4
+
5
+ /** Phones open their own maps app; everything else gets OpenStreetMap. */
6
+ export const geoNavigationPlatform = (userAgent: string): GeoNavigationPlatform =>
7
+ /android/i.test(userAgent) ? "android" : /iphone|ipad|ipod/i.test(userAgent) ? "ios" : "desktop";
8
+
9
+ /**
10
+ * A link into a navigation app. Cloud does no live navigation; the link leaves Cloud only on click and carries
11
+ * just the destination and an optional name. Without a point it navigates to the address text.
12
+ */
13
+ export const geoNavigationHref = (
14
+ target: { point?: GeoPoint | null; text: string; name?: string },
15
+ platform: GeoNavigationPlatform,
16
+ ): string => {
17
+ const point = target.point ? `${target.point.lat},${target.point.lon}` : null;
18
+ const text = encodeURIComponent(target.text);
19
+ if (platform === "android")
20
+ return point ? `geo:0,0?q=${point}${target.name ? `(${encodeURIComponent(target.name)})` : ""}` : `geo:0,0?q=${text}`;
21
+ if (platform === "ios") return `https://maps.apple.com/?daddr=${point ? encodeURIComponent(point) : text}`;
22
+ return point
23
+ ? `https://www.openstreetmap.org/directions?route=%3B${encodeURIComponent(point)}`
24
+ : `https://www.openstreetmap.org/search?query=${text}`;
25
+ };
package/src/geo/ui.ts CHANGED
@@ -1 +1,3 @@
1
+ export { AddressAutocomplete, type AddressAutocompleteProps } from "./AddressAutocomplete";
1
2
  export { CloudMap, CloudMapAttribution, type CloudMapProps } from "./CloudMap";
3
+ export { type GeoNavigationPlatform, geoNavigationHref, geoNavigationPlatform } from "./navigation";
package/src/index.ts CHANGED
@@ -60,5 +60,14 @@ export type {
60
60
  NotificationSendInput,
61
61
  } from "./contracts/notification-types";
62
62
  export { notification } from "./contracts/notification-types";
63
+ export { createRedis, createSql, redis, type SqlPoolOptions, sql } from "./runtime/clients";
64
+ export {
65
+ type ConnectionSource,
66
+ connections,
67
+ type PlatformConnectionSources,
68
+ type PlatformConnections,
69
+ type PlatformConnectionValues,
70
+ } from "./runtime/connections";
63
71
  export type { HelpDefinition, HelpDefinitionDocument } from "./server/help";
64
72
  export { defineHelp } from "./server/help";
73
+ export { storage } from "./services/object-store";
@@ -0,0 +1,72 @@
1
+ import { RedisClient, type RedisOptions, SQL } from "bun";
2
+ import { postgresApplicationOptions } from "../_internal/postgres-application-name";
3
+ import { connections, hasConnections } from "./connections";
4
+
5
+ export type SqlPoolOptions = SQL.PostgresOrMySQLOptions;
6
+
7
+ /** Only the pre-start placeholder is proxied; initialized clients are native Bun objects. */
8
+ const notStarted = <T>(name: string): T => {
9
+ const fail = (): never => {
10
+ throw new Error(`${name} used before platform start`);
11
+ };
12
+ return new Proxy(fail, {
13
+ get: fail,
14
+ set: fail,
15
+ apply: fail,
16
+ construct: fail,
17
+ getPrototypeOf: fail,
18
+ defineProperty: fail,
19
+ deleteProperty: fail,
20
+ has: fail,
21
+ ownKeys: fail,
22
+ getOwnPropertyDescriptor: fail,
23
+ setPrototypeOf: fail,
24
+ isExtensible: fail,
25
+ preventExtensions: fail,
26
+ }) as T;
27
+ };
28
+
29
+ export let sql: SQL = notStarted("sql");
30
+ export let redis: RedisClient = notStarted("redis");
31
+
32
+ /** Own pool and lifecycle; defaults to the installation URL and application name. */
33
+ export const createSql = (options: SqlPoolOptions = {}): SQL => {
34
+ const state = connections();
35
+ const url = options.url ?? state.database.url;
36
+ const connection = { ...postgresApplicationOptions(state.appId, url.toString()), ...options.connection };
37
+ return new SQL({
38
+ ...options,
39
+ url,
40
+ ...(Object.keys(connection).length ? { connection } : {}),
41
+ });
42
+ };
43
+
44
+ /** Own connection and lifecycle, using the installation's Valkey URL. */
45
+ export const createRedis = (options?: RedisOptions): RedisClient => new RedisClient(connections().valkey.url, options);
46
+
47
+ export const initializeClients = async (): Promise<void> => {
48
+ let database: SQL;
49
+ try {
50
+ database = createSql();
51
+ } catch {
52
+ throw new Error("Postgres client configuration is invalid; check DATABASE_URL and native SQL options");
53
+ }
54
+ try {
55
+ const valkey = createRedis();
56
+ sql = database;
57
+ redis = valkey;
58
+ } catch {
59
+ await database.close();
60
+ throw new Error("Valkey client configuration is invalid; check REDIS_URL and native Redis options");
61
+ }
62
+ };
63
+
64
+ /** Internal test-target teardown, before a fixture selects a different database. */
65
+ export const closePlatformClients = async (): Promise<void> => {
66
+ if (hasConnections()) {
67
+ await sql.close();
68
+ redis.close();
69
+ }
70
+ sql = notStarted("sql");
71
+ redis = notStarted("redis");
72
+ };
@@ -0,0 +1,37 @@
1
+ /** Startup snapshot. Connection credentials never flow back into the process environment. */
2
+ export type ConnectionSource = "env" | "core";
3
+ export type PlatformConnectionValues = {
4
+ database: { url: string };
5
+ valkey: { url: string };
6
+ nats: {
7
+ servers: string[];
8
+ namespace: string;
9
+ replicas: 1 | 3 | 5;
10
+ ignoreClusterUpdates: boolean;
11
+ /** Mounted credentials and CA files are read once; these are contents, not paths. */
12
+ creds?: string;
13
+ ca?: string;
14
+ };
15
+ appSecret: { current: string; previous?: string; next?: string };
16
+ };
17
+ export type PlatformConnectionSources = {
18
+ [Group in keyof PlatformConnectionValues]: { [Key in keyof PlatformConnectionValues[Group]]: ConnectionSource };
19
+ };
20
+ export type PlatformConnections = PlatformConnectionValues & {
21
+ appId: string;
22
+ sources: PlatformConnectionSources;
23
+ };
24
+
25
+ let current: PlatformConnections | undefined;
26
+
27
+ export const connections = (): PlatformConnections => {
28
+ if (!current)
29
+ throw new Error("connections() used before platform start; use @k2b/cloud/start, the Cloud build entry or development preload");
30
+ return current;
31
+ };
32
+
33
+ /** Internal startup binding; tests replace it only after closing their previous clients. */
34
+ export const bindConnections = (value: PlatformConnections | undefined): void => {
35
+ current = value;
36
+ };
37
+ export const hasConnections = (): boolean => current !== undefined;