@k2b/cloud 0.45.0 → 0.46.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 (71) hide show
  1. package/package.json +5 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/ai/capabilities.ts +4 -2
  9. package/src/ai/chat/map-messages.ts +270 -0
  10. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  11. package/src/ai/chat/map-notebook.tsx +301 -0
  12. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  13. package/src/ai/chat/map-widget.css +107 -0
  14. package/src/ai/chat/map-widget.tsx +1279 -0
  15. package/src/ai/chat/message-utils.ts +1 -0
  16. package/src/ai/chat/tool-names.ts +2 -0
  17. package/src/ai/chat/turn-layout.ts +2 -1
  18. package/src/ai/chat/turn-view.tsx +17 -1
  19. package/src/ai/default-tools.ts +3 -0
  20. package/src/ai/executor.ts +13 -1
  21. package/src/ai/map-contracts.ts +209 -0
  22. package/src/ai/map-execution.ts +14 -0
  23. package/src/ai/map-notebook-save.ts +276 -0
  24. package/src/ai/map-notebook.ts +32 -0
  25. package/src/ai/map-opening-hours.ts +40 -0
  26. package/src/ai/map-routes.ts +84 -0
  27. package/src/ai/map-service.ts +288 -0
  28. package/src/ai/map-store.ts +137 -0
  29. package/src/ai/map-tool.ts +73 -0
  30. package/src/ai/migrate.ts +3 -0
  31. package/src/ai/protocol.ts +3 -1
  32. package/src/ai/routes.ts +2 -0
  33. package/src/ai/store.ts +19 -6
  34. package/src/ai/tools.ts +14 -0
  35. package/src/ai/types.ts +3 -0
  36. package/src/api/capabilities.ts +90 -12
  37. package/src/api/mcp.ts +4 -2
  38. package/src/browser/notifications.ts +25 -8
  39. package/src/capabilities/discussion-providers.ts +99 -0
  40. package/src/capabilities/executions.ts +2 -2
  41. package/src/capabilities/migrate.ts +10 -1
  42. package/src/capabilities/resource-readers.ts +58 -0
  43. package/src/capabilities/server-invoke.ts +23 -13
  44. package/src/capabilities/server.ts +17 -6
  45. package/src/capabilities/testing.ts +13 -0
  46. package/src/contracts/capabilities.ts +137 -4
  47. package/src/contracts/discussions.ts +91 -0
  48. package/src/contracts/geo-document.ts +60 -0
  49. package/src/contracts/geo-snapshot.ts +86 -0
  50. package/src/contracts/geo.ts +14 -0
  51. package/src/contracts/index.ts +2 -0
  52. package/src/contracts/outgoing-mail.ts +1 -1
  53. package/src/contracts/resource-types.ts +21 -0
  54. package/src/geo/AddressAutocomplete.tsx +213 -0
  55. package/src/geo/messages.ts +29 -0
  56. package/src/geo/navigation.ts +25 -0
  57. package/src/geo/ui.ts +2 -0
  58. package/src/services/accounts/app.ts +28 -6
  59. package/src/services/accounts/entities.ts +11 -4
  60. package/src/services/accounts/groups.ts +10 -4
  61. package/src/services/accounts/local-groups.ts +87 -38
  62. package/src/services/accounts/machine-group-management.ts +94 -0
  63. package/src/services/geo/cache.ts +2 -1
  64. package/src/services/geo/geoapify.ts +3 -0
  65. package/src/services/identity/invocation-token.ts +110 -0
  66. package/src/services/pdf/geo-snapshot.ts +107 -0
  67. package/src/services/pdf/index.ts +2 -0
  68. package/src/ssr/AdminArea.tsx +2 -2
  69. package/src/ssr/AdminLayout.tsx +1 -1
  70. package/src/ssr/AdminSidebar.tsx +1 -1
  71. package/src/styles/global.css +2 -0
@@ -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,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";
@@ -14,6 +14,7 @@ import type {
14
14
  UserProfile,
15
15
  UserProvider,
16
16
  } from "../../contracts/shared";
17
+ import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
17
18
  import {
18
19
  err,
19
20
  fail,
@@ -41,6 +42,7 @@ import type { AvatarChange, AvatarState } from "./avatar";
41
42
  import { listDuplicateEmails } from "./duplicate-emails";
42
43
  import * as entities from "./entities";
43
44
  import * as groups from "./groups";
45
+ import { mutateMachineGroupMember } from "./machine-group-management";
44
46
  import type { AccountsNotificationSender } from "./notification-sender";
45
47
  import { PosixError } from "./posix";
46
48
  import { accountRequestsEnabled } from "./request-policy";
@@ -1018,13 +1020,15 @@ export const accountsAppService = {
1018
1020
  return paginateItems(filtered, config.pagination);
1019
1021
  },
1020
1022
  add: async (config: {
1021
- actor: AccountsActor;
1023
+ actor: AccountsActor | ServiceAccountRequestActor;
1024
+ requestId?: string;
1022
1025
  id: string;
1023
1026
  provider?: UserProvider;
1024
1027
  userId?: string;
1025
1028
  groupId?: string;
1026
1029
  serviceAccountId?: string;
1027
1030
  }) => {
1031
+ if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "add" });
1028
1032
  const group = await groups.get({ id: config.id });
1029
1033
  if (!group) {
1030
1034
  const result = fail(err.notFound("Group not found"));
@@ -1055,13 +1059,15 @@ export const accountsAppService = {
1055
1059
  });
1056
1060
  },
1057
1061
  remove: async (config: {
1058
- actor: AccountsActor;
1062
+ actor: AccountsActor | ServiceAccountRequestActor;
1063
+ requestId?: string;
1059
1064
  id: string;
1060
1065
  provider?: UserProvider;
1061
1066
  userId?: string;
1062
1067
  groupId?: string;
1063
1068
  serviceAccountId?: string;
1064
1069
  }) => {
1070
+ if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "remove" });
1065
1071
  const group = await groups.get({ id: config.id });
1066
1072
  if (!group) {
1067
1073
  const result = fail(err.notFound("Group not found"));
@@ -1110,7 +1116,14 @@ export const accountsAppService = {
1110
1116
  });
1111
1117
  return paginateItems(filtered, config.pagination);
1112
1118
  },
1113
- add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1119
+ add: async (config: {
1120
+ actor: AccountsActor;
1121
+ id: string;
1122
+ provider?: UserProvider;
1123
+ userId?: string;
1124
+ groupId?: string;
1125
+ serviceAccountId?: string;
1126
+ }) => {
1114
1127
  const group = await groups.get({ id: config.id });
1115
1128
  if (!group) {
1116
1129
  const result = fail(err.notFound("Group not found"));
@@ -1133,17 +1146,25 @@ export const accountsAppService = {
1133
1146
  provider: config.provider,
1134
1147
  user: config.userId,
1135
1148
  group: config.groupId,
1149
+ serviceAccount: config.serviceAccountId,
1136
1150
  }),
1137
1151
  );
1138
1152
  return recordCompletedMutation({
1139
1153
  action: "accounts.group.manager.add",
1140
1154
  actor: auditActor(config.actor),
1141
1155
  target: groupTarget(group),
1142
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1156
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1143
1157
  result,
1144
1158
  });
1145
1159
  },
1146
- remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1160
+ remove: async (config: {
1161
+ actor: AccountsActor;
1162
+ id: string;
1163
+ provider?: UserProvider;
1164
+ userId?: string;
1165
+ groupId?: string;
1166
+ serviceAccountId?: string;
1167
+ }) => {
1147
1168
  const group = await groups.get({ id: config.id });
1148
1169
  if (!group) {
1149
1170
  const result = fail(err.notFound("Group not found"));
@@ -1166,13 +1187,14 @@ export const accountsAppService = {
1166
1187
  provider: config.provider,
1167
1188
  user: config.userId,
1168
1189
  group: config.groupId,
1190
+ serviceAccount: config.serviceAccountId,
1169
1191
  }),
1170
1192
  );
1171
1193
  return recordCompletedMutation({
1172
1194
  action: "accounts.group.manager.remove",
1173
1195
  actor: auditActor(config.actor),
1174
1196
  target: groupTarget(group),
1175
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1197
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1176
1198
  result,
1177
1199
  });
1178
1200
  },
@@ -484,8 +484,8 @@ export const list = async (
484
484
  AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
485
485
  ))`;
486
486
 
487
- // Machine recipients have local membership, but no directory management/parent relation.
488
- const otherRelation = Boolean(params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
487
+ // Machine management is direct only; membership never implies management.
488
+ const otherRelation = Boolean(params.parentGroupId || params.managedByUserId);
489
489
  const machineRelation = params.memberOfGroupId
490
490
  ? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
491
491
  SELECT 1 FROM auth.groups target
@@ -497,11 +497,18 @@ export const list = async (
497
497
  WHERE sg.service_account_id = sa.id AND sg.group_id = target.id)`
498
498
  }
499
499
  )`
500
- : sql`TRUE`;
500
+ : params.managerOfGroupId
501
+ ? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
502
+ SELECT 1 FROM auth.group_manager_service_accounts gm JOIN auth.groups g ON g.id = gm.group_id
503
+ WHERE gm.service_account_id = sa.id AND gm.group_id = ${params.managerOfGroupId}::uuid AND g.provider = 'local'
504
+ )`
505
+ : sql`TRUE`;
501
506
  const machineDirect = params.memberOfGroupId
502
507
  ? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
503
508
  WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
504
- : sql`NULL::boolean`;
509
+ : params.managerOfGroupId
510
+ ? sql`TRUE`
511
+ : sql`NULL::boolean`;
505
512
  const where = sql`
506
513
  ${visibilityCondition}
507
514
  AND ${kindsCondition}
@@ -264,9 +264,12 @@ export const addManager = async (params: {
264
264
  provider?: UserProvider;
265
265
  user?: string;
266
266
  group?: string;
267
+ serviceAccount?: string;
267
268
  }): Promise<MutationResult<void>> => {
268
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
269
- if (provider === "local") return localGroups.addManager({ id: params.id, user: params.user, group: params.group });
269
+ const provider = (await getGroup(params.id))?.provider;
270
+ if (provider === "local")
271
+ return localGroups.addManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
272
+ if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
270
273
  const serviceSession = await getServiceIpaSession();
271
274
  if (!serviceSession.ok) return serviceSession;
272
275
  return providers.ipa.groups.addManager({
@@ -282,9 +285,12 @@ export const removeManager = async (params: {
282
285
  provider?: UserProvider;
283
286
  user?: string;
284
287
  group?: string;
288
+ serviceAccount?: string;
285
289
  }): Promise<MutationResult<void>> => {
286
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
287
- if (provider === "local") return localGroups.removeManager({ id: params.id, user: params.user, group: params.group });
290
+ const provider = (await getGroup(params.id))?.provider;
291
+ if (provider === "local")
292
+ return localGroups.removeManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
293
+ if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
288
294
  const serviceSession = await getServiceIpaSession();
289
295
  if (!serviceSession.ok) return serviceSession;
290
296
  return providers.ipa.groups.removeManager({