@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.
- package/package.json +9 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +11 -2
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +22 -4
- package/src/ai/index.ts +1 -0
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +56 -46
- package/src/ai/projects.ts +100 -79
- package/src/ai/protocol.ts +3 -1
- package/src/ai/recent-resources.ts +3 -3
- package/src/ai/routes.ts +32 -30
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/shared.ts +2 -1
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/server/api/respond.ts +10 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +23 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +56 -17
- package/src/services/accounts/entities.ts +36 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +26 -17
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +141 -27
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/accounts/users.ts +38 -8
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/session/index.ts +12 -3
- package/src/shared/email-html.ts +1 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +428 -77
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +19 -3
- package/src/ssr/Layout.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-navigation.ts +1 -11
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/shell-data.tsx +1 -1
- package/src/styles/global.css +2 -0
- package/src/workflows/language/expressions.ts +3 -2
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 (${
|
|
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:
|
|
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
|
-
${
|
|
350
|
-
|
|
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
|
|
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
|
)
|
|
@@ -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
|
|
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 }) =>
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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.
|
|
1665
|
-
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.
|
|
1666
|
-
(SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.
|
|
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
|