@k2b/cloud 0.44.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 (137) hide show
  1. package/package.json +9 -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/access/GroupCoverage.tsx +20 -5
  9. package/src/access/PermissionEditor.tsx +11 -2
  10. package/src/access/PrincipalAvatar.tsx +30 -21
  11. package/src/access/messages.ts +4 -4
  12. package/src/account/EntitySearch.tsx +12 -0
  13. package/src/ai/capabilities.ts +4 -2
  14. package/src/ai/chat/map-messages.ts +270 -0
  15. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  16. package/src/ai/chat/map-notebook.tsx +301 -0
  17. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  18. package/src/ai/chat/map-widget.css +107 -0
  19. package/src/ai/chat/map-widget.tsx +1279 -0
  20. package/src/ai/chat/message-utils.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +2 -1
  23. package/src/ai/chat/turn-view.tsx +17 -1
  24. package/src/ai/default-tools.ts +3 -0
  25. package/src/ai/dictation-runtime.ts +6 -2
  26. package/src/ai/executor.ts +22 -4
  27. package/src/ai/index.ts +1 -0
  28. package/src/ai/map-contracts.ts +209 -0
  29. package/src/ai/map-execution.ts +14 -0
  30. package/src/ai/map-notebook-save.ts +276 -0
  31. package/src/ai/map-notebook.ts +32 -0
  32. package/src/ai/map-opening-hours.ts +40 -0
  33. package/src/ai/map-routes.ts +84 -0
  34. package/src/ai/map-service.ts +288 -0
  35. package/src/ai/map-store.ts +137 -0
  36. package/src/ai/map-tool.ts +73 -0
  37. package/src/ai/migrate.ts +3 -0
  38. package/src/ai/project-tool.ts +4 -4
  39. package/src/ai/projects-authorization.ts +53 -0
  40. package/src/ai/projects-routes.ts +56 -46
  41. package/src/ai/projects.ts +100 -79
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/recent-resources.ts +3 -3
  44. package/src/ai/routes.ts +32 -30
  45. package/src/ai/skill-tool.ts +4 -4
  46. package/src/ai/skills-routes.ts +27 -26
  47. package/src/ai/skills.ts +120 -81
  48. package/src/ai/store.ts +19 -6
  49. package/src/ai/tools.ts +14 -0
  50. package/src/ai/types.ts +3 -0
  51. package/src/api/accounts-entities.ts +6 -3
  52. package/src/api/admin-ai-skills.ts +4 -1
  53. package/src/api/auth.ts +9 -2
  54. package/src/api/capabilities.ts +90 -12
  55. package/src/api/mcp.ts +4 -2
  56. package/src/browser/notifications.ts +25 -8
  57. package/src/browser/session.ts +39 -0
  58. package/src/browser/shared-text-history-validation.ts +151 -0
  59. package/src/browser/shared-text-history.ts +464 -0
  60. package/src/browser/shared-text-offline-authorized.ts +29 -0
  61. package/src/browser/shared-text-offline.ts +351 -0
  62. package/src/browser/shared-text-provider.ts +714 -0
  63. package/src/browser/shared-text-table-contract.ts +150 -0
  64. package/src/browser/shared-text.ts +19 -0
  65. package/src/capabilities/discussion-providers.ts +99 -0
  66. package/src/capabilities/executions.ts +2 -2
  67. package/src/capabilities/migrate.ts +10 -1
  68. package/src/capabilities/resource-readers.ts +58 -0
  69. package/src/capabilities/server-invoke.ts +23 -13
  70. package/src/capabilities/server.ts +17 -6
  71. package/src/capabilities/testing.ts +13 -0
  72. package/src/contracts/capabilities.ts +137 -4
  73. package/src/contracts/discussions.ts +91 -0
  74. package/src/contracts/geo-document.ts +60 -0
  75. package/src/contracts/geo-snapshot.ts +86 -0
  76. package/src/contracts/geo.ts +14 -0
  77. package/src/contracts/index.ts +2 -0
  78. package/src/contracts/outgoing-mail.ts +1 -1
  79. package/src/contracts/resource-types.ts +21 -0
  80. package/src/contracts/shared.ts +2 -1
  81. package/src/geo/AddressAutocomplete.tsx +213 -0
  82. package/src/geo/messages.ts +29 -0
  83. package/src/geo/navigation.ts +25 -0
  84. package/src/geo/ui.ts +2 -0
  85. package/src/server/api/respond.ts +10 -2
  86. package/src/server/index.ts +1 -0
  87. package/src/server/services/access.ts +23 -10
  88. package/src/server/services/index.ts +1 -0
  89. package/src/services/accounts/app.ts +56 -17
  90. package/src/services/accounts/entities.ts +36 -16
  91. package/src/services/accounts/group-sql.ts +23 -0
  92. package/src/services/accounts/groups.ts +26 -17
  93. package/src/services/accounts/identities.ts +8 -1
  94. package/src/services/accounts/identity-reconciliation.ts +3 -1
  95. package/src/services/accounts/local-groups.ts +141 -27
  96. package/src/services/accounts/machine-group-management.ts +94 -0
  97. package/src/services/accounts/users.ts +38 -8
  98. package/src/services/geo/cache.ts +2 -1
  99. package/src/services/geo/geoapify.ts +3 -0
  100. package/src/services/identity/invocation-token.ts +110 -0
  101. package/src/services/mandates/index.ts +2 -2
  102. package/src/services/mandates/policy.ts +1 -0
  103. package/src/services/pdf/geo-snapshot.ts +107 -0
  104. package/src/services/pdf/index.ts +2 -0
  105. package/src/services/session/index.ts +12 -3
  106. package/src/shared/email-html.ts +1 -0
  107. package/src/shared/markdown/extensions/katex.ts +8 -1
  108. package/src/shared/markdown/extensions/tables.ts +14 -1
  109. package/src/shared/markdown/index.ts +22 -11
  110. package/src/shared-text/anchors.ts +29 -0
  111. package/src/shared-text/awareness.ts +87 -0
  112. package/src/shared-text/changes.ts +27 -0
  113. package/src/shared-text/documents.ts +428 -77
  114. package/src/shared-text/events.ts +12 -2
  115. package/src/shared-text/index.ts +16 -1
  116. package/src/shared-text/log.ts +24 -5
  117. package/src/shared-text/snapshots.ts +69 -23
  118. package/src/shared-text/structural-history.ts +377 -0
  119. package/src/shared-text/table-actions.ts +432 -0
  120. package/src/shared-text/types.ts +24 -5
  121. package/src/ssr/AdminArea.tsx +2 -2
  122. package/src/ssr/AdminLayout.tsx +1 -1
  123. package/src/ssr/AdminSidebar.tsx +1 -1
  124. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  125. package/src/ssr/CloudShell.island.tsx +19 -3
  126. package/src/ssr/Layout.tsx +1 -1
  127. package/src/ssr/MobileProfileActions.tsx +2 -2
  128. package/src/ssr/ProfilePreferences.tsx +2 -1
  129. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  130. package/src/ssr/admin-navigation.ts +1 -11
  131. package/src/ssr/app-launchpad.tsx +6 -1
  132. package/src/ssr/platform-messages.ts +2 -0
  133. package/src/ssr/profile-actions.ts +3 -6
  134. package/src/ssr/shell-data.tsx +1 -1
  135. package/src/styles/global.css +2 -0
  136. package/src/workflows/language/expressions.ts +3 -2
  137. package/src/workflows/runtime/executor.ts +2 -1
@@ -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";
@@ -43,6 +43,12 @@ const toErrorResponse = (result: AnyResult): [ApiErrorBody, number] => {
43
43
  return [{ message: "Internal server error", code: "INTERNAL" }, 500];
44
44
  };
45
45
 
46
+ /** Add explicitly selected transport recovery fields without changing the error envelope. */
47
+ export async function respond<D extends Record<string, unknown>, S extends LegacyErrorStatus>(
48
+ c: Context,
49
+ resultOrFn: ResultOrFn<LegacyErrorResult<S>>,
50
+ options: { errorDetails: D },
51
+ ): Promise<JsonTypedResponse<Omit<D, keyof ApiErrorBody> & ApiErrorBody, S>>;
46
52
  export async function respond<T>(
47
53
  c: Context,
48
54
  resultOrFn: ResultOrFn<AnyResult<T>>,
@@ -76,15 +82,17 @@ export async function respond<T>(
76
82
  export async function respond<T>(
77
83
  c: Context,
78
84
  resultOrFn: ResultOrFn<AnyResult<T>>,
79
- successStatus: SuccessStatus | 202 = 200,
85
+ statusOrOptions: SuccessStatus | 202 | { errorDetails: Record<string, unknown> } = 200,
80
86
  ): Promise<JsonTypedResponse<T, SuccessStatus | 202> | ApiErrorResponse> {
81
87
  const result = typeof resultOrFn === "function" ? await resultOrFn() : await resultOrFn;
82
88
 
83
89
  if (!result.ok) {
84
90
  const [body, status] = toErrorResponse(result);
85
- return c.json(body, status as ApiErrorStatus) as ApiErrorResponse;
91
+ const details = typeof statusOrOptions === "object" ? statusOrOptions.errorDetails : {};
92
+ return c.json({ ...details, ...body, code: body.code }, status as ApiErrorStatus) as ApiErrorResponse;
86
93
  }
87
94
 
95
+ const successStatus = typeof statusOrOptions === "number" ? statusOrOptions : 200;
88
96
  return c.json(result.data, successStatus) as JsonTypedResponse<T, SuccessStatus | 202>;
89
97
  }
90
98
 
@@ -89,6 +89,7 @@ export {
89
89
  paginate,
90
90
  paginateItems,
91
91
  password,
92
+ recursiveServiceAccountGroupIdsSubquery,
92
93
  resolveDisplayNames,
93
94
  services,
94
95
  tryCatch,
@@ -2,7 +2,7 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
2
2
  import { type SQLQuery, sql } from "bun";
3
3
  import { type ManagerCandidate, removesLastManager } from "../../access/managers";
4
4
  import { accessMessages } from "../../access/messages";
5
- import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
5
+ import { recursiveGroupIdsSubquery, recursiveServiceAccountGroupIdsSubquery } from "../../services/accounts/group-sql";
6
6
  import { toPgUuidArray } from "../../services/postgres";
7
7
  import type { ServiceAccountKind } from "../../services/service-accounts";
8
8
  import type { RequestAuthority } from "../middleware/auth";
@@ -266,19 +266,26 @@ export const ensureManagerRemains = (params: {
266
266
  return fail({ code: "LAST_MANAGER", message: t.lastManager({ level: params.level ?? t.manage }), status: 409 });
267
267
  };
268
268
 
269
- /** Resolve direct and nested group memberships from the authoritative database mirror. */
270
- export const getEffectiveGroups = async (params: { userId: string | null }, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
271
- if (!params.userId) return [];
269
+ type EffectiveGroupParams = { userId: string | null; serviceAccountId?: never } | { serviceAccountId: string | null; userId?: never };
270
+
271
+ /** Resolve authoritative memberships; machine recipients inherit local groups only. */
272
+ export const getEffectiveGroups = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
273
+ const groupIds = params.userId
274
+ ? recursiveGroupIdsSubquery(params.userId)
275
+ : params.serviceAccountId
276
+ ? recursiveServiceAccountGroupIdsSubquery(params.serviceAccountId)
277
+ : null;
278
+ if (!groupIds) return [];
272
279
  const rows = await db<{ id: string; name: string }[]>`
273
280
  SELECT g.id, g.name
274
281
  FROM auth.groups g
275
- WHERE g.id IN (${recursiveGroupIdsSubquery(params.userId)})
282
+ WHERE g.id IN (${groupIds})
276
283
  ORDER BY g.name
277
284
  `;
278
285
  return rows;
279
286
  };
280
287
 
281
- export const getEffectiveGroupIds = async (params: { userId: string | null }, db: AccessDb = sql): Promise<string[]> =>
288
+ export const getEffectiveGroupIds = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<string[]> =>
282
289
  (await getEffectiveGroups(params, db)).map((group) => group.id);
283
290
 
284
291
  /**
@@ -329,7 +336,7 @@ export type ResourceAccessAdapter<TResourceId = string> = {
329
336
  *
330
337
  * Apps provide column fragments because access rows are commonly joined under
331
338
  * different aliases. The subject is authoritative: group membership is always
332
- * resolved recursively from its user id and never accepted from a caller.
339
+ * resolved recursively from its user or service-account id and never accepted from a caller.
333
340
  * Resource binding, credential scopes, and permission precedence remain the
334
341
  * responsibility of the app using the predicate.
335
342
  */
@@ -346,8 +353,13 @@ export const buildAccessPrincipalTierConditions = (params: {
346
353
  serviceAccount: sql`${columns.serviceAccountId} = ${serviceAccountId}::uuid`,
347
354
  user: sql`${columns.userId} = ${userId}::uuid`,
348
355
  group: sql`(
349
- ${userId}::uuid IS NOT NULL
350
- AND ${columns.groupId} IN (${userId ? recursiveGroupIdsSubquery(userId) : sql`SELECT NULL::uuid WHERE FALSE`})
356
+ ${columns.groupId} IN (${
357
+ userId
358
+ ? recursiveGroupIdsSubquery(userId)
359
+ : serviceAccountId
360
+ ? recursiveServiceAccountGroupIdsSubquery(serviceAccountId)
361
+ : sql`SELECT NULL::uuid WHERE FALSE`
362
+ })
351
363
  )`,
352
364
  authenticated: sql`${authenticated} AND ${columns.authenticatedOnly} = true`,
353
365
  public: sql`(
@@ -369,7 +381,7 @@ export const buildAccessPrincipalCondition = (params: { subject: AccessSubject |
369
381
  * Get the effective permission level for an actor on a resource.
370
382
  * Returns the highest permission from:
371
383
  * - Direct user or service-account access
372
- * - Direct and nested user group memberships resolved from the database
384
+ * - Direct and nested group memberships for users and active standalone/agent accounts
373
385
  * - Authenticated access for every authenticated subject
374
386
  * - Public access for every subject, including anonymous callers
375
387
  *
@@ -465,6 +477,7 @@ export const getEffectivePermissions = async (params: {
465
477
  a.service_account_id = s.service_account_id
466
478
  OR a.user_id = s.user_id
467
479
  OR (s.user_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(sql`s.user_id`)}))
480
+ OR (s.service_account_id IS NOT NULL AND a.group_id IN (${recursiveServiceAccountGroupIdsSubquery(sql`s.service_account_id`)}))
468
481
  OR a.authenticated_only = true
469
482
  OR (a.user_id IS NULL AND a.group_id IS NULL AND a.service_account_id IS NULL AND a.authenticated_only = false)
470
483
  )
@@ -1,4 +1,5 @@
1
1
  // Cloud-specific server services
2
+ export { recursiveServiceAccountGroupIdsSubquery } from "../../services/accounts/group-sql";
2
3
 
3
4
  export type {
4
5
  AccessDb,
@@ -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";
@@ -415,7 +417,6 @@ export const accountsAppService = {
415
417
  const userGroups = await users.getGroups({
416
418
  id: config.userId,
417
419
  recursive: config.recursive,
418
- includePersonal: false,
419
420
  });
420
421
  const query = config.filter?.query?.trim().toLowerCase();
421
422
  const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
@@ -970,7 +971,7 @@ export const accountsAppService = {
970
971
  group: {
971
972
  list: async (config: {
972
973
  pagination?: PageParams;
973
- filter?: { search?: string; includePersonal?: boolean };
974
+ filter?: { search?: string };
974
975
  scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
975
976
  }): Promise<Paginated<BaseGroup>> => {
976
977
  const { page, perPage } = paginate(config.pagination);
@@ -982,7 +983,6 @@ export const accountsAppService = {
982
983
  scope: config.scope?.mode,
983
984
  ids: config.scope?.ids,
984
985
  provider: config.scope?.provider,
985
- includePersonal: config.filter?.includePersonal,
986
986
  });
987
987
 
988
988
  return {
@@ -993,7 +993,10 @@ export const accountsAppService = {
993
993
  hasNext: result.pagination.hasNext,
994
994
  };
995
995
  },
996
- get: async (config: { id: string }) => groups.get(config),
996
+ get: async (config: { id: string }) => {
997
+ const result = await groups.list({ ids: [config.id], perPage: 1 });
998
+ return result.groups[0] ?? null;
999
+ },
997
1000
  member: {
998
1001
  list: async (config: {
999
1002
  id: string;
@@ -1016,7 +1019,16 @@ export const accountsAppService = {
1016
1019
  });
1017
1020
  return paginateItems(filtered, config.pagination);
1018
1021
  },
1019
- add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1022
+ add: async (config: {
1023
+ actor: AccountsActor | ServiceAccountRequestActor;
1024
+ requestId?: string;
1025
+ id: string;
1026
+ provider?: UserProvider;
1027
+ userId?: string;
1028
+ groupId?: string;
1029
+ serviceAccountId?: string;
1030
+ }) => {
1031
+ if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "add" });
1020
1032
  const group = await groups.get({ id: config.id });
1021
1033
  if (!group) {
1022
1034
  const result = fail(err.notFound("Group not found"));
@@ -1035,17 +1047,27 @@ export const accountsAppService = {
1035
1047
  provider: config.provider,
1036
1048
  user: config.userId,
1037
1049
  group: config.groupId,
1050
+ serviceAccount: config.serviceAccountId,
1038
1051
  }),
1039
1052
  );
1040
1053
  return recordCompletedMutation({
1041
1054
  action: "accounts.group.member.add",
1042
1055
  actor: auditActor(config.actor),
1043
1056
  target: groupTarget(group),
1044
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1057
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1045
1058
  result,
1046
1059
  });
1047
1060
  },
1048
- remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1061
+ remove: async (config: {
1062
+ actor: AccountsActor | ServiceAccountRequestActor;
1063
+ requestId?: string;
1064
+ id: string;
1065
+ provider?: UserProvider;
1066
+ userId?: string;
1067
+ groupId?: string;
1068
+ serviceAccountId?: string;
1069
+ }) => {
1070
+ if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "remove" });
1049
1071
  const group = await groups.get({ id: config.id });
1050
1072
  if (!group) {
1051
1073
  const result = fail(err.notFound("Group not found"));
@@ -1064,13 +1086,14 @@ export const accountsAppService = {
1064
1086
  provider: config.provider,
1065
1087
  user: config.userId,
1066
1088
  group: config.groupId,
1089
+ serviceAccount: config.serviceAccountId,
1067
1090
  }),
1068
1091
  );
1069
1092
  return recordCompletedMutation({
1070
1093
  action: "accounts.group.member.remove",
1071
1094
  actor: auditActor(config.actor),
1072
1095
  target: groupTarget(group),
1073
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1096
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1074
1097
  result,
1075
1098
  });
1076
1099
  },
@@ -1093,7 +1116,14 @@ export const accountsAppService = {
1093
1116
  });
1094
1117
  return paginateItems(filtered, config.pagination);
1095
1118
  },
1096
- 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
+ }) => {
1097
1127
  const group = await groups.get({ id: config.id });
1098
1128
  if (!group) {
1099
1129
  const result = fail(err.notFound("Group not found"));
@@ -1116,17 +1146,25 @@ export const accountsAppService = {
1116
1146
  provider: config.provider,
1117
1147
  user: config.userId,
1118
1148
  group: config.groupId,
1149
+ serviceAccount: config.serviceAccountId,
1119
1150
  }),
1120
1151
  );
1121
1152
  return recordCompletedMutation({
1122
1153
  action: "accounts.group.manager.add",
1123
1154
  actor: auditActor(config.actor),
1124
1155
  target: groupTarget(group),
1125
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1156
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1126
1157
  result,
1127
1158
  });
1128
1159
  },
1129
- 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
+ }) => {
1130
1168
  const group = await groups.get({ id: config.id });
1131
1169
  if (!group) {
1132
1170
  const result = fail(err.notFound("Group not found"));
@@ -1149,13 +1187,14 @@ export const accountsAppService = {
1149
1187
  provider: config.provider,
1150
1188
  user: config.userId,
1151
1189
  group: config.groupId,
1190
+ serviceAccount: config.serviceAccountId,
1152
1191
  }),
1153
1192
  );
1154
1193
  return recordCompletedMutation({
1155
1194
  action: "accounts.group.manager.remove",
1156
1195
  actor: auditActor(config.actor),
1157
1196
  target: groupTarget(group),
1158
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1197
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1159
1198
  result,
1160
1199
  });
1161
1200
  },
@@ -1255,6 +1294,7 @@ export const accountsAppService = {
1255
1294
  profile?: UserProfile;
1256
1295
  userIds?: string[];
1257
1296
  groupIds?: string[];
1297
+ serviceAccountIds?: string[];
1258
1298
  serviceAccountKinds?: ServiceAccountKind[];
1259
1299
  serviceAccountStatus?: ServiceAccountStatus;
1260
1300
  excludeUserIds?: string[];
@@ -1266,7 +1306,6 @@ export const accountsAppService = {
1266
1306
  parentGroupId?: string;
1267
1307
  managedByUserId?: string;
1268
1308
  recursive?: boolean;
1269
- includePersonal?: boolean;
1270
1309
  }): Promise<Paginated<EntityListItem>> => {
1271
1310
  const canSearchDirectory = config.actor.roles.includes("user");
1272
1311
  const usesRelationFilter = Boolean(
@@ -1288,6 +1327,7 @@ export const accountsAppService = {
1288
1327
  profile: config.profile,
1289
1328
  userIds: config.userIds,
1290
1329
  groupIds: config.groupIds,
1330
+ serviceAccountIds: config.serviceAccountIds,
1291
1331
  serviceAccountKinds: config.serviceAccountKinds,
1292
1332
  serviceAccountStatus: config.serviceAccountStatus,
1293
1333
  excludeUserIds: config.excludeUserIds,
@@ -1299,7 +1339,6 @@ export const accountsAppService = {
1299
1339
  parentGroupId: config.parentGroupId,
1300
1340
  managedByUserId: config.managedByUserId,
1301
1341
  recursive: config.recursive,
1302
- includePersonal: config.includePersonal,
1303
1342
  page,
1304
1343
  perPage,
1305
1344
  });
@@ -1661,9 +1700,9 @@ export const accountsAppService = {
1661
1700
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
1662
1701
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
1663
1702
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
1664
- (SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS groups_total,
1665
- (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS ipa_groups_total,
1666
- (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS local_groups_total,
1703
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS groups_total,
1704
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS ipa_groups_total,
1705
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS local_groups_total,
1667
1706
  (SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
1668
1707
  (
1669
1708
  SELECT COUNT(*)::int