@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.
Files changed (42) hide show
  1. package/package.json +14 -14
  2. package/scripts/build.ts +3 -1
  3. package/scripts/sync-recovery-smoke.ts +1 -4
  4. package/src/_internal/define-app.ts +4 -0
  5. package/src/_internal/font-preloads.ts +25 -0
  6. package/src/_internal/nats-connection.ts +3 -10
  7. package/src/_internal/process-sync.ts +100 -21
  8. package/src/_internal/status-preserving-ssr.ts +9 -1
  9. package/src/_internal/sync-budget.ts +306 -0
  10. package/src/access/PermissionEditor.tsx +31 -12
  11. package/src/access/PrincipalPicker.tsx +23 -19
  12. package/src/access/messages.ts +2 -0
  13. package/src/access/service-account-kind.ts +28 -0
  14. package/src/account/EntitySearch.tsx +9 -8
  15. package/src/ai/chat-quotas.ts +10 -6
  16. package/src/ai/code-mode-skill.ts +2 -2
  17. package/src/ai/inference-calls.ts +2 -2
  18. package/src/ai/model-access.ts +5 -2
  19. package/src/ai/projects.ts +7 -2
  20. package/src/ai/quota-report.ts +6 -1
  21. package/src/ai/quotas.ts +1 -1
  22. package/src/ai/skills.ts +7 -2
  23. package/src/cli/access.ts +1 -0
  24. package/src/config/env.ts +5 -0
  25. package/src/server/locale.ts +17 -4
  26. package/src/server/middleware/validator.ts +23 -1
  27. package/src/services/gateway.ts +5 -0
  28. package/src/services/index.ts +2 -0
  29. package/src/services/pdf/index.ts +2 -0
  30. package/src/services/pdf/markdown.ts +50 -12
  31. package/src/services/settings/core-settings.ts +2 -0
  32. package/src/shared/ai-quotas.ts +16 -3
  33. package/src/ssr/LayoutPreferences.island.tsx +2 -17
  34. package/src/ssr/MinimalLayout.tsx +44 -20
  35. package/src/ssr/MinimalLayoutPreferences.island.tsx +52 -0
  36. package/src/ssr/PageError.tsx +1 -1
  37. package/src/ssr/profile-preferences-messages.ts +4 -0
  38. package/src/styles/resource-search.css +2 -2
  39. package/src/styles/utilities-feedback.css +2 -2
  40. package/src/styles/utilities-markdown-editor.css +2 -2
  41. package/src/styles/utilities-navigation.css +94 -20
  42. 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
- grantAccess: (principal: Principal, permission: GrantableLevel, display?: { displayName: string }) => Promise<AccessEntry>;
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 = (principal: Principal): string => {
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: { principal: Principal; permission: GrantableLevel; display: { displayName: string } }) =>
166
- props.grantAccess(data.principal, data.permission, data.display),
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.principal)} text-sm`} />
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
- <div class="min-w-0 flex-1">
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="ml-1 text-xs text-dimmed">({t().anyoneWithLink})</span>
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: string;
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
- onSelect: (principal: Principal, display: { displayName: string }) => void;
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
- let principals = new Map<string, Principal>();
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: { principal: Principal; label: string; icon: string; description?: string }[] = [];
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: e.serviceAccount.id },
77
- label: e.serviceAccount.name,
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
- e.serviceAccount.kind === "user_delegated"
80
- ? t().userBoundServiceAccount
81
- : e.serviceAccount.kind === "agent"
82
- ? t().agentServiceAccount
83
- : e.serviceAccount.kind === "standalone"
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
- principals = new Map(filtered.map((e) => [principalKey(e.principal), e.principal]));
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 principal = principals.get(option.id);
111
- if (principal) props.onSelect(principal, { displayName: option.label });
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
  );
@@ -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.kind === "agent" ? "ti-robot" : "ti-key"}
297
+ icon={serviceAccountKind(serviceAccount).icon}
294
298
  title={serviceAccount.name}
295
299
  subtitle={
296
- serviceAccount.kind === "user_delegated"
297
- ? "User-bound service account"
298
- : serviceAccount.kind === "agent"
299
- ? "Agent"
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={() =>
@@ -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
- /** Own allowances only. Grant identities and unavailable model profiles stay private. */
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": 55,
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 and self-billing,\nVAT categories S/Z/E/AE/K/G/O, units C62/HUR/DAY/KGM. Generation does not\nsupport UBL, XRechnung, discounts or prepayments. Readers preserve declared totals;\nparsing is not arithmetic verification. Validation is not XSD or Schematron\ncertification. No 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\";\n number: string; invoiceDate: string; serviceDate: string; dueDate: 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: {iban: string; accountName: string};\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.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n Credit notes require `precedingInvoice`, whose date cannot be later than the\n credit note; other kinds cannot supply it. Credit-note amounts stay unsigned.\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. `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 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`.\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## Minimal supported invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture demonstrates the required fields; it is not a document 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, account\nor 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"
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 costs could not be measured. Reset the allowance or wait for the next window.",
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 cost limit reached."),
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.
@@ -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);
@@ -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
@@ -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 { principal: grant.principal, limit: grant.limit, displayName: grant.displayName };
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 cost limit reached. Resets at ${b.resetsAt}.`);
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: "",
@@ -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` in quality order. Every
16
- * candidate is canonicalized; invalid tags fall through to the next source.
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) return candidate;
34
+ if (!candidate) continue;
35
+ if (CATALOG_LANGUAGES.has(new Intl.Locale(candidate).language)) return candidate;
36
+ firstValid ??= candidate;
24
37
  }
25
- return undefined;
38
+ return firstValid;
26
39
  };
27
40
 
28
41
  /**