@k2b/cloud 0.15.0 → 0.17.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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `cld` plugin contract shared by the applications that serve plugins and by
3
+ * `cld`, which installs and loads them.
4
+ *
5
+ * An application serves each declared CLI module under
6
+ * `/cli/plugins/<name>/`: `manifest.json`, the bundled module `cli.js`, and
7
+ * its skill references `references/<path>.md`. The manifest lists every file
8
+ * with its SHA-512; the plugin digest is the SHA-512 of those lines, so one
9
+ * value identifies the exact module and references.
10
+ */
11
+ import type { CloudCliModule } from "./index";
12
+
13
+ /** Version of the `CloudCliModule` shape a plugin's default export implements. */
14
+ export const CLOUD_CLI_API_VERSION = 1;
15
+
16
+ /** Gateway route under which applications serve their `cld` plugins. */
17
+ export const CLOUD_CLI_PLUGIN_ROUTE = "/cli/plugins";
18
+
19
+ /** Module names: `cld <name>`, the plugin ID, and the route segment. */
20
+ export const CLOUD_CLI_MODULE_NAME = /^[a-z][a-z0-9-]*$/;
21
+
22
+ /** File name of the bundled module inside a plugin. */
23
+ export const CLOUD_CLI_PLUGIN_ENTRY = "cli.js";
24
+
25
+ /** Entry file of a plugin's skill references. */
26
+ export const CLOUD_CLI_REFERENCE_INDEX = "references/index.md";
27
+
28
+ const PLUGIN_FILE_PATH = /^(?:cli\.js|references\/(?:[A-Za-z0-9_][A-Za-z0-9._-]*\/)*[A-Za-z0-9_][A-Za-z0-9._-]*\.md)$/;
29
+ const SHA512_HEX = /^[0-9a-f]{128}$/;
30
+
31
+ export type CloudCliPluginFile = { path: string; size: number; sha512: string };
32
+
33
+ export type CloudCliPluginManifest = {
34
+ apiVersion: number;
35
+ /** Module name and plugin ID. */
36
+ name: string;
37
+ /** ID of the application that serves the plugin. */
38
+ app: string;
39
+ /** Version of the serving application build. */
40
+ version: string;
41
+ entry: typeof CLOUD_CLI_PLUGIN_ENTRY;
42
+ /** SHA-512 over the file list; see {@link cloudCliPluginDigest}. */
43
+ digest: string;
44
+ files: CloudCliPluginFile[];
45
+ };
46
+
47
+ /** One entry of `GET /cli/plugins`. */
48
+ export type CloudCliPluginSummary = { name: string; app: string; version: string };
49
+
50
+ /** Whether `path` is a file name a plugin may contain. */
51
+ export const isCloudCliPluginFilePath = (path: string): boolean => PLUGIN_FILE_PATH.test(path);
52
+
53
+ export const sha512Hex = (bytes: Uint8Array | string): string => new Bun.CryptoHasher("sha512").update(bytes).digest("hex");
54
+
55
+ /**
56
+ * Plugin digest: SHA-512 of `<sha512> <path>\n` for every file, sorted by
57
+ * path. This is the `sha512sum` line format, so a plugin directory can be
58
+ * checked with standard tools.
59
+ */
60
+ export const cloudCliPluginDigest = (files: readonly Pick<CloudCliPluginFile, "path" | "sha512">[]): string =>
61
+ sha512Hex(
62
+ [...files]
63
+ .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
64
+ .map((file) => `${file.sha512} ${file.path}\n`)
65
+ .join(""),
66
+ );
67
+
68
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
69
+
70
+ /** Validate a served manifest, including its digest. Throws an `Error` naming the first problem. */
71
+ export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
72
+ if (!isRecord(value)) throw new Error("plugin manifest is not an object");
73
+ const { apiVersion, name, app, version, entry, digest, files } = value;
74
+ if (typeof apiVersion !== "number" || !Number.isInteger(apiVersion)) throw new Error("plugin manifest needs an integer apiVersion");
75
+ if (typeof name !== "string" || !CLOUD_CLI_MODULE_NAME.test(name)) throw new Error("plugin manifest needs a kebab-case name");
76
+ if (typeof app !== "string" || app === "") throw new Error("plugin manifest needs an app");
77
+ if (typeof version !== "string" || version === "") throw new Error("plugin manifest needs a version");
78
+ if (entry !== CLOUD_CLI_PLUGIN_ENTRY) throw new Error(`plugin manifest entry must be ${CLOUD_CLI_PLUGIN_ENTRY}`);
79
+ if (typeof digest !== "string" || !SHA512_HEX.test(digest)) throw new Error("plugin manifest needs a SHA-512 digest");
80
+ if (!Array.isArray(files)) throw new Error("plugin manifest needs a file list");
81
+ const seen = new Set<string>();
82
+ const parsed = files.map((file): CloudCliPluginFile => {
83
+ if (
84
+ !isRecord(file) ||
85
+ typeof file.path !== "string" ||
86
+ !isCloudCliPluginFilePath(file.path) ||
87
+ typeof file.size !== "number" ||
88
+ !Number.isSafeInteger(file.size) ||
89
+ file.size < 0 ||
90
+ typeof file.sha512 !== "string" ||
91
+ !SHA512_HEX.test(file.sha512)
92
+ ) {
93
+ throw new Error("plugin manifest lists an invalid file");
94
+ }
95
+ if (seen.has(file.path)) throw new Error(`plugin manifest lists ${file.path} twice`);
96
+ seen.add(file.path);
97
+ return { path: file.path, size: file.size, sha512: file.sha512 };
98
+ });
99
+ if (!seen.has(CLOUD_CLI_PLUGIN_ENTRY)) throw new Error(`plugin manifest does not list ${CLOUD_CLI_PLUGIN_ENTRY}`);
100
+ if (!seen.has(CLOUD_CLI_REFERENCE_INDEX)) throw new Error(`plugin manifest does not list ${CLOUD_CLI_REFERENCE_INDEX}`);
101
+ if (cloudCliPluginDigest(parsed) !== digest) throw new Error("plugin manifest digest does not match its files");
102
+ return { apiVersion, name, app, version, entry, digest, files: parsed };
103
+ };
104
+
105
+ /**
106
+ * Check a plugin's default export structurally. `instanceof` would fail
107
+ * because every plugin bundles its own copy of `@k2b/cloud/cli`.
108
+ */
109
+ export const validateCloudCliModule = (value: unknown): CloudCliModule => {
110
+ if (!isRecord(value)) throw new Error("default export is not a CLI module");
111
+ if (typeof value.name !== "string" || !CLOUD_CLI_MODULE_NAME.test(value.name)) {
112
+ throw new Error("default export needs a lowercase kebab-case name");
113
+ }
114
+ if (typeof value.summary !== "string") throw new Error("default export needs a string summary");
115
+ if (typeof value.run !== "function") throw new Error("default export needs a run function");
116
+ for (const key of ["help", "requiresCloudFor"] as const) {
117
+ if (value[key] !== undefined && typeof value[key] !== "function") throw new Error(`${key} must be a function`);
118
+ }
119
+ if (value.requiresCloud !== undefined && typeof value.requiresCloud !== "boolean") {
120
+ throw new Error("requiresCloud must be a boolean");
121
+ }
122
+ const flags = value.booleanFlags;
123
+ if (flags !== undefined && (!Array.isArray(flags) || flags.some((flag) => typeof flag !== "string"))) {
124
+ throw new Error("booleanFlags must be a string array");
125
+ }
126
+ return value as CloudCliModule;
127
+ };
@@ -77,6 +77,17 @@ export type AppPresentationCatalog = {
77
77
  translations: Readonly<Record<string, AppPresentationTranslation>>;
78
78
  };
79
79
 
80
+ /** One `cld` module an application serves as a plugin. Paths are relative to the application directory. */
81
+ export type AppCliModule = {
82
+ /** Source file whose default export is the module from `defineCliCommands()`, e.g. `src/cli.ts`. */
83
+ module: string;
84
+ /** Directory of the module's skill references: `index.md` plus any further Markdown files. */
85
+ references: string;
86
+ };
87
+
88
+ /** `cld` modules keyed by module name, the command `cld <name>`. */
89
+ export type AppCliModules = Readonly<Record<string, AppCliModule>>;
90
+
80
91
  export type AppMeta = {
81
92
  id: string;
82
93
  name: string;
@@ -119,6 +130,8 @@ export type AppMeta = {
119
130
  settingKeys?: readonly string[];
120
131
  /** Gateway-relative URL where this app's OpenAPI JSON is served, or undefined. */
121
132
  openapi?: string;
133
+ /** `cld` modules this app serves as plugins. */
134
+ cli?: AppCliModules;
122
135
  };
123
136
 
124
137
  export type WidgetEndpoint = {
@@ -85,4 +85,6 @@ export type AppRegistryEntry = {
85
85
  settingKeys?: readonly string[];
86
86
  /** Gateway-relative URL where this app serves its OpenAPI JSON spec. */
87
87
  openapi?: string;
88
+ /** Names of the `cld` modules this app serves under `/cli/plugins/<name>`. */
89
+ cliModules?: readonly string[];
88
90
  };
@@ -104,6 +104,9 @@ export const BaseGroupSchema = z.object({
104
104
  });
105
105
  export type BaseGroup = z.infer<typeof BaseGroupSchema>;
106
106
 
107
+ export const ServiceAccountKindSchema = z.enum(["user_delegated", "resource_bound", "standalone", "agent"]);
108
+ export type ServiceAccountKind = z.infer<typeof ServiceAccountKindSchema>;
109
+
107
110
  export const EntityKindSchema = z.enum(["user", "group", "service_account"]);
108
111
  export type EntityKind = z.infer<typeof EntityKindSchema>;
109
112
 
@@ -128,7 +131,7 @@ export const EntityListItemSchema = z.discriminatedUnion("kind", [
128
131
  serviceAccount: z.object({
129
132
  id: z.uuid(),
130
133
  name: z.string(),
131
- kind: z.enum(["user_delegated", "resource_bound"]),
134
+ kind: ServiceAccountKindSchema,
132
135
  status: z.enum(["active", "disabled"]),
133
136
  delegatedUserId: z.uuid().nullable(),
134
137
  appId: z.string().nullable(),
@@ -208,9 +211,6 @@ export type MutationResult<T = void> = { ok: true; data: T } | { ok: false; erro
208
211
  export const PermissionLevelSchema = z.enum(["none", "read", "write", "admin"]);
209
212
  export type PermissionLevel = z.infer<typeof PermissionLevelSchema>;
210
213
 
211
- export const ServiceAccountKindSchema = z.enum(["user_delegated", "resource_bound"]);
212
- export type ServiceAccountKind = z.infer<typeof ServiceAccountKindSchema>;
213
-
214
214
  export const ServiceAccountStatusSchema = z.enum(["active", "disabled"]);
215
215
  export type ServiceAccountStatus = z.infer<typeof ServiceAccountStatusSchema>;
216
216
 
@@ -327,6 +327,8 @@ export const AccessEntrySchema = z.object({
327
327
  createdAt: z.string(),
328
328
  displayName: z.string().optional(),
329
329
  avatarHash: z.string().nullable().optional(),
330
+ /** Kind of a `service_account` principal; presentation only, never authorization. */
331
+ serviceAccountKind: ServiceAccountKindSchema.optional(),
330
332
  });
331
333
  export type AccessEntry = z.infer<typeof AccessEntrySchema>;
332
334
 
@@ -2,6 +2,7 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
2
2
  import { type SQLQuery, sql } from "bun";
3
3
  import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
4
4
  import { toPgUuidArray } from "../../services/postgres";
5
+ import type { ServiceAccountKind } from "../../services/service-accounts";
5
6
 
6
7
  export type AccessDb = typeof sql;
7
8
 
@@ -67,6 +68,7 @@ export type AccessEntry = {
67
68
  // Resolved display info (populated by service)
68
69
  displayName?: string;
69
70
  avatarHash?: string | null;
71
+ serviceAccountKind?: ServiceAccountKind;
70
72
  };
71
73
 
72
74
  export type AccessUserSource =
@@ -566,11 +568,12 @@ export const listUsersWithAccess = async (params: {
566
568
 
567
569
  /**
568
570
  * Resolve display names for access entries.
569
- * Populates the displayName field based on principal type.
571
+ * Populates the displayName field based on principal type, and the
572
+ * serviceAccountKind field for service-account principals.
570
573
  */
571
574
  export const resolveDisplayNames = async <T extends { principal: Principal }>(
572
575
  entries: T[],
573
- ): Promise<(T & { displayName: string; avatarHash?: string | null })[]> => {
576
+ ): Promise<(T & { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind })[]> => {
574
577
  const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
575
578
 
576
579
  const groupIds = entries
@@ -606,21 +609,22 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
606
609
  }
607
610
  }
608
611
 
609
- const serviceAccountNames = new Map<string, string>();
612
+ const serviceAccountsById = new Map<string, { id: string; name: string; kind: ServiceAccountKind }>();
610
613
  if (serviceAccountIds.length > 0) {
611
- const serviceAccounts = await sql<{ id: string; name: string }[]>`
612
- SELECT id, name
614
+ const serviceAccounts = await sql<{ id: string; name: string; kind: ServiceAccountKind }[]>`
615
+ SELECT id, name, kind
613
616
  FROM auth.service_accounts
614
617
  WHERE id = ANY(${toPgUuidArray(serviceAccountIds)}::uuid[])
615
618
  `;
616
619
  for (const serviceAccount of serviceAccounts) {
617
- serviceAccountNames.set(serviceAccount.id, serviceAccount.name);
620
+ serviceAccountsById.set(serviceAccount.id, serviceAccount);
618
621
  }
619
622
  }
620
623
 
621
624
  return entries.map((entry) => {
622
625
  let displayName: string;
623
626
  let avatarHash: string | null | undefined;
627
+ let serviceAccountKind: ServiceAccountKind | undefined;
624
628
  switch (entry.principal.type) {
625
629
  case "user": {
626
630
  const user = userNames.get(entry.principal.userId);
@@ -631,9 +635,12 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
631
635
  case "group":
632
636
  displayName = groupNames.get(entry.principal.groupId) ?? "Unknown Group";
633
637
  break;
634
- case "service_account":
635
- displayName = serviceAccountNames.get(entry.principal.serviceAccountId) ?? "Unknown Service Account";
638
+ case "service_account": {
639
+ const serviceAccount = serviceAccountsById.get(entry.principal.serviceAccountId);
640
+ displayName = serviceAccount?.name ?? "Unknown Service Account";
641
+ serviceAccountKind = serviceAccount?.kind;
636
642
  break;
643
+ }
637
644
  case "authenticated":
638
645
  displayName = "All users (incl. guests)";
639
646
  break;
@@ -641,6 +648,6 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
641
648
  displayName = "Public";
642
649
  break;
643
650
  }
644
- return { ...entry, displayName, avatarHash };
651
+ return { ...entry, displayName, avatarHash, serviceAccountKind };
645
652
  });
646
653
  };
@@ -2,6 +2,7 @@ import { sql } from "bun";
2
2
  import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
3
3
  import { getFreeIpaConfig } from "../freeipa-config";
4
4
  import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
5
+ import type { ServiceAccountKind } from "../service-accounts";
5
6
  import { buildBaseGroup, personalOwnerJoin } from "./base-group";
6
7
  import { buildBaseUser } from "./base-user";
7
8
  import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
@@ -369,6 +370,9 @@ const buildQuerySpec = (params: EntityListParams): EntityQuerySpec => {
369
370
  return buildNoRelationSpec();
370
371
  };
371
372
 
373
+ const serviceAccountKind = (value: unknown): ServiceAccountKind =>
374
+ value === "resource_bound" || value === "standalone" || value === "agent" ? value : "user_delegated";
375
+
372
376
  const mapEntityRow = (row: DbRow): EntityListItem => {
373
377
  const direct = typeof row.direct === "boolean" ? row.direct : undefined;
374
378
 
@@ -386,7 +390,7 @@ const mapEntityRow = (row: DbRow): EntityListItem => {
386
390
  serviceAccount: {
387
391
  id: String(row.id),
388
392
  name: String(row.name ?? ""),
389
- kind: row.service_account_kind === "resource_bound" ? "resource_bound" : "user_delegated",
393
+ kind: serviceAccountKind(row.service_account_kind),
390
394
  status: row.status === "disabled" ? "disabled" : "active",
391
395
  delegatedUserId: typeof row.delegated_user_id === "string" ? row.delegated_user_id : null,
392
396
  appId: typeof row.app_id === "string" ? row.app_id : null,
@@ -596,6 +600,8 @@ export const list = async (
596
600
  sa.name,
597
601
  CASE
598
602
  WHEN sa.kind = 'user_delegated' THEN 'Personal automation keys'
603
+ WHEN sa.kind = 'agent' THEN 'Agent'
604
+ WHEN sa.kind = 'standalone' THEN 'Service account'
599
605
  ELSE CONCAT_WS(' · ', sa.app_id, sa.resource_type, sa.resource_id)
600
606
  END AS description,
601
607
  NULL::int AS gid_number,
@@ -90,7 +90,8 @@ export type {
90
90
  MandateSubject,
91
91
  } from "./mandates";
92
92
  export { serviceAccounts } from "./service-accounts";
93
- export type { ServiceAccount, ServiceAccountKind, ServiceAccountStatus } from "./service-accounts";
93
+ export type { ServiceAccount, ServiceAccountKind, ServiceAccountStatus, StandaloneServiceAccountKind } from "./service-accounts";
94
+ export { isStandaloneServiceAccountKind, STANDALONE_SERVICE_ACCOUNT_KINDS } from "./service-accounts";
94
95
  export { serviceAccountCredentials } from "./service-account-credentials";
95
96
  export type {
96
97
  AuthenticatedServiceAccountCredential,
@@ -5,7 +5,12 @@ import type { User } from "../contracts/shared";
5
5
  import { accounts } from "./accounts";
6
6
  import { audit } from "./audit";
7
7
  import { isUniqueViolation, toPgTextArray } from "./postgres";
8
- import { type ServiceAccount, serviceAccounts } from "./service-accounts";
8
+ import {
9
+ isStandaloneServiceAccountKind,
10
+ type ServiceAccount,
11
+ type StandaloneServiceAccountKind,
12
+ serviceAccounts,
13
+ } from "./service-accounts";
9
14
 
10
15
  export type ServiceAccountCredentialStatus = "active" | "revoked";
11
16
  export type ServiceAccountCredentialKind = "api_token";
@@ -46,6 +51,12 @@ export type ServiceAccountCredentialOwner =
46
51
  appId: string;
47
52
  resourceType: string;
48
53
  resourceId: string;
54
+ }
55
+ | {
56
+ type: "standalone";
57
+ serviceAccountId: string;
58
+ name: string;
59
+ kind: StandaloneServiceAccountKind;
49
60
  };
50
61
 
51
62
  export type ServiceAccountCredentialOverview = ServiceAccountCredential & {
@@ -139,30 +150,33 @@ const mapServiceAccount = (row: DbCredentialServiceAccountFields): ServiceAccoun
139
150
  createdAt: row.service_account_created_at.toISOString(),
140
151
  });
141
152
 
142
- const mapCredentialOverview = (row: DbCredentialOverviewRow): ServiceAccountCredentialOverview => {
143
- const serviceAccount = mapServiceAccount(row);
153
+ const credentialOwner = (row: DbCredentialOverviewRow, serviceAccount: ServiceAccount): ServiceAccountCredentialOwner => {
154
+ if (serviceAccount.kind === "user_delegated" && serviceAccount.delegatedUserId) {
155
+ return {
156
+ type: "user",
157
+ userId: serviceAccount.delegatedUserId,
158
+ uid: row.delegated_uid ?? serviceAccount.delegatedUserId,
159
+ displayName: row.delegated_display_name ?? "",
160
+ mail: row.delegated_mail,
161
+ avatarHash: row.delegated_avatar_hash,
162
+ };
163
+ }
164
+ if (isStandaloneServiceAccountKind(serviceAccount.kind)) {
165
+ return { type: "standalone", serviceAccountId: serviceAccount.id, name: serviceAccount.name, kind: serviceAccount.kind };
166
+ }
144
167
  return {
145
- ...mapCredential(row),
146
- serviceAccount,
147
- owner:
148
- serviceAccount.kind === "user_delegated" && serviceAccount.delegatedUserId
149
- ? {
150
- type: "user",
151
- userId: serviceAccount.delegatedUserId,
152
- uid: row.delegated_uid ?? serviceAccount.delegatedUserId,
153
- displayName: row.delegated_display_name ?? "",
154
- mail: row.delegated_mail,
155
- avatarHash: row.delegated_avatar_hash,
156
- }
157
- : {
158
- type: "resource",
159
- appId: serviceAccount.appId ?? "",
160
- resourceType: serviceAccount.resourceType ?? "",
161
- resourceId: serviceAccount.resourceId ?? "",
162
- },
168
+ type: "resource",
169
+ appId: serviceAccount.appId ?? "",
170
+ resourceType: serviceAccount.resourceType ?? "",
171
+ resourceId: serviceAccount.resourceId ?? "",
163
172
  };
164
173
  };
165
174
 
175
+ const mapCredentialOverview = (row: DbCredentialOverviewRow): ServiceAccountCredentialOverview => {
176
+ const serviceAccount = mapServiceAccount(row);
177
+ return { ...mapCredential(row), serviceAccount, owner: credentialOwner(row, serviceAccount) };
178
+ };
179
+
166
180
  const actorForUser = (user: Pick<User, "id" | "uid" | "provider" | "roles">) => ({
167
181
  userId: user.id,
168
182
  uid: user.uid,
@@ -438,6 +452,43 @@ export const createResourceApiToken = async (params: {
438
452
  });
439
453
  };
440
454
 
455
+ /** Static bearer for a standalone principal (kind standalone or agent); the raw token is returned once. */
456
+ export const createStandaloneApiToken = async (params: {
457
+ serviceAccountId: string;
458
+ actor: User;
459
+ name: string;
460
+ expiresAt?: string | null;
461
+ scopes?: string[];
462
+ }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
463
+ const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
464
+ if (!serviceAccount || !isStandaloneServiceAccountKind(serviceAccount.kind)) return fail(err.notFound("Service account"));
465
+ if (serviceAccount.status !== "active") return fail(err.badInput("Service account is disabled"));
466
+
467
+ return sql.begin(async (tx) => {
468
+ const result = await insertApiToken(tx, {
469
+ serviceAccountId: serviceAccount.id,
470
+ name: params.name,
471
+ expiresAt: params.expiresAt,
472
+ createdBy: params.actor.id,
473
+ scopes: params.scopes,
474
+ });
475
+
476
+ return audit.recordResult({
477
+ action: "service_account_credential.create",
478
+ actor: actorForUser(params.actor),
479
+ target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
480
+ metadata: {
481
+ serviceAccountId: serviceAccount.id,
482
+ kind: "api_token",
483
+ serviceAccountKind: serviceAccount.kind,
484
+ expiresAt: params.expiresAt ?? null,
485
+ },
486
+ result,
487
+ db: tx,
488
+ });
489
+ });
490
+ };
491
+
441
492
  export const listForDelegatedUser = async (params: { userId: string }): Promise<ServiceAccountCredential[]> => {
442
493
  const rows = await sql<DbCredentialRow[]>`
443
494
  SELECT c.id, c.service_account_id, c.name, c.kind, c.status, c.token_prefix, c.scopes, c.expires_at,
@@ -880,6 +931,7 @@ export const serviceAccountCredentials = {
880
931
  createApiToken,
881
932
  createUserApiToken,
882
933
  createResourceApiToken,
934
+ createStandaloneApiToken,
883
935
  listForDelegatedUser,
884
936
  listOverview,
885
937
  getOverview,
@@ -2,9 +2,20 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
2
2
  import { sql } from "bun";
3
3
  import { isUniqueViolation } from "./postgres";
4
4
 
5
- export type ServiceAccountKind = "user_delegated" | "resource_bound";
5
+ export type ServiceAccountKind = "user_delegated" | "resource_bound" | "standalone" | "agent";
6
6
  export type ServiceAccountStatus = "active" | "disabled";
7
7
 
8
+ /**
9
+ * Kinds of a standalone principal: not delegated by a user and not bound to one
10
+ * app resource. `agent` is a standalone account that surfaces as an agent
11
+ * (badge, pickers, activity); it carries no extra permission.
12
+ */
13
+ export type StandaloneServiceAccountKind = Extract<ServiceAccountKind, "standalone" | "agent">;
14
+ export const STANDALONE_SERVICE_ACCOUNT_KINDS = ["standalone", "agent"] as const satisfies readonly StandaloneServiceAccountKind[];
15
+
16
+ export const isStandaloneServiceAccountKind = (kind: ServiceAccountKind): kind is StandaloneServiceAccountKind =>
17
+ kind === "standalone" || kind === "agent";
18
+
8
19
  export type ServiceAccount = {
9
20
  id: string;
10
21
  name: string;
@@ -48,6 +59,8 @@ const trimRequired = (value: string): string => value.trim();
48
59
 
49
60
  const isForeignKeyViolation = (error: unknown): boolean => (error as { code?: string } | null)?.code === "23503";
50
61
  const RESOURCE_BOUND_UNIQUE_CONSTRAINT = "uniq_service_accounts_resource_bound";
62
+ const STANDALONE_NAME_UNIQUE_CONSTRAINT = "uniq_service_accounts_standalone_name";
63
+ const MAX_NAME_LENGTH = 120;
51
64
 
52
65
  export const getByResource = async (params: {
53
66
  appId: string;
@@ -125,6 +138,60 @@ export const createResourceBound = async (params: {
125
138
  }
126
139
  };
127
140
 
141
+ export const createStandalone = async (params: {
142
+ name: string;
143
+ kind: StandaloneServiceAccountKind;
144
+ createdBy?: string | null;
145
+ }): Promise<Result<ServiceAccount>> => {
146
+ const name = trimRequired(params.name);
147
+ if (!name) return fail(err.badInput("Service account name is required"));
148
+ if (name.length > MAX_NAME_LENGTH) return fail(err.badInput(`Service account name must be ${MAX_NAME_LENGTH} characters or fewer`));
149
+ if (!isStandaloneServiceAccountKind(params.kind)) return fail(err.badInput("Service account kind must be standalone or agent"));
150
+
151
+ try {
152
+ const [row] = await sql<DbServiceAccount[]>`
153
+ INSERT INTO auth.service_accounts (name, kind, created_by)
154
+ VALUES (${name}, ${params.kind}, ${params.createdBy ?? null}::uuid)
155
+ RETURNING id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
156
+ `;
157
+ return row ? ok(mapServiceAccount(row)) : fail(err.internal("Failed to create service account"));
158
+ } catch (error) {
159
+ if (isForeignKeyViolation(error)) return fail(err.notFound("Creator"));
160
+ if (isUniqueViolation(error, STANDALONE_NAME_UNIQUE_CONSTRAINT)) return fail(err.conflict("Service account name"));
161
+ throw error;
162
+ }
163
+ };
164
+
165
+ /** Bounded page of standalone principals; user-delegated and resource-bound accounts stay with their owners. */
166
+ export const listStandalone = async (params: {
167
+ kind?: StandaloneServiceAccountKind;
168
+ status?: ServiceAccountStatus;
169
+ search?: string;
170
+ page?: number;
171
+ perPage?: number;
172
+ }): Promise<{ items: ServiceAccount[]; page: number; perPage: number; total: number; hasNext: boolean }> => {
173
+ const page = Math.max(1, params.page ?? 1);
174
+ const perPage = Math.max(1, Math.min(params.perPage ?? 100, 500));
175
+ const offset = (page - 1) * perPage;
176
+ const kind = params.kind ?? null;
177
+ const status = params.status ?? null;
178
+ const search = params.search?.trim() || null;
179
+ const rows = await sql<(DbServiceAccount & { total: number })[]>`
180
+ SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at,
181
+ COUNT(*) OVER()::int AS total
182
+ FROM auth.service_accounts
183
+ WHERE kind IN ('standalone', 'agent')
184
+ AND (${kind}::text IS NULL OR kind = ${kind})
185
+ AND (${status}::text IS NULL OR status = ${status})
186
+ AND (${search}::text IS NULL OR name ILIKE '%' || ${search} || '%')
187
+ ORDER BY created_at DESC, id DESC
188
+ LIMIT ${perPage}
189
+ OFFSET ${offset}
190
+ `;
191
+ const total = rows[0]?.total ?? 0;
192
+ return { items: rows.map(mapServiceAccount), page, perPage, total, hasNext: page * perPage < total };
193
+ };
194
+
128
195
  export const getOrCreateResourceBound = async (params: {
129
196
  name: string;
130
197
  appId: string;
@@ -176,6 +243,8 @@ export const serviceAccounts = {
176
243
  get,
177
244
  createUserDelegated,
178
245
  createResourceBound,
246
+ createStandalone,
247
+ listStandalone,
179
248
  getByResource,
180
249
  getOrCreateResourceBound,
181
250
  setStatus,
@@ -709,6 +709,19 @@ export const CORE_SETTINGS = {
709
709
  description: "Maximum API requests per second per IP address",
710
710
  },
711
711
 
712
+ // ── Command line ────────────────────────────────────────────────────────
713
+ "cli.plugins.access": {
714
+ kind: "enum",
715
+ label: "Who may install cld plugins",
716
+ default: "full_users",
717
+ options: [
718
+ { value: "full_users", label: "Full accounts" },
719
+ { value: "all_users", label: "All accounts, including guests" },
720
+ ],
721
+ description:
722
+ "Applies to the plugin list and every plugin download. Signed-out requests never get plugins. Commands stay authorized by the server either way.",
723
+ },
724
+
712
725
  // ── Legal documents (Imprint, Privacy, Terms) ──────────────────────────
713
726
  // mode = "local" → render markdown from `legal.<kind>.content`
714
727
  // mode = "external" → 302-redirect to `legal.<kind>.url`
@@ -210,7 +210,9 @@ export default function Layout(props: LayoutProps) {
210
210
  user?.avatarHash && user.id
211
211
  ? `/api/accounts/users/${encodeURIComponent(user.id)}/avatar?rev=${encodeURIComponent(user.avatarHash)}`
212
212
  : undefined;
213
- const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "lg:overflow-auto";
213
+ // From lg, `main` is the page scroller of regular pages. Its stable gutter
214
+ // keeps short and long pages at the same width where scrollbars take space.
215
+ const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "lg:overflow-auto lg:[scrollbar-gutter:stable]";
214
216
  const canvasStyle =
215
217
  [appAppearanceStyle(currentApp?.appearance), appWorkspaceLayoutStyle(workspaceLayout), focusMode && flushCanvas ? "padding:0" : ""]
216
218
  .filter(Boolean)
@@ -280,6 +280,10 @@ solid-island {
280
280
  padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
281
281
  background: var(--ui-surface);
282
282
  }
283
+ /* Below lg the document is the page scroller; reserve its scrollbar gutter
284
+ so short and long pages keep the same width. Full-page surfaces own their
285
+ inner scrolling. */
286
+ html:has(.cloud-app-canvas:not([data-layout-full-page])) { scrollbar-gutter: stable; }
283
287
  .cloud-app-canvas > .layout-shell-content { gap: 0; }
284
288
  .cloud-app-canvas .layout-header {
285
289
  border-radius: 0; border: 0; border-bottom: 1px solid var(--ui-border);
@@ -7,6 +7,11 @@ declare module "*.md" {
7
7
  export default content;
8
8
  }
9
9
 
10
+ declare module "*.yaml" {
11
+ const content: string;
12
+ export default content;
13
+ }
14
+
10
15
  declare module "*.sql" {
11
16
  const content: string;
12
17
  export default content;