@k2b/cloud 0.15.0 → 0.16.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.15.0",
3
+ "version": "0.16.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": {
@@ -99,7 +99,7 @@
99
99
  "@tailwindcss/typography": "0.5.20",
100
100
  "@k2b/nessi": "0.12.1",
101
101
  "@k2b/ssr": "0.14.0",
102
- "@k2b/ui": "0.6.2",
102
+ "@k2b/ui": "0.6.3",
103
103
  "@k2b/stdlib": "0.26.0",
104
104
  "@k2b/sync": "6.5.0",
105
105
  "@nats-io/transport-node": "3.4.0",
package/scripts/build.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  * public/<id>/app.css Tailwind, if the app has src/styles/app.css
19
19
  * public/<id>/... anything from <appDir>/public/
20
20
  * assets/... anything from <appDir>/src/assets/ (appAssetPath)
21
+ * cli/<name>/... `cld` plugin per `defineApp({ cli })` module: cli.js,
22
+ * references/*.md, manifest.json
21
23
  *
22
24
  * If the app needs additional artefacts (core's global.css, logo, katex),
23
25
  * it ships a `scripts/build-extras.ts` that this script runs at the end.
@@ -131,6 +133,7 @@ try {
131
133
  __CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
132
134
  __CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./pdf-render/worker.js"),
133
135
  __CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
136
+ __CLOUD_CLI_PLUGINS__: JSON.stringify("./cli/"),
134
137
  },
135
138
  plugins: [(app?.plugin ?? plugin)()],
136
139
  });
@@ -175,6 +178,14 @@ if (existsSync(appAssets)) {
175
178
  await cp(appAssets, resolve(dist, "assets"), { recursive: true });
176
179
  }
177
180
 
181
+ // `cld` plugins: one self-contained module bundle plus references per module.
182
+ // Imported after the app config so the framework sees the APP_DIR set above.
183
+ const { buildCliPlugin, writeCliPlugin } = await import("../src/_internal/cli-plugins");
184
+ for (const [name, declaration] of Object.entries(app?.meta.cli ?? {})) {
185
+ const plugin = await buildCliPlugin({ appDir, appId, name, declaration, version });
186
+ await writeCliPlugin(resolve(dist, "cli", name), plugin);
187
+ }
188
+
178
189
  if (appId !== "core" && app) {
179
190
  await writeAppFavicon({
180
191
  publicDir: distPublic,
@@ -0,0 +1,248 @@
1
+ /**
2
+ * `cld` plugins served by the application that owns them.
3
+ *
4
+ * `defineApp({ cli })` names each CLI module and the paths of its source and
5
+ * skill references. The production build bundles every module into one
6
+ * self-contained ESM file and writes it with its references and manifest to
7
+ * `dist/cli/<name>/`. At run time the application serves those files under
8
+ * `/cli/plugins/<name>/`; in development it builds them from source on first
9
+ * request. Only authenticated callers allowed by `cli.plugins.access` get any
10
+ * of them.
11
+ */
12
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
13
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { Glob } from "bun";
16
+ import { type Context, Hono } from "hono";
17
+ import { every } from "hono/combine";
18
+ import { createMiddleware } from "hono/factory";
19
+ import {
20
+ CLOUD_CLI_API_VERSION,
21
+ CLOUD_CLI_MODULE_NAME,
22
+ CLOUD_CLI_PLUGIN_ENTRY,
23
+ CLOUD_CLI_PLUGIN_ROUTE,
24
+ CLOUD_CLI_REFERENCE_INDEX,
25
+ type CloudCliPluginFile,
26
+ type CloudCliPluginManifest,
27
+ cloudCliPluginDigest,
28
+ isCloudCliPluginFilePath,
29
+ parseCloudCliPluginManifest,
30
+ sha512Hex,
31
+ validateCloudCliModule,
32
+ } from "../cli/plugin";
33
+ import { env } from "../config/env";
34
+ import type { AppCliModules } from "../contracts/app";
35
+ import { type AuthContext, auth } from "../server/middleware/auth";
36
+ import { logger } from "../services/logging";
37
+ import { get } from "../services/settings";
38
+ import { buildMetadata } from "./build-metadata";
39
+
40
+ /** Set by `scripts/build.ts`; the bundle lives next to `dist/cli/`. */
41
+ declare const __CLOUD_CLI_PLUGINS__: string | undefined;
42
+
43
+ export const CLI_PLUGIN_ACCESS_SETTING = "cli.plugins.access";
44
+
45
+ /** Reject names and paths that cannot become a served plugin. Returns the module names. */
46
+ export const validateAppCliModules = (appId: string, cli: AppCliModules | undefined): string[] => {
47
+ const names = Object.keys(cli ?? {});
48
+ for (const name of names) {
49
+ if (!CLOUD_CLI_MODULE_NAME.test(name)) throw new Error(`App "${appId}" declares CLI module "${name}"; use a kebab-case name`);
50
+ const declaration = cli?.[name];
51
+ for (const key of ["module", "references"] as const) {
52
+ const path = declaration?.[key];
53
+ if (typeof path !== "string" || path === "" || isAbsolute(path)) {
54
+ throw new Error(`App "${appId}" CLI module "${name}" needs a relative ${key} path`);
55
+ }
56
+ }
57
+ }
58
+ return names;
59
+ };
60
+
61
+ export type BuiltCliPlugin = {
62
+ manifest: CloudCliPluginManifest;
63
+ files: ReadonlyMap<string, Uint8Array<ArrayBuffer>>;
64
+ };
65
+
66
+ const referenceFiles = async (directory: string): Promise<Map<string, Uint8Array<ArrayBuffer>>> => {
67
+ const files = new Map<string, Uint8Array<ArrayBuffer>>();
68
+ for await (const path of new Glob("**/*").scan({ cwd: directory, onlyFiles: true, dot: true })) {
69
+ const name = `references/${path.split(sep).join("/")}`;
70
+ if (!isCloudCliPluginFilePath(name)) throw new Error(`CLI reference ${path} must be a Markdown file with a plain name`);
71
+ files.set(name, new Uint8Array(await readFile(join(directory, path))));
72
+ }
73
+ if (!files.has(CLOUD_CLI_REFERENCE_INDEX)) throw new Error(`CLI references in ${directory} need an index.md`);
74
+ return files;
75
+ };
76
+
77
+ /**
78
+ * Bundle one CLI module and collect its references. The bundle carries every
79
+ * dependency, including its own copy of `@k2b/cloud/cli`, because `cld`
80
+ * installs no plugin dependencies.
81
+ */
82
+ export const buildCliPlugin = async (params: {
83
+ appDir: string;
84
+ appId: string;
85
+ name: string;
86
+ declaration: { module: string; references: string };
87
+ version: string;
88
+ }): Promise<BuiltCliPlugin> => {
89
+ const { appDir, appId, name, declaration, version } = params;
90
+ const result = await Bun.build({
91
+ entrypoints: [resolve(appDir, declaration.module)],
92
+ target: "bun",
93
+ format: "esm",
94
+ minify: true,
95
+ // Playwright references this optional BiDi adapter lazily; Cloud uses CDP.
96
+ external: ["chromium-bidi/*"],
97
+ });
98
+ if (!result.success) {
99
+ throw new Error(`CLI module "${name}" failed to bundle:\n${result.logs.map((log) => String(log)).join("\n")}`);
100
+ }
101
+ if (result.outputs.length !== 1 || result.outputs[0]!.kind !== "entry-point") {
102
+ throw new Error(`CLI module "${name}" must bundle into one file; it produced ${result.outputs.length}`);
103
+ }
104
+ const files = new Map<string, Uint8Array<ArrayBuffer>>([
105
+ [CLOUD_CLI_PLUGIN_ENTRY, new Uint8Array(await result.outputs[0]!.arrayBuffer())],
106
+ ...(await referenceFiles(resolve(appDir, declaration.references))),
107
+ ]);
108
+ const list: CloudCliPluginFile[] = [...files]
109
+ .map(([path, bytes]) => ({ path, size: bytes.byteLength, sha512: sha512Hex(bytes) }))
110
+ .sort((left, right) => (left.path < right.path ? -1 : 1));
111
+ return {
112
+ manifest: {
113
+ apiVersion: CLOUD_CLI_API_VERSION,
114
+ name,
115
+ app: appId,
116
+ version,
117
+ entry: CLOUD_CLI_PLUGIN_ENTRY,
118
+ digest: cloudCliPluginDigest(list),
119
+ files: list,
120
+ },
121
+ files,
122
+ };
123
+ };
124
+
125
+ /** Write a built plugin to `<directory>/`, then import it once to check its default export. */
126
+ export const writeCliPlugin = async (directory: string, plugin: BuiltCliPlugin): Promise<void> => {
127
+ for (const [path, bytes] of plugin.files) {
128
+ await mkdir(dirname(join(directory, path)), { recursive: true });
129
+ await writeFile(join(directory, path), bytes);
130
+ }
131
+ await writeFile(join(directory, "manifest.json"), `${JSON.stringify(plugin.manifest, null, 2)}\n`);
132
+ const loaded: unknown = await import(pathToFileURL(join(directory, CLOUD_CLI_PLUGIN_ENTRY)).href);
133
+ const module = validateCloudCliModule(typeof loaded === "object" && loaded !== null ? (loaded as { default?: unknown }).default : null);
134
+ if (module.name !== plugin.manifest.name) {
135
+ throw new Error(`CLI module declared as "${plugin.manifest.name}" exports the name "${module.name}"`);
136
+ }
137
+ };
138
+
139
+ type ServedCliPlugin = {
140
+ manifest: CloudCliPluginManifest;
141
+ read: (path: string) => Promise<Uint8Array<ArrayBuffer>>;
142
+ };
143
+
144
+ const builtPluginsDirectory = (): string | null =>
145
+ typeof __CLOUD_CLI_PLUGINS__ === "string" ? fileURLToPath(new URL(__CLOUD_CLI_PLUGINS__, import.meta.url)) : null;
146
+
147
+ const loadBuiltPlugin = async (root: string, name: string): Promise<ServedCliPlugin> => {
148
+ const directory = join(root, name);
149
+ const manifest = parseCloudCliPluginManifest(JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")));
150
+ return { manifest, read: async (path) => new Uint8Array(await readFile(join(directory, path))) };
151
+ };
152
+
153
+ const buildSourcePlugin = async (appId: string, name: string, declaration: { module: string; references: string }) => {
154
+ const plugin = await buildCliPlugin({
155
+ appDir: env.APP_DIR ?? process.cwd(),
156
+ appId,
157
+ name,
158
+ declaration,
159
+ version: buildMetadata.version,
160
+ });
161
+ return { manifest: plugin.manifest, read: async (path: string) => plugin.files.get(path)! };
162
+ };
163
+
164
+ /** Resolves served plugins: prebuilt files in production, a cached source build in development. */
165
+ const createPluginSource = (appId: string, cli: AppCliModules, root: string | null) => {
166
+ const cache = new Map<string, Promise<ServedCliPlugin>>();
167
+ return (name: string): Promise<ServedCliPlugin> | null => {
168
+ const declaration = Object.hasOwn(cli, name) ? cli[name] : undefined;
169
+ if (!declaration) return null;
170
+ let pending = cache.get(name);
171
+ if (!pending) {
172
+ pending = root ? loadBuiltPlugin(root, name) : buildSourcePlugin(appId, name, declaration);
173
+ // A failed source build is retried on the next request instead of being cached.
174
+ pending.catch(() => cache.delete(name));
175
+ cache.set(name, pending);
176
+ }
177
+ return pending;
178
+ };
179
+ };
180
+
181
+ /**
182
+ * Access rule for every plugin route, the list included: an authenticated
183
+ * caller (session, OAuth token, or API key) with `read` scope when OAuth is
184
+ * used, and no guest account unless `cli.plugins.access` is `all_users`. A
185
+ * service account without a delegated user is not a guest.
186
+ */
187
+ export const requireCliPluginAccess = every(
188
+ auth.requireRole("authenticated"),
189
+ auth.requireOAuthScope("read"),
190
+ createMiddleware<AuthContext>(async (c, next) => {
191
+ const access = await get<string>(CLI_PLUGIN_ACCESS_SETTING);
192
+ if (access !== "all_users" && c.get("user")?.profile === "guest") {
193
+ return c.json({ code: "FORBIDDEN", message: "CLI plugins are available to full accounts only" }, 403);
194
+ }
195
+ return next();
196
+ }),
197
+ );
198
+
199
+ const CONTENT_TYPES: Record<string, string> = {
200
+ ".js": "text/javascript; charset=utf-8",
201
+ ".md": "text/markdown; charset=utf-8",
202
+ };
203
+
204
+ const privateHeaders = { "cache-control": "private, no-store", "x-content-type-options": "nosniff" } as const;
205
+
206
+ /**
207
+ * Framework-owned plugin routes of one application, mounted at
208
+ * `/cli/plugins`. The gateway reaches them through the `/cli/plugins/<name>`
209
+ * prefixes `defineApp` adds to the application's routes.
210
+ */
211
+ export const createCliPluginRoutes = (appId: string, cli: AppCliModules, builtRoot = builtPluginsDirectory()) => {
212
+ const source = createPluginSource(appId, cli, builtRoot);
213
+ const log = logger("cli-plugins");
214
+ const served = async (c: Context<AuthContext>): Promise<ServedCliPlugin | Response> => {
215
+ const pending = source(c.req.param("name") ?? "");
216
+ if (!pending) return c.json({ code: "NOT_FOUND", message: "CLI plugin not found" }, 404);
217
+ try {
218
+ return await pending;
219
+ } catch (error) {
220
+ log.error("CLI plugin unavailable", {
221
+ appId,
222
+ name: c.req.param("name"),
223
+ error: error instanceof Error ? error.message : String(error),
224
+ });
225
+ return c.json({ code: "UNAVAILABLE", message: "CLI plugin is unavailable" }, 503);
226
+ }
227
+ };
228
+ return new Hono<AuthContext>()
229
+ .get("/:name/manifest.json", requireCliPluginAccess, async (c) => {
230
+ const plugin = await served(c);
231
+ if (plugin instanceof Response) return plugin;
232
+ return c.json(plugin.manifest, 200, privateHeaders);
233
+ })
234
+ .get("/:name/*", requireCliPluginAccess, async (c) => {
235
+ const plugin = await served(c);
236
+ if (plugin instanceof Response) return plugin;
237
+ const prefix = `${CLOUD_CLI_PLUGIN_ROUTE}/${plugin.manifest.name}/`;
238
+ const path = c.req.path.startsWith(prefix) ? c.req.path.slice(prefix.length) : "";
239
+ if (!plugin.manifest.files.some((file) => file.path === path)) {
240
+ return c.json({ code: "NOT_FOUND", message: "CLI plugin file not found" }, 404);
241
+ }
242
+ const bytes = await plugin.read(path);
243
+ return c.body(bytes, 200, { ...privateHeaders, "content-type": CONTENT_TYPES[path.slice(path.lastIndexOf("."))]! });
244
+ });
245
+ };
246
+
247
+ /** The gateway prefixes of an application's plugin routes. */
248
+ export const cliPluginRoutePrefixes = (names: readonly string[]): string[] => names.map((name) => `${CLOUD_CLI_PLUGIN_ROUTE}/${name}`);
@@ -17,10 +17,12 @@ import { routes } from "@k2b/ssr/hono";
17
17
  import type { Sync } from "@k2b/sync";
18
18
  import { type Context, type Handler, Hono } from "hono";
19
19
  import { generateSpecs } from "hono-openapi";
20
+ import { CLOUD_CLI_PLUGIN_ROUTE } from "../cli/plugin";
20
21
  import { env } from "../config/env";
21
22
  import type {
22
23
  AppAdminNavigationGroup,
23
24
  AppAppearance,
25
+ AppCliModules,
24
26
  AppLifecycle,
25
27
  AppMeta,
26
28
  AppPresentationCatalog,
@@ -69,6 +71,7 @@ import { compileAppPresentation } from "./app-presentation";
69
71
  import { readBoundedJson } from "./bounded-json";
70
72
  import { appRuntimeMetadata } from "./build-metadata";
71
73
  import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
74
+ import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
72
75
  import { createHeartbeat } from "./heartbeat";
73
76
  import { compileHelp } from "./help";
74
77
  import { createPageResponses } from "./page-responses";
@@ -197,6 +200,18 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
197
200
  * `/api/<id>` entry in `routes`).
198
201
  */
199
202
  openapi?: string;
203
+ /**
204
+ * `cld` modules this app serves as plugins, keyed by module name
205
+ * (`cld <name>`). Paths are relative to the application directory:
206
+ *
207
+ * cli: { inventory: { module: "src/cli.ts", references: "src/cli-references" } }
208
+ *
209
+ * The production build bundles each module with its skill references into
210
+ * the image; the framework serves them under `/cli/plugins/<name>/` to
211
+ * authenticated callers that `cli.plugins.access` allows, and adds that
212
+ * prefix to the app's gateway routes.
213
+ */
214
+ cli?: AppCliModules;
200
215
  /**
201
216
  * Project root used for SSR component IDs and development assets.
202
217
  * Defaults to `process.cwd()`. Override only if you run the entrypoint
@@ -305,6 +320,7 @@ export const defineApp = <
305
320
  configurePostgresApplicationName(opts.id);
306
321
  const isDevelopment = env.IS_DEVELOPMENT;
307
322
  const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
323
+ const cliModules = validateAppCliModules(opts.id, opts.cli);
308
324
 
309
325
  // ── 0. Register declared settings into the runtime registry ──────────
310
326
  // SETTINGS_MAP is the single source of truth for validation in store.ts
@@ -392,13 +408,14 @@ export const defineApp = <
392
408
  links: group.links.map((link) => ({ ...link })),
393
409
  }),
394
410
  ),
395
- routes: [...opts.routes],
411
+ routes: [...opts.routes, ...cliPluginRoutePrefixes(cliModules)],
396
412
  nav: opts.nav,
397
413
  legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
398
414
  searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
399
415
  widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
400
416
  settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
401
417
  openapi: opts.openapi,
418
+ cli: opts.cli,
402
419
  };
403
420
  const meta: AppMeta = { ...baseMeta, presentation: compileAppPresentation(baseMeta, opts.presentation) };
404
421
 
@@ -492,6 +509,7 @@ export const defineApp = <
492
509
  widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
493
510
  settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
494
511
  openapi: advertiseOpenapi ? opts.openapi : undefined,
512
+ cliModules: cliModules.length > 0 ? cliModules : undefined,
495
513
  };
496
514
 
497
515
  // Heartbeat
@@ -554,6 +572,7 @@ export const defineApp = <
554
572
  // /api/_internal/capabilities/v1/* when capabilities are declared
555
573
  // /api/_internal/widgets/v1/* when widget handlers are bound
556
574
  // /_internal/sync/* process-local sync operations (admin only)
575
+ // /cli/plugins/<name>/* `cld` plugins, when `cli` modules are declared
557
576
  // <opts.openapi> OpenAPI JSON spec, when both opts.openapi
558
577
  // and startOpts.openapi are set
559
578
  const ssrMountPath = config.basePath ? `${config.basePath}/_ssr` : "/_ssr";
@@ -582,6 +601,8 @@ export const defineApp = <
582
601
  })
583
602
  .route("/_internal/sync", createSyncOpsRoutes(getProcessSync));
584
603
 
604
+ if (opts.cli && cliModules.length > 0) server.route(CLOUD_CLI_PLUGIN_ROUTE, createCliPluginRoutes(meta.id, opts.cli));
605
+
585
606
  if (compiledHelp) {
586
607
  const pageBase = compiledHelp.summary.pageBase;
587
608
  const centralPageBase = `/help/apps/${encodeURIComponent(meta.id)}`;
@@ -1,3 +1,4 @@
1
+ import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
1
2
  import type { AppRegistryEntry } from "../contracts/registry";
2
3
 
3
4
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -112,6 +113,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
112
113
  }
113
114
  }
114
115
  if (value.settingKeys !== undefined && !isStringArray(value.settingKeys)) return invalid("settingKeys", "an array of strings");
116
+ if (
117
+ value.cliModules !== undefined &&
118
+ (!isStringArray(value.cliModules) || value.cliModules.some((name) => !CLOUD_CLI_MODULE_NAME.test(name)))
119
+ ) {
120
+ return invalid("cliModules", "an array of CLI module names");
121
+ }
115
122
  if (value.capabilities !== undefined) {
116
123
  if (
117
124
  !isRecord(value.capabilities) ||
@@ -78,8 +78,12 @@ export default function PrincipalPicker(props: {
78
78
  description:
79
79
  e.serviceAccount.kind === "user_delegated"
80
80
  ? t().userBoundServiceAccount
81
- : [e.serviceAccount.appId, e.serviceAccount.resourceType, e.serviceAccount.resourceId].filter(Boolean).join(" · "),
82
- icon: "ti ti-key",
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",
83
87
  });
84
88
  }
85
89
  }
@@ -15,6 +15,8 @@ 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
+ standaloneServiceAccount: "Service account",
19
+ agentServiceAccount: "Agent",
18
20
  noDirectGrants: "No direct access granted yet.",
19
21
  addAll: "Add user, group, service account, or audience…",
20
22
  addService: "Add user, group, or service account…",
@@ -72,6 +74,8 @@ export const accessMessages = i18n.define({
72
74
  removeAccessConfirm: ({ name }) => `Zugriff für ${name} entfernen?`,
73
75
  removeAccess: "Zugriff entfernen",
74
76
  userBoundServiceAccount: "Benutzergebundenes Dienstkonto",
77
+ standaloneServiceAccount: "Dienstkonto",
78
+ agentServiceAccount: "Agent",
75
79
  noDirectGrants: "Noch kein direkter Zugriff erteilt.",
76
80
  addAll: "Benutzer, Gruppe, Dienstkonto oder Zielgruppe hinzufügen…",
77
81
  addService: "Benutzer, Gruppe oder Dienstkonto hinzufügen…",
@@ -33,7 +33,7 @@ export type EntitySearchPrincipal =
33
33
  type: "service_account";
34
34
  serviceAccountId: string;
35
35
  name: string;
36
- kind: "user_delegated" | "resource_bound";
36
+ kind: "user_delegated" | "resource_bound" | "standalone" | "agent";
37
37
  appId: string | null;
38
38
  resourceType: string | null;
39
39
  resourceId: string | null;
@@ -102,7 +102,7 @@ type ApiGroup = {
102
102
  type ApiServiceAccount = {
103
103
  id: string;
104
104
  name: string;
105
- kind: "user_delegated" | "resource_bound";
105
+ kind: "user_delegated" | "resource_bound" | "standalone" | "agent";
106
106
  appId: string | null;
107
107
  resourceType: string | null;
108
108
  resourceId: string | null;
@@ -290,12 +290,16 @@ const EntitySearch = (props: EntitySearchProps) => {
290
290
  <For each={serviceAccounts()}>
291
291
  {(serviceAccount) => (
292
292
  <ResultRow
293
- icon="ti-key"
293
+ icon={serviceAccount.kind === "agent" ? "ti-robot" : "ti-key"}
294
294
  title={serviceAccount.name}
295
295
  subtitle={
296
296
  serviceAccount.kind === "user_delegated"
297
297
  ? "User-bound service account"
298
- : [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
298
+ : serviceAccount.kind === "agent"
299
+ ? "Agent"
300
+ : serviceAccount.kind === "standalone"
301
+ ? "Service account"
302
+ : [serviceAccount.appId, serviceAccount.resourceType, serviceAccount.resourceId].filter(Boolean).join(" · ")
299
303
  }
300
304
  disabled={props.disabled}
301
305
  onSelect={() =>
@@ -22,6 +22,7 @@ export type AvatarUploadDialogOptions = {
22
22
  removeFailed: string;
23
23
  replaceDrop: string;
24
24
  chooseDrop: string;
25
+ formats: string;
25
26
  cropHint: string;
26
27
  removing: string;
27
28
  remove: string;
@@ -149,13 +150,13 @@ function AvatarUploadDialog(props: AvatarUploadDialogOptions & { close: (saved?:
149
150
  disabled={saving() || removing()}
150
151
  busy={processing()}
151
152
  error={error}
152
- icon="ti-photo-plus"
153
+ icon="ti ti-photo-plus"
153
154
  title={
154
155
  sourceFile()
155
156
  ? (props.messages?.replaceDrop ?? "Drop another image or click to replace")
156
157
  : (props.messages?.chooseDrop ?? "Drop image or click to choose")
157
158
  }
158
- subtitle="PNG, JPEG, or WebP"
159
+ subtitle={props.messages?.formats ?? "PNG, JPEG, or WebP"}
159
160
  hint={props.messages?.cropHint ?? "Adjust the crop, then save."}
160
161
  onDrop={handleFiles}
161
162
  />
@@ -39,8 +39,17 @@ export async function renderPdfPages(bytes: Uint8Array, pages: number[] = [1], s
39
39
  stdin: new Blob([JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages })]),
40
40
  stdout: "pipe",
41
41
  stderr: "ignore",
42
- // The decoder needs no application credentials or runtime configuration.
43
- env: { PATH: process.env.PATH, LANG: "C.UTF-8" },
42
+ // No application credentials or configuration reach the decoder, only fixed
43
+ // thread pool sizes: the Bun work pool, canvas tokio runtime, and JSC GC
44
+ // markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
45
+ // each thread's stack and allocator memory.
46
+ env: {
47
+ PATH: process.env.PATH,
48
+ LANG: "C.UTF-8",
49
+ UV_THREADPOOL_SIZE: "2",
50
+ TOKIO_WORKER_THREADS: "2",
51
+ BUN_JSC_numberOfGCMarkers: "2",
52
+ },
44
53
  },
45
54
  );
46
55
  const stop = () => child.kill("SIGKILL");