@k2b/cloud 0.19.0 → 0.21.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 +14 -14
- package/scripts/build.ts +3 -1
- package/scripts/sync-recovery-smoke.ts +1 -4
- package/src/_internal/define-app.ts +4 -0
- package/src/_internal/font-preloads.ts +25 -0
- package/src/_internal/nats-connection.ts +3 -10
- package/src/_internal/process-sync.ts +100 -21
- package/src/_internal/status-preserving-ssr.ts +9 -1
- package/src/_internal/sync-budget.ts +306 -0
- package/src/access/PermissionEditor.tsx +31 -12
- package/src/access/PrincipalPicker.tsx +23 -19
- package/src/access/messages.ts +2 -0
- package/src/access/service-account-kind.ts +28 -0
- package/src/account/EntitySearch.tsx +9 -8
- package/src/ai/chat-quotas.ts +10 -6
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/inference-calls.ts +2 -2
- package/src/ai/model-access.ts +5 -2
- package/src/ai/projects.ts +7 -2
- package/src/ai/quota-report.ts +6 -1
- package/src/ai/quotas.ts +1 -1
- package/src/ai/skills.ts +7 -2
- package/src/cli/access.ts +1 -0
- package/src/config/env.ts +5 -0
- package/src/server/locale.ts +17 -4
- package/src/server/middleware/validator.ts +23 -1
- package/src/services/gateway.ts +5 -0
- package/src/services/index.ts +2 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/pdf/markdown.ts +50 -12
- package/src/services/settings/core-settings.ts +2 -0
- package/src/shared/ai-quotas.ts +16 -3
- package/src/ssr/LayoutPreferences.island.tsx +2 -17
- package/src/ssr/MinimalLayout.tsx +44 -20
- package/src/ssr/MinimalLayoutPreferences.island.tsx +52 -0
- package/src/ssr/PageError.tsx +1 -1
- package/src/ssr/profile-preferences-messages.ts +4 -0
- package/src/styles/resource-search.css +2 -2
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-markdown-editor.css +2 -2
- package/src/styles/utilities-navigation.css +94 -20
- package/src/types/k2b-ui-fonts.d.ts +7 -0
|
@@ -2,10 +2,11 @@ import { mutation } from "@k2b/stdlib/solid";
|
|
|
2
2
|
import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createSignal, For, Show } from "solid-js";
|
|
4
4
|
import { CloudAvatar } from "../account/Avatar";
|
|
5
|
-
import type { AccessEntry, PermissionLevel, Principal } from "../contracts/shared";
|
|
5
|
+
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
6
6
|
import { groupDisplayName } from "../shared/account-display";
|
|
7
7
|
import { accessMessages } from "./messages";
|
|
8
8
|
import PrincipalPicker from "./PrincipalPicker";
|
|
9
|
+
import { serviceAccountKindDisplay } from "./service-account-kind";
|
|
9
10
|
|
|
10
11
|
// ─────────────────────────────────────────────────────────────────────────
|
|
11
12
|
// Public API
|
|
@@ -31,8 +32,13 @@ type PermissionEditorProps = {
|
|
|
31
32
|
canEdit?: boolean;
|
|
32
33
|
|
|
33
34
|
/** Grant access. The caller closes over the resource id. The optional
|
|
34
|
-
* display metadata lets deferred form drafts retain the selected name
|
|
35
|
-
|
|
35
|
+
* display metadata lets deferred form drafts retain the selected name
|
|
36
|
+
* and, for service accounts, the kind shown in the row. */
|
|
37
|
+
grantAccess: (
|
|
38
|
+
principal: Principal,
|
|
39
|
+
permission: GrantableLevel,
|
|
40
|
+
display?: { displayName: string; serviceAccountKind?: ServiceAccountKind },
|
|
41
|
+
) => Promise<AccessEntry>;
|
|
36
42
|
|
|
37
43
|
/** Update an existing entry's permission level. */
|
|
38
44
|
updateAccess: (accessId: string, permission: GrantableLevel) => Promise<void>;
|
|
@@ -122,14 +128,14 @@ const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMess
|
|
|
122
128
|
return entry.principal.groupId;
|
|
123
129
|
};
|
|
124
130
|
|
|
125
|
-
const getPrincipalIcon = (
|
|
126
|
-
switch (principal.type) {
|
|
131
|
+
const getPrincipalIcon = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
|
|
132
|
+
switch (entry.principal.type) {
|
|
127
133
|
case "user":
|
|
128
134
|
return "ti-user";
|
|
129
135
|
case "group":
|
|
130
136
|
return "ti-users-group";
|
|
131
137
|
case "service_account":
|
|
132
|
-
return "ti-key";
|
|
138
|
+
return entry.serviceAccountKind ? serviceAccountKindDisplay(entry.serviceAccountKind, t).icon : "ti-key";
|
|
133
139
|
case "authenticated":
|
|
134
140
|
return "ti-lock-open-2";
|
|
135
141
|
case "public":
|
|
@@ -162,8 +168,11 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
const grantMut = mutation.create({
|
|
165
|
-
mutation: async (data: {
|
|
166
|
-
|
|
171
|
+
mutation: async (data: {
|
|
172
|
+
principal: Principal;
|
|
173
|
+
permission: GrantableLevel;
|
|
174
|
+
display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
|
|
175
|
+
}) => props.grantAccess(data.principal, data.permission, data.display),
|
|
167
176
|
onSuccess: (newEntry) => {
|
|
168
177
|
setEntries([...entries(), newEntry as AccessEntry]);
|
|
169
178
|
},
|
|
@@ -288,7 +297,7 @@ function AccessEntryRow(props: {
|
|
|
288
297
|
when={props.entry.principal.type === "user"}
|
|
289
298
|
fallback={
|
|
290
299
|
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
291
|
-
<i class={`ti ${getPrincipalIcon(props.entry
|
|
300
|
+
<i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
|
|
292
301
|
</div>
|
|
293
302
|
}
|
|
294
303
|
>
|
|
@@ -301,11 +310,21 @@ function AccessEntryRow(props: {
|
|
|
301
310
|
/>
|
|
302
311
|
</Show>
|
|
303
312
|
|
|
304
|
-
{/* Display name
|
|
305
|
-
|
|
313
|
+
{/* Display name. Name and label share one line so every row keeps its height on phones:
|
|
314
|
+
when both do not fit, each gets an equal share and the shorter one stays whole. */}
|
|
315
|
+
<div class="grid min-w-0 flex-1 auto-cols-[minmax(0,max-content)] grid-flow-col items-baseline gap-1">
|
|
306
316
|
<span class="truncate text-sm">{displayName()}</span>
|
|
307
317
|
<Show when={props.entry.principal.type === "public"}>
|
|
308
|
-
<span class="
|
|
318
|
+
<span class="truncate text-xs text-dimmed">({t().anyoneWithLink})</span>
|
|
319
|
+
</Show>
|
|
320
|
+
<Show
|
|
321
|
+
when={
|
|
322
|
+
props.entry.principal.type === "service_account" &&
|
|
323
|
+
props.entry.serviceAccountKind &&
|
|
324
|
+
serviceAccountKindDisplay(props.entry.serviceAccountKind, t()).label
|
|
325
|
+
}
|
|
326
|
+
>
|
|
327
|
+
{(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
|
|
309
328
|
</Show>
|
|
310
329
|
</div>
|
|
311
330
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Combobox, type ComboboxOption, useLocale } from "@k2b/ui";
|
|
2
|
-
import type { Principal } from "../contracts/shared";
|
|
2
|
+
import type { Principal, ServiceAccountKind } from "../contracts/shared";
|
|
3
3
|
import { groupDisplayName } from "../shared/account-display";
|
|
4
4
|
import { accessMessages } from "./messages";
|
|
5
|
+
import { serviceAccountKindDisplay } from "./service-account-kind";
|
|
5
6
|
|
|
6
7
|
export const principalKey = (p: Principal) =>
|
|
7
8
|
p.type === "user"
|
|
@@ -19,7 +20,7 @@ type Entity =
|
|
|
19
20
|
serviceAccount: {
|
|
20
21
|
id: string;
|
|
21
22
|
name: string;
|
|
22
|
-
kind:
|
|
23
|
+
kind: ServiceAccountKind;
|
|
23
24
|
appId: string | null;
|
|
24
25
|
resourceType: string | null;
|
|
25
26
|
resourceId: string | null;
|
|
@@ -32,13 +33,15 @@ export default function PrincipalPicker(props: {
|
|
|
32
33
|
allowAuthenticated?: boolean;
|
|
33
34
|
allowServiceAccounts?: boolean;
|
|
34
35
|
disabled?: boolean;
|
|
35
|
-
|
|
36
|
+
/** `serviceAccountKind` is set for service accounts so a new row can show the same kind as the result. */
|
|
37
|
+
onSelect: (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => void;
|
|
36
38
|
}) {
|
|
37
39
|
const locale = useLocale(),
|
|
38
40
|
t = () => accessMessages.resolve([locale()]).t;
|
|
39
|
-
|
|
41
|
+
type Entry = { principal: Principal; label: string; icon: string; description?: string; serviceAccountKind?: ServiceAccountKind };
|
|
42
|
+
let selectable = new Map<string, Entry>();
|
|
40
43
|
const load = async (search: string, signal: AbortSignal): Promise<ComboboxOption[]> => {
|
|
41
|
-
const entries:
|
|
44
|
+
const entries: Entry[] = [];
|
|
42
45
|
if (props.allowAuthenticated !== false)
|
|
43
46
|
entries.push({ principal: { type: "authenticated" }, label: t().allUsers, icon: "ti ti-lock-open-2" });
|
|
44
47
|
if (props.allowPublic) entries.push({ principal: { type: "public" }, label: t().public, icon: "ti ti-world" });
|
|
@@ -71,26 +74,27 @@ export default function PrincipalPicker(props: {
|
|
|
71
74
|
icon: "ti ti-users-group",
|
|
72
75
|
description: e.group.description ?? undefined,
|
|
73
76
|
});
|
|
74
|
-
else
|
|
77
|
+
else {
|
|
78
|
+
const account = e.serviceAccount;
|
|
79
|
+
const kind = serviceAccountKindDisplay(account.kind, t());
|
|
75
80
|
entries.push({
|
|
76
|
-
principal: { type: "service_account", serviceAccountId:
|
|
77
|
-
label:
|
|
81
|
+
principal: { type: "service_account", serviceAccountId: account.id },
|
|
82
|
+
label: account.name,
|
|
83
|
+
// Resource-bound accounts often share a name; their binding tells them apart.
|
|
78
84
|
description:
|
|
79
|
-
|
|
80
|
-
?
|
|
81
|
-
:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
? t().standaloneServiceAccount
|
|
85
|
-
: [e.serviceAccount.appId, e.serviceAccount.resourceType, e.serviceAccount.resourceId].filter(Boolean).join(" · "),
|
|
86
|
-
icon: e.serviceAccount.kind === "agent" ? "ti ti-robot" : "ti ti-key",
|
|
85
|
+
account.kind === "resource_bound"
|
|
86
|
+
? [account.appId, account.resourceType, account.resourceId].filter(Boolean).join(" · ")
|
|
87
|
+
: kind.label,
|
|
88
|
+
icon: `ti ${kind.icon}`,
|
|
89
|
+
serviceAccountKind: account.kind,
|
|
87
90
|
});
|
|
91
|
+
}
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
const existing = new Set(props.existing?.map(principalKey));
|
|
91
95
|
const filtered = entries.filter((e) => !existing.has(principalKey(e.principal)));
|
|
92
96
|
if (signal.aborted) return [];
|
|
93
|
-
|
|
97
|
+
selectable = new Map(filtered.map((e) => [principalKey(e.principal), e]));
|
|
94
98
|
return filtered.map((e) => ({ id: principalKey(e.principal), label: e.label, icon: e.icon, description: e.description }));
|
|
95
99
|
};
|
|
96
100
|
return (
|
|
@@ -107,8 +111,8 @@ export default function PrincipalPicker(props: {
|
|
|
107
111
|
}
|
|
108
112
|
fetchData={load}
|
|
109
113
|
onSelect={(option) => {
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
114
|
+
const entry = selectable.get(option.id);
|
|
115
|
+
if (entry) props.onSelect(entry.principal, { displayName: entry.label, serviceAccountKind: entry.serviceAccountKind });
|
|
112
116
|
}}
|
|
113
117
|
/>
|
|
114
118
|
);
|
package/src/access/messages.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const accessMessages = i18n.define({
|
|
|
15
15
|
removeAccessConfirm: ({ name }: { name: string }) => `Remove access for ${name}?`,
|
|
16
16
|
removeAccess: "Remove access",
|
|
17
17
|
userBoundServiceAccount: "User-bound service account",
|
|
18
|
+
resourceBoundServiceAccount: "Resource-bound service account",
|
|
18
19
|
standaloneServiceAccount: "Service account",
|
|
19
20
|
agentServiceAccount: "Agent",
|
|
20
21
|
noDirectGrants: "No direct access granted yet.",
|
|
@@ -74,6 +75,7 @@ export const accessMessages = i18n.define({
|
|
|
74
75
|
removeAccessConfirm: ({ name }) => `Zugriff für ${name} entfernen?`,
|
|
75
76
|
removeAccess: "Zugriff entfernen",
|
|
76
77
|
userBoundServiceAccount: "Benutzergebundenes Dienstkonto",
|
|
78
|
+
resourceBoundServiceAccount: "Ressourcengebundenes Dienstkonto",
|
|
77
79
|
standaloneServiceAccount: "Dienstkonto",
|
|
78
80
|
agentServiceAccount: "Agent",
|
|
79
81
|
noDirectGrants: "Noch kein direkter Zugriff erteilt.",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ServiceAccountKind } from "../contracts/shared";
|
|
2
|
+
import type { accessMessages } from "./messages";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one icon and label for each service-account kind, shared by the
|
|
6
|
+
* permission editor rows, the principal picker, and entity search so the same
|
|
7
|
+
* account never appears under different names.
|
|
8
|
+
*
|
|
9
|
+
* Apps bundle their own copy of this mapping, so a kind added later by Core
|
|
10
|
+
* falls back to the key icon without a label instead of breaking the list.
|
|
11
|
+
*/
|
|
12
|
+
export const serviceAccountKindDisplay = (
|
|
13
|
+
kind: ServiceAccountKind,
|
|
14
|
+
t: ReturnType<typeof accessMessages.resolve>["t"],
|
|
15
|
+
): { icon: string; label?: string } => {
|
|
16
|
+
switch (kind) {
|
|
17
|
+
case "agent":
|
|
18
|
+
return { icon: "ti-robot", label: t.agentServiceAccount };
|
|
19
|
+
case "user_delegated":
|
|
20
|
+
return { icon: "ti-user-key", label: t.userBoundServiceAccount };
|
|
21
|
+
case "resource_bound":
|
|
22
|
+
return { icon: "ti-box", label: t.resourceBoundServiceAccount };
|
|
23
|
+
case "standalone":
|
|
24
|
+
return { icon: "ti-key", label: t.standaloneServiceAccount };
|
|
25
|
+
default:
|
|
26
|
+
return { icon: "ti-key" };
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -2,6 +2,8 @@ import { timed } from "@k2b/stdlib/solid";
|
|
|
2
2
|
import { Button, ScrollArea, TextInput, useLocale } from "@k2b/ui";
|
|
3
3
|
import type { JSX } from "solid-js";
|
|
4
4
|
import { createSignal, For, Show } from "solid-js";
|
|
5
|
+
import { accessMessages } from "../access/messages";
|
|
6
|
+
import { serviceAccountKindDisplay } from "../access/service-account-kind";
|
|
5
7
|
import { groupDisplayName } from "../shared/account-display";
|
|
6
8
|
import { CloudAvatar } from "./Avatar";
|
|
7
9
|
|
|
@@ -115,6 +117,8 @@ const EntitySearch = (props: EntitySearchProps) => {
|
|
|
115
117
|
const [groups, setGroups] = createSignal<ApiGroup[]>([]);
|
|
116
118
|
const [serviceAccounts, setServiceAccounts] = createSignal<ApiServiceAccount[]>([]);
|
|
117
119
|
const [loading, setLoading] = createSignal(false);
|
|
120
|
+
const serviceAccountKind = (serviceAccount: ApiServiceAccount) =>
|
|
121
|
+
serviceAccountKindDisplay(serviceAccount.kind, accessMessages.resolve([locale()]).t);
|
|
118
122
|
|
|
119
123
|
// Defensive dev-warning: at least one principal kind must be enabled,
|
|
120
124
|
// otherwise the component is decorative-only and the caller probably
|
|
@@ -290,16 +294,13 @@ const EntitySearch = (props: EntitySearchProps) => {
|
|
|
290
294
|
<For each={serviceAccounts()}>
|
|
291
295
|
{(serviceAccount) => (
|
|
292
296
|
<ResultRow
|
|
293
|
-
icon={serviceAccount.
|
|
297
|
+
icon={serviceAccountKind(serviceAccount).icon}
|
|
294
298
|
title={serviceAccount.name}
|
|
295
299
|
subtitle={
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
: serviceAccount.kind === "standalone"
|
|
301
|
-
? "Service account"
|
|
302
|
-
: [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
|
|
300
|
+
// Resource-bound accounts often share a name; their binding tells them apart.
|
|
301
|
+
serviceAccount.kind === "resource_bound"
|
|
302
|
+
? [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
|
|
303
|
+
: serviceAccountKind(serviceAccount).label
|
|
303
304
|
}
|
|
304
305
|
disabled={props.disabled}
|
|
305
306
|
onSelect={() =>
|
package/src/ai/chat-quotas.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { AccessSubject } from "../server/services/access";
|
|
2
2
|
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
3
|
-
import type { AiChatQuotaSnapshot } from "../shared/ai-quotas";
|
|
3
|
+
import type { AiChatQuotaBalance, AiChatQuotaSnapshot, AiQuotaBalance } from "../shared/ai-quotas";
|
|
4
4
|
import { listAssistantAiModels } from "./assistant-models";
|
|
5
5
|
import { aiQuotas } from "./quotas";
|
|
6
6
|
import { readAiSettingsState } from "./settings";
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/** Reduces a balance to what the chat shows. Amounts, limits, and the unit never leave the server on this path. */
|
|
9
|
+
const usage = (b: AiQuotaBalance): AiChatQuotaBalance => {
|
|
10
|
+
if (b.bypassed || b.limit === null) return { scope: b.scope, unlimited: true, usedPercent: null, resetsAt: null };
|
|
11
|
+
const usedPercent = b.unknown ? null : b.used >= b.limit ? 100 : Math.max(0, Math.min(99, Math.floor((100 * b.used) / b.limit)));
|
|
12
|
+
return { scope: b.scope, unlimited: false, usedPercent, resetsAt: b.resetsAt };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Own allowances only, as usage shares. Grant identities and unavailable model profiles stay private. */
|
|
9
16
|
export async function getAiChatQuotas(subject: AccessSubject): Promise<AiChatQuotaSnapshot> {
|
|
10
17
|
const config = await aiQuotas.config();
|
|
11
18
|
if (!config.enabled) return { enabled: false, balances: [] };
|
|
@@ -14,12 +21,9 @@ export async function getAiChatQuotas(subject: AccessSubject): Promise<AiChatQuo
|
|
|
14
21
|
const profiles = (await readAiSettingsState()).profiles;
|
|
15
22
|
return {
|
|
16
23
|
enabled: snapshot.enabled,
|
|
17
|
-
unit: config.unit ?? "EUR",
|
|
18
24
|
unlimitedModels: models
|
|
19
25
|
.filter((model) => !hasBillableAiPricing(profiles.find((profile) => profile.id === model.id)?.pricing))
|
|
20
26
|
.map((model) => model.id),
|
|
21
|
-
balances: snapshot.balances
|
|
22
|
-
.filter((b) => b.scope === "*" || models.some((m) => m.id === b.scope))
|
|
23
|
-
.map(({ sources: _sources, sourceDetails: _sourceDetails, ...balance }) => balance),
|
|
27
|
+
balances: snapshot.balances.filter((b) => b.scope === "*" || models.some((m) => m.id === b.scope)).map(usage),
|
|
24
28
|
};
|
|
25
29
|
}
|
|
@@ -4,7 +4,7 @@ import type { AiSkillTemplate } from "./skills";
|
|
|
4
4
|
|
|
5
5
|
export const ASSISTANT_CODE_MODE_SKILL = {
|
|
6
6
|
"key": "assistant:code-mode",
|
|
7
|
-
"version":
|
|
7
|
+
"version": 56,
|
|
8
8
|
"name": "assistant-code-mode",
|
|
9
9
|
"description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
|
|
10
10
|
"instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, save one in Files, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
|
|
@@ -56,7 +56,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"path": "references/einvoice.md",
|
|
59
|
-
"content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes
|
|
59
|
+
"content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes, self-billing\nand self-billed credit notes, VAT categories S/Z/E/AE/K/G/O, units\nC62/HUR/DAY/KGM, and payment by credit transfer, cash, online service or\nclearing, or no payment means. Generation does not support UBL, XRechnung,\ndiscounts or prepayments. Readers preserve declared totals; parsing is not\narithmetic verification. Validation is not XSD or Schematron certification.\nNo XSD validator is exposed.\n\n## Complete input and result shapes\n\nType descriptions only; no imports are needed. All fields are required unless\nmarked `?`; unknown fields are rejected.\n\n```ts\ntype Party = {\n name: string; id?: string; vatId: string; // \"\" when the party has no VAT ID\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype Tax = {\n taxCategory?: \"S\" | \"Z\" | \"E\" | \"AE\" | \"K\" | \"G\" | \"O\"; // default \"S\"\n taxRate: string; taxExemptionReason?: string; taxExemptionReasonCode?: string;\n};\ntype InvoiceLine = Tax & {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: (Tax & {netAmount: string; taxAmount: string})[];\n};\ntype Invoice = {\n kind: \"invoice\" | \"creditNote\" | \"selfBilling\" | \"selfBillingCreditNote\";\n number: string; invoiceDate: string; dueDate: string;\n serviceDate?: string; period?: {startDate?: string; endDate?: string};\n currency: \"EUR\"; seller: Party & {taxRegistrationId?: string}; buyer: Party;\n deliverToCountryCode?: string; buyerReference: string;\n notes?: string[];\n precedingInvoice?: {number: string; invoiceDate: string};\n payment?: {\n typeCode?: \"10\" | \"30\" | \"58\" | \"68\" | \"97\"; // default \"58\"\n information?: string; iban?: string; accountName?: string;\n };\n lines: InvoiceLine[]; totals?: InvoiceTotals;\n};\ntype InvoiceCalculation = InvoiceTotals & {\n lines: (InvoiceLine & {netAmount: string})[];\n};\ntype ParsedInvoice = {\n format: \"zugferd-2.5-en16931\"; profile: string; xml: string;\n invoice: Invoice; filename?: string;\n};\ntype ParseOptions = {maxCharacters?: number; maxElements?: number; maxDepth?: number};\ntype PdfOptions = ParseOptions & {maxPdfBytes?: number};\n```\n\nXML options default to 10 Mi UTF-16 code units, 100,000 elements, depth 64.\nPDF input defaults to 25 MiB. Overrides must be positive safe integers.\nA parser result's business fields are under **`data.invoice`**. Calculated\namounts are directly under **`data.netAmount`**, etc., with no `data.totals` wrapper.\nA parsed invoice carries `serviceDate`, `period`, `payment` and the account\nfields only when the XML does: check each before reading it, for example\n`invoice.payment?.iban`. A parsed `payment` always has its `typeCode`.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n `serviceDate` (delivery date) and `period` (invoicing period) are optional\n and can be combined. A `period` needs a start or an end, and its end cannot\n precede its start.\n- `creditNote` and `selfBillingCreditNote` require `precedingInvoice`; every\n kind may supply it, and its date cannot be later than `invoiceDate`.\n Credit-note amounts stay unsigned.\n- `payment.typeCode`: `\"58\"` SEPA credit transfer, `\"30\"` credit transfer,\n `\"10\"` cash, `\"68\"` online payment service, `\"97\"` clearing between\n partners. 30 and 58 require `iban`; `accountName` is optional. The other\n codes forbid both. `information` is free text for any code. Omit `payment`\n when no payment means applies. Any other code fails both writing and the\n default reader; read such invoices with `{mode: \"incoming\"}`.\n- Lines: 1–1000, unique IDs. Quantities are positive, prices nonnegative,\n VAT rates at most 100. Decimal strings allow up to four\n fractional digits and no leading zeros. Totals/net amounts require exactly\n two fractional digits; do not convert through JavaScript Number.\n- Country codes: two uppercase letters. A supplied `payment.iban` must be valid.\n Required text is nonblank valid XML text. Limits: number/reference/line ID/VAT ID\n 100; names/address line/accountName 200; city 100; postalCode 20;\n line description, `payment.information` and each note 4000; at most 100 notes.\n- Category S needs a positive rate; every other category uses `taxRate: \"0\"`\n and zero tax. E/AE/K/G/O need `taxExemptionReason` or a VATEX\n `taxExemptionReasonCode`; S/Z forbid both. O cannot be mixed with other\n categories and requires `vatId: \"\"` for both parties. A seller without a VAT\n ID needs `seller.id` and, outside O, `seller.taxRegistrationId`. AE/K need a\n buyer VAT ID, K/G a seller VAT ID, and K `deliverToCountryCode` plus a\n `serviceDate` or `period`.\n- `calculate` rounds each line half up to cents, then VAT per category and rate. It recalculates\n line `netAmount`; `serialize` also rejects supplied line/totals values that\n disagree. Render these calculated amounts in HTML instead of another arithmetic path.\n\n## Example invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture is a credit-transfer invoice with a delivery date; it is not a\ndocument to issue.\n\n```js\nconst invoice = {\n kind: \"invoice\",\n number: \"EXAMPLE-42\",\n invoiceDate: \"2026-09-15\",\n serviceDate: \"2026-09-15\",\n dueDate: \"2026-09-30\",\n currency: \"EUR\",\n seller: {\n name: \"Example Seller\", vatId: \"DE123456789\",\n address: { line1: \"Street 1\", city: \"Ulm\", postalCode: \"89073\", countryCode: \"DE\" },\n },\n buyer: {\n name: \"Example Buyer\", vatId: \"DE987654321\",\n address: { line1: \"Street 2\", city: \"Berlin\", postalCode: \"10115\", countryCode: \"DE\" },\n },\n buyerReference: \"ORDER-42\",\n payment: { iban: \"DE89370400440532013000\", accountName: \"Example Seller\" },\n lines: [{ id: \"1\", name: \"Service\", quantity: \"2.0000\", unitPrice: \"50.0000\", unitCode: \"HUR\", taxRate: \"19.00\" }],\n};\nconst result = einvoice.serialize(invoice, { format: \"zugferd-2.5-en16931\" });\nif (!result.ok) throw new Error(JSON.stringify(result.error));\nawait files.save(new Blob([result.data.bytes], { type: \"application/xml\" }), \"invoice.xml\");\n```\n\nNever infer a missing VAT identifier, tax category, exemption reason, delivery\ndate, account or business reference merely to satisfy input validation.\n\n## Reading received invoices\n\n`{mode: \"incoming\"}` (plus the same limits) reads a broader separate model:\nalso XRechnung 3.0/2.3 CII, other currencies, discounts, prepayments, all\npayment means and optional references. `data` is\n`{format: \"cii-en16931\", profile, xml, invoice, unmapped, filename?}`.\nAmounts are declared strings, never recalculated; O lines have no `taxRate`.\n`unmapped` lists supplementary XML elements and attributes with their paths; review it before\naccounting. Do not pass this `invoice` to `validate` or `serialize`.\n\nFor an invoice PDF, pass `serialized.data.xml` to\n[`pdf.facturX`](pdf.md) with profile `\"EN 16931\"` and matching HTML.\nNumbering, business mapping, issuance and persistence belong to the app.\n"
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "references/examples.md",
|
|
@@ -98,7 +98,7 @@ export async function beginAiCall(
|
|
|
98
98
|
return {
|
|
99
99
|
error: new AiQuotaError(
|
|
100
100
|
"quota_usage_unknown",
|
|
101
|
-
"Chat
|
|
101
|
+
"Chat usage could not be measured. Reset the allowance or wait for the next window.",
|
|
102
102
|
),
|
|
103
103
|
};
|
|
104
104
|
const rule = config.rules.find((rule) => rule.scope === balance.scope)!;
|
|
@@ -127,7 +127,7 @@ export async function beginAiCall(
|
|
|
127
127
|
error:
|
|
128
128
|
context.kind === "background"
|
|
129
129
|
? new AiBackgroundAdmissionError(backgroundHeadroom !== undefined && inputCost + outputPrice <= backgroundHeadroom)
|
|
130
|
-
: new AiQuotaError("quota_exhausted", "Chat
|
|
130
|
+
: new AiQuotaError("quota_exhausted", "Chat usage limit reached."),
|
|
131
131
|
};
|
|
132
132
|
const reserveOutput = Math.min(requestedOutput ?? outputBound ?? affordable, affordable);
|
|
133
133
|
// Preserve adapter defaults unless an explicit maximum or budget requires a cap.
|
package/src/ai/model-access.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { type AccessEntry, type Principal, PrincipalSchema } from "../contracts/shared";
|
|
3
|
+
import { type AccessEntry, type Principal, PrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
|
|
4
4
|
import { type AccessSubject, buildAccessPrincipalCondition, createAccess } from "../server/services/access";
|
|
5
5
|
import { toPgTextArray } from "../services/postgres";
|
|
6
6
|
import type { AiSettingsError } from "./types";
|
|
@@ -114,11 +114,13 @@ export const aiModelAccess = {
|
|
|
114
114
|
service_account_id: string | null;
|
|
115
115
|
authenticated_only: boolean | null;
|
|
116
116
|
display_name: string | null;
|
|
117
|
+
service_account_kind: ServiceAccountKind | null;
|
|
117
118
|
}[]
|
|
118
119
|
>`
|
|
119
120
|
SELECT resource.profile_id, resource.revision, access.id, access.created_at,
|
|
120
121
|
access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
|
|
121
|
-
COALESCE(users.display_name, groups.name, service_accounts.name) AS display_name
|
|
122
|
+
COALESCE(users.display_name, groups.name, service_accounts.name) AS display_name,
|
|
123
|
+
service_accounts.kind AS service_account_kind
|
|
122
124
|
FROM ai.model_access_resources resource LEFT JOIN ai.model_access model ON model.profile_id = resource.profile_id
|
|
123
125
|
LEFT JOIN auth.access access ON access.id = model.access_id
|
|
124
126
|
LEFT JOIN auth.users users ON users.id = access.user_id
|
|
@@ -146,6 +148,7 @@ export const aiModelAccess = {
|
|
|
146
148
|
permission: "read",
|
|
147
149
|
createdAt: row.created_at.toISOString(),
|
|
148
150
|
...(row.display_name ? { displayName: row.display_name } : {}),
|
|
151
|
+
...(row.service_account_kind ? { serviceAccountKind: row.service_account_kind } : {}),
|
|
149
152
|
});
|
|
150
153
|
}
|
|
151
154
|
return Object.fromEntries(result);
|
package/src/ai/projects.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type SQL, type SQLQuery, sql } from "bun";
|
|
2
2
|
import type { CloudResourceRef } from "../contracts/capabilities";
|
|
3
|
-
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
3
|
+
import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
|
|
4
4
|
import type { AccessSubject } from "../server";
|
|
5
5
|
import {
|
|
6
6
|
buildAccessPrincipalCondition,
|
|
@@ -82,6 +82,8 @@ export type AiProjectAccess = {
|
|
|
82
82
|
principal: Principal;
|
|
83
83
|
permission: AiProjectPermission;
|
|
84
84
|
displayName?: string;
|
|
85
|
+
/** Kind of a `service_account` principal; presentation only. */
|
|
86
|
+
serviceAccountKind?: ServiceAccountKind;
|
|
85
87
|
createdAt: string;
|
|
86
88
|
};
|
|
87
89
|
|
|
@@ -155,6 +157,7 @@ type ProjectAccessRow = {
|
|
|
155
157
|
permission: AiProjectPermission;
|
|
156
158
|
created_at: Date | string;
|
|
157
159
|
display_name: string | null;
|
|
160
|
+
service_account_kind: ServiceAccountKind | null;
|
|
158
161
|
};
|
|
159
162
|
|
|
160
163
|
type AdminProjectRow = ProjectRow & {
|
|
@@ -257,6 +260,7 @@ const toProjectAccess = (row: ProjectAccessRow): AiProjectAccess => ({
|
|
|
257
260
|
: { type: "public" },
|
|
258
261
|
permission: row.permission,
|
|
259
262
|
displayName: row.display_name ?? undefined,
|
|
263
|
+
serviceAccountKind: row.service_account_kind ?? undefined,
|
|
260
264
|
createdAt: iso(row.created_at),
|
|
261
265
|
});
|
|
262
266
|
|
|
@@ -287,7 +291,8 @@ const listProjectAccess = async (projectId: string, db: SQL = sql): Promise<AiPr
|
|
|
287
291
|
SELECT project_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
|
|
288
292
|
access.permission, access.created_at,
|
|
289
293
|
COALESCE(users.display_name, groups.name, service_accounts.name,
|
|
290
|
-
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name
|
|
294
|
+
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
|
|
295
|
+
service_accounts.kind AS service_account_kind
|
|
291
296
|
FROM ai.project_access project_access
|
|
292
297
|
JOIN auth.access access ON access.id = project_access.access_id
|
|
293
298
|
LEFT JOIN auth.users users ON users.id = access.user_id
|
package/src/ai/quota-report.ts
CHANGED
|
@@ -18,7 +18,12 @@ export async function quotaAdminConfig() {
|
|
|
18
18
|
...rule,
|
|
19
19
|
grants: rule.grants.map(() => {
|
|
20
20
|
const grant = grants[offset++]!;
|
|
21
|
-
return {
|
|
21
|
+
return {
|
|
22
|
+
principal: grant.principal,
|
|
23
|
+
limit: grant.limit,
|
|
24
|
+
displayName: grant.displayName,
|
|
25
|
+
serviceAccountKind: grant.serviceAccountKind,
|
|
26
|
+
};
|
|
22
27
|
}),
|
|
23
28
|
})),
|
|
24
29
|
};
|
package/src/ai/quotas.ts
CHANGED
|
@@ -124,7 +124,7 @@ export const aiQuotas = {
|
|
|
124
124
|
"quota_usage_unknown",
|
|
125
125
|
"Chat usage could not be measured. Contact an administrator or wait for the quota reset.",
|
|
126
126
|
);
|
|
127
|
-
if (b.used >= b.limit) throw new AiQuotaError("quota_exhausted", `Chat
|
|
127
|
+
if (b.used >= b.limit) throw new AiQuotaError("quota_exhausted", `Chat usage limit reached. Resets at ${b.resetsAt}.`);
|
|
128
128
|
}
|
|
129
129
|
return snapshot.balances.some((b) => !b.bypassed && b.limit !== null);
|
|
130
130
|
},
|
package/src/ai/skills.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { type SQL, type SQLQuery, sql } from "bun";
|
|
3
|
-
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
3
|
+
import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
|
|
4
4
|
import type { AccessSubject } from "../server";
|
|
5
5
|
import {
|
|
6
6
|
buildAccessPrincipalCondition,
|
|
@@ -58,6 +58,8 @@ export type AiSkillAccess = {
|
|
|
58
58
|
principal: Principal;
|
|
59
59
|
permission: AiSkillPermission;
|
|
60
60
|
displayName?: string;
|
|
61
|
+
/** Kind of a `service_account` principal; presentation only. */
|
|
62
|
+
serviceAccountKind?: ServiceAccountKind;
|
|
61
63
|
createdAt: string;
|
|
62
64
|
};
|
|
63
65
|
|
|
@@ -128,6 +130,7 @@ type SkillAccessRow = {
|
|
|
128
130
|
permission: AiSkillPermission;
|
|
129
131
|
created_at: Date | string;
|
|
130
132
|
display_name: string | null;
|
|
133
|
+
service_account_kind: ServiceAccountKind | null;
|
|
131
134
|
};
|
|
132
135
|
|
|
133
136
|
type SkillSummaryRow = Pick<SkillRow, "id" | "short_id" | "name" | "description" | "revision" | "created_at" | "updated_at"> & {
|
|
@@ -282,7 +285,8 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
|
|
|
282
285
|
SELECT skill_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
|
|
283
286
|
access.permission, access.created_at,
|
|
284
287
|
COALESCE(users.display_name, groups.name, service_accounts.name,
|
|
285
|
-
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name
|
|
288
|
+
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
|
|
289
|
+
service_accounts.kind AS service_account_kind
|
|
286
290
|
FROM ai.skill_access skill_access
|
|
287
291
|
JOIN auth.access access ON access.id = skill_access.access_id
|
|
288
292
|
LEFT JOIN auth.users users ON users.id = access.user_id
|
|
@@ -305,6 +309,7 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
|
|
|
305
309
|
: { type: "public" },
|
|
306
310
|
permission: row.permission,
|
|
307
311
|
displayName: row.display_name ?? undefined,
|
|
312
|
+
serviceAccountKind: row.service_account_kind ?? undefined,
|
|
308
313
|
createdAt: iso(row.created_at),
|
|
309
314
|
}));
|
|
310
315
|
};
|
package/src/cli/access.ts
CHANGED
|
@@ -238,6 +238,7 @@ const entryTypeLabel = (entry: AccessEntry): string => {
|
|
|
238
238
|
if (entry.principal.type !== "service_account") return entry.principal.type;
|
|
239
239
|
if (entry.serviceAccountKind === "agent") return "agent";
|
|
240
240
|
if (entry.serviceAccountKind === "resource_bound") return "resource-bound";
|
|
241
|
+
if (entry.serviceAccountKind === "user_delegated") return "user-bound";
|
|
241
242
|
return "service account";
|
|
242
243
|
};
|
|
243
244
|
|
package/src/config/env.ts
CHANGED
|
@@ -72,6 +72,11 @@ const registry = defineEnv({
|
|
|
72
72
|
example: "localhost:3000",
|
|
73
73
|
doc: "Public Cloud URL used to bootstrap the `app.url` setting; the stored setting wins once written.",
|
|
74
74
|
},
|
|
75
|
+
GOTENBERG_URL: {
|
|
76
|
+
schema: z.string(),
|
|
77
|
+
example: "http://localhost:3001",
|
|
78
|
+
doc: "Internal Gotenberg base URL used to bootstrap the `gotenberg.url` PDF rendering setting; the stored setting wins once written.",
|
|
79
|
+
},
|
|
75
80
|
APP_SECRET: {
|
|
76
81
|
schema: z.string(),
|
|
77
82
|
default: "",
|
package/src/server/locale.ts
CHANGED
|
@@ -9,20 +9,33 @@ type LocaleContext = {
|
|
|
9
9
|
req: { raw: { headers: Headers } };
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Languages Cloud ships platform catalogs for and offers in its language
|
|
14
|
+
* picker. `Accept-Language` negotiation prefers a tag in one of these.
|
|
15
|
+
*/
|
|
16
|
+
const CATALOG_LANGUAGES = new Set(["en", "de"]);
|
|
17
|
+
|
|
12
18
|
/**
|
|
13
19
|
* The caller's explicit locale preference, or `undefined` when the request
|
|
14
20
|
* carries none. Precedence: `x-cloud-locale` transport metadata, then the
|
|
15
|
-
* `cloud.locale` cookie, then `Accept-Language
|
|
16
|
-
*
|
|
21
|
+
* `cloud.locale` cookie, then `Accept-Language`. Every candidate is
|
|
22
|
+
* canonicalized; invalid tags fall through to the next source.
|
|
23
|
+
*
|
|
24
|
+
* `Accept-Language` picks the first tag in quality order whose language has a
|
|
25
|
+
* catalog, keeping its region (`de-CH`, `en-GB`) for formatting. Without such a
|
|
26
|
+
* tag, the first valid tag still wins so formatting follows the browser.
|
|
17
27
|
*/
|
|
18
28
|
export const preferredLocale = (headers: Headers): string | undefined => {
|
|
19
29
|
const explicit = canonicalLocale(headers.get(LOCALE_HEADER)) ?? canonicalLocale(readCookie(headers, LOCALE_COOKIE));
|
|
20
30
|
if (explicit) return explicit;
|
|
31
|
+
let firstValid: string | undefined;
|
|
21
32
|
for (const tag of i18n.parseAcceptLanguage(headers.get("Accept-Language"))) {
|
|
22
33
|
const candidate = canonicalLocale(tag);
|
|
23
|
-
if (candidate)
|
|
34
|
+
if (!candidate) continue;
|
|
35
|
+
if (CATALOG_LANGUAGES.has(new Intl.Locale(candidate).language)) return candidate;
|
|
36
|
+
firstValid ??= candidate;
|
|
24
37
|
}
|
|
25
|
-
return
|
|
38
|
+
return firstValid;
|
|
26
39
|
};
|
|
27
40
|
|
|
28
41
|
/**
|