@k2b/cloud 0.20.0 → 0.21.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -98,7 +98,7 @@
98
98
  "@tailwindcss/typography": "0.5.20",
99
99
  "@k2b/nessi": "0.12.1",
100
100
  "@k2b/ssr": "0.14.0",
101
- "@k2b/ui": "0.9.1",
101
+ "@k2b/ui": "0.11.0",
102
102
  "@k2b/stdlib": "0.27.0",
103
103
  "@k2b/sync": "7.0.0",
104
104
  "@nats-io/jetstream": "3.4.0",
@@ -132,7 +132,7 @@
132
132
  "hono": "4.13.9",
133
133
  "playwright": "1.63.0",
134
134
  "solid-js": "1.9.15",
135
- "typescript": "5.9.3",
135
+ "typescript": "7.0.2",
136
136
  "zod": "4.6.5"
137
137
  },
138
138
  "peerDependencies": {
@@ -73,6 +73,7 @@ import { readBoundedJson } from "./bounded-json";
73
73
  import { appRuntimeMetadata } from "./build-metadata";
74
74
  import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
75
75
  import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
76
+ import { FONT_PRELOAD_LINKS } from "./font-preloads";
76
77
  import { createHeartbeat } from "./heartbeat";
77
78
  import { compileHelp } from "./help";
78
79
  import { createPageResponses } from "./page-responses";
@@ -356,6 +357,7 @@ export const defineApp = <
356
357
  <link rel="icon" href="${appFaviconHref(opts.id, v)}">
357
358
  <style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
358
359
  <link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
360
+ ${FONT_PRELOAD_LINKS}
359
361
  <link rel="stylesheet" href="/public/fonts.css?v=${v}">
360
362
  <link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
361
363
  <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
@@ -0,0 +1,25 @@
1
+ /// <reference path="../types/k2b-ui-fonts.d.ts" />
2
+ import plexPreset from "@k2b/ui/fonts/plex.css" with { type: "text" };
3
+
4
+ /**
5
+ * Preload tags for the IBM Plex Sans faces that every page sets in its first
6
+ * frame: Latin text at 400, 500 (buttons, labels) and 600 (headings).
7
+ *
8
+ * The preset declares its faces with `font-display: swap`. Without a preload
9
+ * the browser requests a face only when layout needs it, paints the fallback
10
+ * font and re-wraps the text once Plex arrives. That moves every box after the
11
+ * first changed line, and on a vertically centered page such as sign-in the
12
+ * whole form. Preloaded, the faces download next to the render-blocking
13
+ * stylesheets and the first frame already uses them.
14
+ *
15
+ * Core serves the faces under `/public/fonts/` with the preset's
16
+ * content-hashed file names (`packages/core/scripts/font-assets.ts`). The
17
+ * preset is bundled as text, so production servers need no `node_modules`.
18
+ */
19
+ export const FONT_PRELOAD_LINKS = ["400", "500", "600"]
20
+ .map((weight) => {
21
+ const file = new RegExp(`url\\(\\./fonts/(ibm-plex-sans-latin-${weight}-normal-[0-9a-f]+\\.woff2)\\)`).exec(plexPreset)?.[1];
22
+ if (!file) throw new Error(`The @k2b/ui Plex preset has no Latin IBM Plex Sans ${weight} face`);
23
+ return `<link rel="preload" href="/public/fonts/${file}" as="font" type="font/woff2" crossorigin>`;
24
+ })
25
+ .join("\n ");
@@ -12,15 +12,9 @@ export type ConnectNatsOptions = {
12
12
  name: string;
13
13
  };
14
14
 
15
- export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> => {
16
- if (env.NATS_SERVERS.length === 0) {
17
- throw new Error(
18
- `NATS_SERVERS is not set (connection "${name}"). @k2b/sync needs a comma-separated list of ` +
19
- "nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
20
- );
21
- }
22
-
23
- return connect({
15
+ /** Dial `NATS_SERVERS` once. `startProcessSync()` requires the list and retries while NATS does not answer. */
16
+ export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> =>
17
+ connect({
24
18
  servers: env.NATS_SERVERS,
25
19
  name,
26
20
  ignoreClusterUpdates: env.NATS_IGNORE_CLUSTER_UPDATES,
@@ -31,4 +25,3 @@ export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsCon
31
25
  ...(env.NATS_CREDS_FILE ? { authenticator: credsAuthenticator(readFileSync(env.NATS_CREDS_FILE)) } : {}),
32
26
  ...(env.NATS_TLS_CA_FILE ? { tls: { caFile: env.NATS_TLS_CA_FILE } } : {}),
33
27
  });
34
- };
@@ -8,8 +8,11 @@
8
8
  * nothing touches sync during module evaluation.
9
9
  */
10
10
  import { hostname } from "node:os";
11
- import { createSync, type Sync } from "@k2b/sync";
11
+ import { createSync, type Sync, SyncError } from "@k2b/sync";
12
+ import { expBackoff } from "@k2b/sync/retry";
13
+ import type { NatsConnection } from "@nats-io/transport-node";
12
14
  import { env } from "../config/env";
15
+ import { logger } from "../services/logging";
13
16
  import { flushSyncTraceEvents, observeSyncEvent } from "../services/logging/trace";
14
17
  import { connectNats } from "./nats-connection";
15
18
  import { withSyncBudgets } from "./sync-budget";
@@ -65,10 +68,93 @@ export type ProcessSync = {
65
68
  stop: () => Promise<void>;
66
69
  };
67
70
 
71
+ /**
72
+ * How long a starting process waits for NATS and JetStream. After a host or
73
+ * stack restart, NATS nodes come up in any order and JetStream must recover
74
+ * its streams and elect a leader before its API answers; five minutes covers
75
+ * that. A process that still gets no answer exits instead of staying alive
76
+ * without readiness, so Docker or Kubernetes restarts it and the outage shows
77
+ * up as restarts.
78
+ */
79
+ const NATS_STARTUP_BUDGET_MS = 5 * 60_000;
80
+ // About 1 s, doubling to 20 s, each ±50 % so processes that restarted together spread their retries.
81
+ const NATS_STARTUP_BACKOFF = { baseMs: 1_000, maxMs: 20_000, jitter: 0.5 };
82
+
83
+ const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
84
+
85
+ /**
86
+ * Run `attempt` until it succeeds, with exponential backoff and jitter. A
87
+ * `SyncError` describes the server or the declarations (unsupported version,
88
+ * JetStream disabled, drift), which waiting does not change, so it fails at
89
+ * once. Every other failure (refused or unresolved address, timeout, JetStream
90
+ * without a leader) is logged and retried until `budgetMs` is spent; then the
91
+ * process exits with status 1.
92
+ */
93
+ export const waitForNats = async <T>(
94
+ attempt: () => Promise<T>,
95
+ { application, budgetMs = NATS_STARTUP_BUDGET_MS }: { application: string; budgetMs?: number },
96
+ ): Promise<T> => {
97
+ const log = logger("sync");
98
+ const deadline = Date.now() + budgetMs;
99
+ for (let attempts = 1; ; attempts++) {
100
+ try {
101
+ const result = await attempt();
102
+ if (attempts > 1) log.info("NATS and JetStream answered", { application, attempts });
103
+ return result;
104
+ } catch (error) {
105
+ if (error instanceof SyncError) throw error;
106
+ const remainingMs = deadline - Date.now();
107
+ if (remainingMs <= 0) {
108
+ log.error("NATS and JetStream did not answer within the startup budget; exiting so the process is restarted", {
109
+ application,
110
+ attempts,
111
+ budgetMs,
112
+ error: errorMessage(error),
113
+ });
114
+ process.exit(1);
115
+ }
116
+ const retryInMs = Math.min(remainingMs, expBackoff(attempts, NATS_STARTUP_BACKOFF));
117
+ log.warn("NATS or JetStream is not ready; retrying", { application, attempt: attempts, retryInMs, error: errorMessage(error) });
118
+ await Bun.sleep(retryInMs);
119
+ }
120
+ }
121
+ };
122
+
123
+ /** One start attempt: connect, bind a new Sync instance, and wait for `ready()`; release both when it fails. */
124
+ const connectReadySync = async (application: string): Promise<{ sync: Sync; connection: NatsConnection }> => {
125
+ const connection = await connectNats({ name: `${application}@${hostname()}` });
126
+ let sync: Sync | undefined;
127
+ try {
128
+ sync = withSyncBudgets(
129
+ createSync({
130
+ connection,
131
+ namespace: env.SYNC_NAMESPACE,
132
+ application,
133
+ defaults: { replicas: env.SYNC_REPLICAS },
134
+ observe: (event) => observeSyncEvent(event, application),
135
+ }),
136
+ { connection, namespace: env.SYNC_NAMESPACE },
137
+ );
138
+ bindProcessSync(sync);
139
+ await sync.ready();
140
+ return { sync, connection };
141
+ } catch (error) {
142
+ if (current === sync) unbindProcessSync();
143
+ await flushSyncTraceEvents();
144
+ await connection.close();
145
+ throw error;
146
+ }
147
+ };
148
+
68
149
  /**
69
150
  * Connect NATS, create and bind the process Sync instance, and wait until it is
70
151
  * ready. Existing streams of its jobs, queues, and topics take the byte
71
152
  * limits they declare before first use (see `sync-budget.ts`).
153
+ *
154
+ * While NATS or JetStream does not answer, it retries with a fresh connection
155
+ * and instance (see `waitForNats`), and exits the process once
156
+ * `NATS_STARTUP_BUDGET_MS` is spent. Callers are process entry points; nothing
157
+ * serves readiness before this returns.
72
158
  */
73
159
  export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
74
160
  if (!env.SYNC_NAMESPACE.trim()) {
@@ -77,31 +163,16 @@ export const startProcessSync = async ({ application }: { application: string })
77
163
  "must share the same @k2b/sync namespace.",
78
164
  );
79
165
  }
166
+ if (env.NATS_SERVERS.length === 0) {
167
+ throw new Error(
168
+ `NATS_SERVERS is not set (application "${application}"). @k2b/sync needs a comma-separated list of ` +
169
+ "nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
170
+ );
171
+ }
80
172
  if (current || starting) throw new Error("A Sync instance is already starting or bound to this process");
81
173
  starting = true;
82
174
  try {
83
- const connection = await connectNats({ name: `${application}@${hostname()}` });
84
- let sync: Sync | undefined;
85
- try {
86
- sync = withSyncBudgets(
87
- createSync({
88
- connection,
89
- namespace: env.SYNC_NAMESPACE,
90
- application,
91
- defaults: { replicas: env.SYNC_REPLICAS },
92
- observe: (event) => observeSyncEvent(event, application),
93
- }),
94
- { connection, namespace: env.SYNC_NAMESPACE },
95
- );
96
- bindProcessSync(sync);
97
- await sync.ready();
98
- } catch (error) {
99
- if (current === sync) unbindProcessSync();
100
- await flushSyncTraceEvents();
101
- await connection.close();
102
- throw error;
103
- }
104
- const active = sync;
175
+ const { sync: active, connection } = await waitForNats(() => connectReadySync(application), { application });
105
176
  let stopPromise: Promise<void> | undefined;
106
177
  return {
107
178
  sync: active,
@@ -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
  }
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { PrincipalSchema } from "../contracts/shared";
2
+ import { PrincipalSchema, ServiceAccountKindSchema } from "../contracts/shared";
3
3
  import { AiPriceSchema } from "./ai-costs";
4
4
 
5
5
  export const AiBackgroundBudgetSchema = z
@@ -22,6 +22,8 @@ export const AiQuotaRuleSchema = z
22
22
  .object({
23
23
  principal: PrincipalSchema.refine((p) => p.type !== "public", "Chat quotas require an identity."),
24
24
  displayName: z.string().max(300).optional(),
25
+ /** Presentation only, like `displayName`: service-account grants show the same kind as the principal picker. */
26
+ serviceAccountKind: ServiceAccountKindSchema.optional(),
25
27
  limit: AiPriceSchema.nullable(),
26
28
  })
27
29
  .strict(),
@@ -71,11 +73,22 @@ export const AiQuotaUsersQuerySchema = z
71
73
  })
72
74
  .strict();
73
75
 
76
+ /**
77
+ * One own chat allowance as the browser sees it. It carries no amounts, limits, or unit:
78
+ * `usedPercent` is a whole number from 0 to 100 and reaches 100 only when the allowance is used up.
79
+ * Below 100 the server can still reject a call that the rest of the allowance cannot cover.
80
+ * It is `null` when the allowance is unlimited or its usage cannot be measured right now.
81
+ */
82
+ export type AiChatQuotaBalance = {
83
+ scope: string;
84
+ unlimited: boolean;
85
+ usedPercent: number | null;
86
+ resetsAt: string | null;
87
+ };
74
88
  export type AiChatQuotaSnapshot = {
75
- unit?: string;
76
89
  unlimitedModels?: string[];
77
90
  enabled: boolean;
78
- balances: Omit<AiQuotaBalance, "sources" | "sourceDetails">[];
91
+ balances: AiChatQuotaBalance[];
79
92
  };
80
93
 
81
94
  export const AiQuotaReportQuerySchema = z.object({
@@ -13,7 +13,7 @@ export default function LayoutPreferences(props: LayoutPreferencesProps) {
13
13
 
14
14
  return (
15
15
  <Dropdown.Root items={preferences.items()} label={preferences.messages().preferencesMenuLabel} position={props.position} width="14rem">
16
- <Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" tooltip={false} variant="secondary">
16
+ <Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" variant="secondary">
17
17
  <i class="ti ti-adjustments-horizontal" aria-hidden="true" />
18
18
  </Dropdown.Trigger>
19
19
  </Dropdown.Root>
@@ -7,7 +7,10 @@ import { createPreferenceController } from "./preference-controller";
7
7
  /**
8
8
  * Labeled language and theme controls for the MinimalLayout footer. The
9
9
  * language trigger names the current language; the theme button names the
10
- * mode it switches to, like every other Cloud preference menu.
10
+ * mode it switches to, like every other Cloud preference menu. Below `md` both
11
+ * are compact, the language code and the theme icon with the full names as
12
+ * accessible labels, so they fit next to the legal links on a phone. CSS picks
13
+ * the form, so the server response is already final.
11
14
  */
12
15
  export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
13
16
  const locale = useLocale();
@@ -35,13 +38,14 @@ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThe
35
38
  >
36
39
  <Dropdown.Trigger label={`${t().language}: ${languageName()}`} size="sm" tooltip={false} variant="ghost">
37
40
  <i class="ti ti-language" aria-hidden="true" />
38
- {languageName()}
39
- <i class="ti ti-chevron-down" aria-hidden="true" />
41
+ <span class="hidden md:inline">{languageName()}</span>
42
+ <span class="md:hidden">{language().toUpperCase()}</span>
43
+ <i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
40
44
  </Dropdown.Trigger>
41
45
  </Dropdown.Root>
42
- <Button onClick={preferences.toggleTheme} size="sm" variant="ghost">
46
+ <Button aria-label={preferences.themeLabel()} onClick={preferences.toggleTheme} size="sm" variant="ghost">
43
47
  <i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
44
- {preferences.themeLabel()}
48
+ <span class="hidden md:inline">{preferences.themeLabel()}</span>
45
49
  </Button>
46
50
  </div>
47
51
  );
@@ -345,8 +345,8 @@
345
345
  display: none;
346
346
  }
347
347
  .cloud-resource-search :is(.cloud-resource-search__result, .cloud-resource-search__tag-option, .cloud-resource-search__tag):focus-visible {
348
- outline: 2px solid var(--color-blue-500);
349
- outline-offset: -2px;
348
+ outline: var(--k2b-focus-width) solid var(--color-blue-500);
349
+ outline-offset: calc(-1 * var(--k2b-focus-width));
350
350
  }
351
351
  @media (min-width: 48rem) {
352
352
  .cloud-resource-search {
@@ -219,8 +219,8 @@
219
219
 
220
220
  @media (forced-colors: active) {
221
221
  .prompt-search-surface:focus-within {
222
- outline: 2px solid CanvasText;
223
- outline-offset: -2px;
222
+ outline: var(--k2b-focus-width) solid CanvasText;
223
+ outline-offset: calc(-1 * var(--k2b-focus-width));
224
224
  }
225
225
  }
226
226
 
@@ -433,8 +433,8 @@
433
433
 
434
434
  .md-link-widget:focus-visible {
435
435
  border-radius: 0.125rem;
436
- outline: 2px solid var(--ui-app-accent-border);
437
- outline-offset: 2px;
436
+ outline: var(--k2b-focus-width) solid var(--ui-app-accent-border);
437
+ outline-offset: var(--k2b-focus-offset);
438
438
  }
439
439
 
440
440
  .md-link-icon {
@@ -957,8 +957,8 @@
957
957
  }
958
958
 
959
959
  .minimal-layout-footer__links a:focus-visible {
960
- outline: 2px solid var(--k2b-focus-ring);
961
- outline-offset: 2px;
960
+ outline: var(--k2b-focus-width) solid var(--k2b-focus-ring);
961
+ outline-offset: var(--k2b-focus-offset);
962
962
  }
963
963
 
964
964
  .k2b-ui .minimal-layout-footer .k2b-button[data-size] {
@@ -984,6 +984,31 @@
984
984
  }
985
985
  }
986
986
 
987
+ /* The one centered card of a standalone page (sign-in, a public share, an
988
+ unlock form). Phones have no room for a frame inside the page padding, so
989
+ below Tailwind's `md` breakpoint the card loses border, radius, shadow,
990
+ surface and its own padding, and its content sits on the page background.
991
+ Pure CSS on the same markup: the server response is already in its final
992
+ form. The second selector outranks an elevated @k2b/ui Paper.
993
+ The query is the exact opposite of `md:`, so a page that moves its own
994
+ padding or columns with `md:` switches at the same width as the card, also
995
+ with a larger default font size. `(width < 48rem)` is not: the build lowers
996
+ it to `max-width: calc(48rem - .001px)`, which Chromium still matches at
997
+ 48rem. Without a frame there is nothing left to clip, so the card also
998
+ stops clipping: its content then reaches the card edge, and a clipping card
999
+ would cut the outer focus ring of a full-width button or field. */
1000
+ @media not all and (min-width: 48rem) {
1001
+ .standalone-card,
1002
+ .k2b-ui .k2b-paper.standalone-card {
1003
+ padding: 0;
1004
+ border: 0;
1005
+ border-radius: 0;
1006
+ background: transparent;
1007
+ box-shadow: none;
1008
+ overflow: visible;
1009
+ }
1010
+ }
1011
+
987
1012
  /* Workspace navigation chrome and resource identity. */
988
1013
  .workspace-sidebar-header {
989
1014
  min-height: var(--ui-control-md);
@@ -0,0 +1,7 @@
1
+ // The @k2b/ui Plex preset, imported as text (`with { type: "text" }`). The
2
+ // importing module references this file, so npm consumers that type-check
3
+ // Cloud's sources get the declaration without adding it to their tsconfig.
4
+ declare module "@k2b/ui/fonts/plex.css" {
5
+ const content: string;
6
+ export default content;
7
+ }