@k2b/cloud 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/package.json +11 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-queries.ts +73 -0
  5. package/src/_internal/capabilities.ts +137 -9
  6. package/src/_internal/define-app.ts +40 -0
  7. package/src/_internal/heartbeat.ts +19 -0
  8. package/src/_internal/postgres-application-name.ts +11 -0
  9. package/src/_internal/process-sync.ts +9 -0
  10. package/src/_internal/readiness.ts +6 -1
  11. package/src/_internal/registry-validation.ts +17 -4
  12. package/src/_internal/registry.ts +32 -4
  13. package/src/_internal/resource-principals.ts +6 -0
  14. package/src/_internal/workload-credential.ts +153 -0
  15. package/src/_internal/workload-protocol.ts +36 -0
  16. package/src/ai/application-tasks.ts +5 -2
  17. package/src/ai/capabilities.ts +4 -2
  18. package/src/ai/chart-block.ts +36 -711
  19. package/src/ai/chat/map-messages.ts +270 -0
  20. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  21. package/src/ai/chat/map-notebook.tsx +301 -0
  22. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  23. package/src/ai/chat/map-widget.css +107 -0
  24. package/src/ai/chat/map-widget.tsx +1279 -0
  25. package/src/ai/chat/message-utils.ts +1 -0
  26. package/src/ai/chat/tool-names.ts +2 -0
  27. package/src/ai/chat/turn-layout.ts +2 -1
  28. package/src/ai/chat/turn-view.tsx +17 -1
  29. package/src/ai/default-tool-contracts.ts +21 -259
  30. package/src/ai/default-tools.ts +3 -0
  31. package/src/ai/executor.ts +13 -1
  32. package/src/ai/map-contracts.ts +209 -0
  33. package/src/ai/map-execution.ts +14 -0
  34. package/src/ai/map-notebook-save.ts +276 -0
  35. package/src/ai/map-notebook.ts +32 -0
  36. package/src/ai/map-opening-hours.ts +40 -0
  37. package/src/ai/map-routes.ts +84 -0
  38. package/src/ai/map-service.ts +288 -0
  39. package/src/ai/map-store.ts +137 -0
  40. package/src/ai/map-tool.ts +73 -0
  41. package/src/ai/migrate.ts +3 -0
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/routes.ts +2 -0
  44. package/src/ai/store.ts +19 -6
  45. package/src/ai/tools.ts +14 -0
  46. package/src/ai/types.ts +3 -0
  47. package/src/api/admin-app-keys.ts +66 -0
  48. package/src/api/admin-core-settings.ts +2 -0
  49. package/src/api/admin-identity.ts +27 -1
  50. package/src/api/admin-settings-env.ts +120 -0
  51. package/src/api/capabilities.ts +99 -15
  52. package/src/api/index.ts +3 -0
  53. package/src/api/mcp.ts +4 -2
  54. package/src/api/settings-ops.ts +35 -0
  55. package/src/app-key.ts +125 -0
  56. package/src/browser/CloudResourceSearch.tsx +48 -41
  57. package/src/browser/notifications.ts +25 -8
  58. package/src/capabilities/discussion-providers.ts +100 -0
  59. package/src/capabilities/executions.ts +2 -2
  60. package/src/capabilities/migrate.ts +10 -1
  61. package/src/capabilities/resource-readers.ts +69 -0
  62. package/src/capabilities/response.ts +4 -1
  63. package/src/capabilities/server-invoke.ts +64 -36
  64. package/src/capabilities/server.ts +17 -6
  65. package/src/capabilities/testing.ts +13 -0
  66. package/src/capabilities/types.ts +1 -1
  67. package/src/cli/admin/app-credentials.ts +32 -7
  68. package/src/cli/admin/app-keys.ts +226 -0
  69. package/src/cli/admin/index.ts +7 -1
  70. package/src/cli/admin/settings.ts +62 -0
  71. package/src/config/define-env.ts +63 -8
  72. package/src/config/env.ts +27 -21
  73. package/src/config/index.ts +12 -1
  74. package/src/config/workflow-ai.ts +28 -0
  75. package/src/contracts/capabilities.ts +137 -4
  76. package/src/contracts/discussions.ts +91 -0
  77. package/src/contracts/geo-document.ts +60 -0
  78. package/src/contracts/geo-snapshot.ts +86 -0
  79. package/src/contracts/geo.ts +14 -0
  80. package/src/contracts/index.ts +2 -0
  81. package/src/contracts/outgoing-mail.ts +1 -1
  82. package/src/contracts/resource-types.ts +21 -0
  83. package/src/contracts/settings-env.ts +21 -0
  84. package/src/contracts/settings-types.ts +6 -0
  85. package/src/geo/AddressAutocomplete.tsx +213 -0
  86. package/src/geo/messages.ts +29 -0
  87. package/src/geo/navigation.ts +25 -0
  88. package/src/geo/ui.ts +2 -0
  89. package/src/index.ts +9 -0
  90. package/src/runtime/clients.ts +72 -0
  91. package/src/runtime/connections.ts +37 -0
  92. package/src/runtime/resolve-connections.ts +91 -0
  93. package/src/server/client-ip.ts +1 -1
  94. package/src/services/accounts/app.ts +28 -6
  95. package/src/services/accounts/entities.ts +11 -4
  96. package/src/services/accounts/groups.ts +10 -4
  97. package/src/services/accounts/local-groups.ts +87 -38
  98. package/src/services/accounts/machine-group-management.ts +94 -0
  99. package/src/services/app-keys.ts +298 -0
  100. package/src/services/gateway.ts +6 -0
  101. package/src/services/geo/cache.ts +2 -1
  102. package/src/services/geo/geoapify.ts +3 -0
  103. package/src/services/identity/index.ts +3 -0
  104. package/src/services/identity/invocation-token.ts +110 -0
  105. package/src/services/identity/resource-readers.ts +80 -0
  106. package/src/services/identity/runtime-config.ts +14 -6
  107. package/src/services/identity/workload-runtime.ts +253 -0
  108. package/src/services/index.ts +4 -1
  109. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  110. package/src/services/pdf/geo-snapshot.ts +107 -0
  111. package/src/services/pdf/index.ts +2 -0
  112. package/src/services/service-account-credentials.ts +63 -21
  113. package/src/services/settings/api.ts +3 -3
  114. package/src/services/settings/binding.ts +45 -0
  115. package/src/services/settings/core-settings.ts +99 -1
  116. package/src/services/settings/defaults.ts +16 -2
  117. package/src/services/settings/env-status.ts +69 -0
  118. package/src/services/settings/index.ts +69 -26
  119. package/src/services/settings/store.ts +73 -8
  120. package/src/services/settings/validation.ts +40 -0
  121. package/src/shared/index.ts +11 -10
  122. package/src/shared/markdown/extensions/tables.ts +10 -1
  123. package/src/ssr/AdminArea.tsx +2 -2
  124. package/src/ssr/AdminLayout.tsx +1 -1
  125. package/src/ssr/AdminSidebar.tsx +1 -1
  126. package/src/start.ts +36 -0
  127. package/src/styles/global.css +2 -0
  128. package/src/workflows/ai/index.ts +1 -0
  129. package/src/workflows/ai/runtime.ts +16 -8
  130. package/src/workflows/ai/settings.ts +5 -0
  131. package/src/shared/markdown/formula.ts +0 -1283
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { envSource } from "../config/define-env";
3
+ import { env } from "../config/env";
4
+ import type { ConnectionSource, PlatformConnections, PlatformConnectionValues } from "./connections";
5
+
6
+ type EnvKey = keyof typeof env.$specs;
7
+ const explicitlySet = (key: EnvKey): boolean => ["env", "alias", "file"].includes(envSource(env, key));
8
+
9
+ /** URL diagnostics deliberately omit both the input and underlying parser errors. */
10
+ const url = (key: string, value: string | undefined, bareHost = false): string => {
11
+ if (!value) throw new Error(`${key} is required for platform start`);
12
+ try {
13
+ const parsed = new URL(bareHost && !value.includes("://") ? `nats://${value}` : value);
14
+ if (bareHost && !parsed.hostname) throw new Error();
15
+ } catch {
16
+ throw new Error(`${key} must be a valid URL${bareHost ? " or host:port" : ""}`);
17
+ }
18
+ return value;
19
+ };
20
+ const contents = (key: "NATS_CREDS_FILE" | "NATS_TLS_CA_FILE"): string | undefined => {
21
+ const path = env[key];
22
+ if (!path) return undefined;
23
+ try {
24
+ const value = readFileSync(path, "utf8");
25
+ if (!value.trim()) throw new Error();
26
+ return value;
27
+ } catch {
28
+ throw new Error(`${key} must name a readable, non-empty file`);
29
+ }
30
+ };
31
+
32
+ /**
33
+ * Explicit environment values always win, including aliases, files and false.
34
+ * The Core bootstrap client supplies `core` in key mode; required values then
35
+ * never fall through to environment defaults. Optional values may use defaults.
36
+ */
37
+ export const resolveConnections = ({ appId, core }: { appId: string; core?: PlatformConnectionValues }): PlatformConnections => {
38
+ const source = (key: EnvKey): ConnectionSource => (!core || explicitlySet(key) ? "env" : "core");
39
+ const choose = <T>(key: EnvKey, fromEnv: () => T, fromCore: T): T => (!core || explicitlySet(key) ? fromEnv() : fromCore);
40
+ // Keep value reads lazy: an absent env value must not evaluate its default in key mode.
41
+ const database = url(
42
+ "DATABASE_URL",
43
+ choose("DATABASE_URL", () => env.DATABASE_URL, core?.database.url),
44
+ );
45
+ const valkey = url(
46
+ "REDIS_URL",
47
+ choose("REDIS_URL", () => env.REDIS_URL, core?.valkey.url),
48
+ );
49
+ const servers = choose("NATS_SERVERS", () => env.NATS_SERVERS, core?.nats.servers) ?? [];
50
+ for (const server of servers) url("NATS_SERVERS", server, true);
51
+ const namespace = choose("SYNC_NAMESPACE", () => env.SYNC_NAMESPACE, core?.nats.namespace) ?? "";
52
+ const replicas = choose("SYNC_REPLICAS", () => env.SYNC_REPLICAS, core?.nats.replicas) ?? env.SYNC_REPLICAS;
53
+ const ignoreClusterUpdates =
54
+ choose("NATS_IGNORE_CLUSTER_UPDATES", () => env.NATS_IGNORE_CLUSTER_UPDATES, core?.nats.ignoreClusterUpdates) ??
55
+ env.NATS_IGNORE_CLUSTER_UPDATES;
56
+ const creds = choose("NATS_CREDS_FILE", () => contents("NATS_CREDS_FILE"), core?.nats.creds);
57
+ const ca = choose("NATS_TLS_CA_FILE", () => contents("NATS_TLS_CA_FILE"), core?.nats.ca);
58
+ const secret = choose("APP_SECRET", () => env.APP_SECRET, core?.appSecret.current) ?? "";
59
+ const rotation = core && !explicitlySet("APP_SECRET") ? core.appSecret : undefined;
60
+ if (core) {
61
+ if (servers.length === 0) throw new Error("NATS_SERVERS is required for platform start");
62
+ if (!namespace.trim()) throw new Error("SYNC_NAMESPACE is required for platform start");
63
+ if (!secret.trim()) throw new Error("APP_SECRET is required for platform start");
64
+ }
65
+ if (![1, 3, 5].includes(replicas)) throw new Error("SYNC_REPLICAS must be 1, 3, or 5");
66
+ if (typeof ignoreClusterUpdates !== "boolean") throw new Error("NATS_IGNORE_CLUSTER_UPDATES must be a boolean");
67
+ return {
68
+ appId,
69
+ database: { url: database },
70
+ valkey: { url: valkey },
71
+ nats: { servers: [...servers], namespace, replicas, ignoreClusterUpdates, creds, ca },
72
+ appSecret: { current: secret, previous: rotation?.previous, next: rotation?.next },
73
+ sources: {
74
+ database: { url: source("DATABASE_URL") },
75
+ valkey: { url: source("REDIS_URL") },
76
+ nats: {
77
+ servers: source("NATS_SERVERS"),
78
+ namespace: source("SYNC_NAMESPACE"),
79
+ replicas: source("SYNC_REPLICAS"),
80
+ ignoreClusterUpdates: source("NATS_IGNORE_CLUSTER_UPDATES"),
81
+ creds: source("NATS_CREDS_FILE"),
82
+ ca: source("NATS_TLS_CA_FILE"),
83
+ },
84
+ appSecret: {
85
+ current: source("APP_SECRET"),
86
+ ...(rotation?.previous ? { previous: "core" as const } : {}),
87
+ ...(rotation?.next ? { next: "core" as const } : {}),
88
+ },
89
+ },
90
+ };
91
+ };
@@ -1,7 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import type { Context } from "hono";
3
3
 
4
- // The gateway (packages/gateway/src/client-address.ts) resolves this address using GATEWAY_TRUSTED_PROXIES.
4
+ // The gateway (packages/gateway/src/client-address.ts) resolves this address using the gateway.trusted_proxies setting.
5
5
  export const clientIp = (c: Context): string =>
6
6
  c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
7
7
 
@@ -14,6 +14,7 @@ import type {
14
14
  UserProfile,
15
15
  UserProvider,
16
16
  } from "../../contracts/shared";
17
+ import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
17
18
  import {
18
19
  err,
19
20
  fail,
@@ -41,6 +42,7 @@ import type { AvatarChange, AvatarState } from "./avatar";
41
42
  import { listDuplicateEmails } from "./duplicate-emails";
42
43
  import * as entities from "./entities";
43
44
  import * as groups from "./groups";
45
+ import { mutateMachineGroupMember } from "./machine-group-management";
44
46
  import type { AccountsNotificationSender } from "./notification-sender";
45
47
  import { PosixError } from "./posix";
46
48
  import { accountRequestsEnabled } from "./request-policy";
@@ -1018,13 +1020,15 @@ export const accountsAppService = {
1018
1020
  return paginateItems(filtered, config.pagination);
1019
1021
  },
1020
1022
  add: async (config: {
1021
- actor: AccountsActor;
1023
+ actor: AccountsActor | ServiceAccountRequestActor;
1024
+ requestId?: string;
1022
1025
  id: string;
1023
1026
  provider?: UserProvider;
1024
1027
  userId?: string;
1025
1028
  groupId?: string;
1026
1029
  serviceAccountId?: string;
1027
1030
  }) => {
1031
+ if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "add" });
1028
1032
  const group = await groups.get({ id: config.id });
1029
1033
  if (!group) {
1030
1034
  const result = fail(err.notFound("Group not found"));
@@ -1055,13 +1059,15 @@ export const accountsAppService = {
1055
1059
  });
1056
1060
  },
1057
1061
  remove: async (config: {
1058
- actor: AccountsActor;
1062
+ actor: AccountsActor | ServiceAccountRequestActor;
1063
+ requestId?: string;
1059
1064
  id: string;
1060
1065
  provider?: UserProvider;
1061
1066
  userId?: string;
1062
1067
  groupId?: string;
1063
1068
  serviceAccountId?: string;
1064
1069
  }) => {
1070
+ if ("kind" in config.actor) return mutateMachineGroupMember({ ...config, actor: config.actor, operation: "remove" });
1065
1071
  const group = await groups.get({ id: config.id });
1066
1072
  if (!group) {
1067
1073
  const result = fail(err.notFound("Group not found"));
@@ -1110,7 +1116,14 @@ export const accountsAppService = {
1110
1116
  });
1111
1117
  return paginateItems(filtered, config.pagination);
1112
1118
  },
1113
- add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1119
+ add: async (config: {
1120
+ actor: AccountsActor;
1121
+ id: string;
1122
+ provider?: UserProvider;
1123
+ userId?: string;
1124
+ groupId?: string;
1125
+ serviceAccountId?: string;
1126
+ }) => {
1114
1127
  const group = await groups.get({ id: config.id });
1115
1128
  if (!group) {
1116
1129
  const result = fail(err.notFound("Group not found"));
@@ -1133,17 +1146,25 @@ export const accountsAppService = {
1133
1146
  provider: config.provider,
1134
1147
  user: config.userId,
1135
1148
  group: config.groupId,
1149
+ serviceAccount: config.serviceAccountId,
1136
1150
  }),
1137
1151
  );
1138
1152
  return recordCompletedMutation({
1139
1153
  action: "accounts.group.manager.add",
1140
1154
  actor: auditActor(config.actor),
1141
1155
  target: groupTarget(group),
1142
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1156
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1143
1157
  result,
1144
1158
  });
1145
1159
  },
1146
- remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1160
+ remove: async (config: {
1161
+ actor: AccountsActor;
1162
+ id: string;
1163
+ provider?: UserProvider;
1164
+ userId?: string;
1165
+ groupId?: string;
1166
+ serviceAccountId?: string;
1167
+ }) => {
1147
1168
  const group = await groups.get({ id: config.id });
1148
1169
  if (!group) {
1149
1170
  const result = fail(err.notFound("Group not found"));
@@ -1166,13 +1187,14 @@ export const accountsAppService = {
1166
1187
  provider: config.provider,
1167
1188
  user: config.userId,
1168
1189
  group: config.groupId,
1190
+ serviceAccount: config.serviceAccountId,
1169
1191
  }),
1170
1192
  );
1171
1193
  return recordCompletedMutation({
1172
1194
  action: "accounts.group.manager.remove",
1173
1195
  actor: auditActor(config.actor),
1174
1196
  target: groupTarget(group),
1175
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1197
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1176
1198
  result,
1177
1199
  });
1178
1200
  },
@@ -484,8 +484,8 @@ export const list = async (
484
484
  AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
485
485
  ))`;
486
486
 
487
- // Machine recipients have local membership, but no directory management/parent relation.
488
- const otherRelation = Boolean(params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
487
+ // Machine management is direct only; membership never implies management.
488
+ const otherRelation = Boolean(params.parentGroupId || params.managedByUserId);
489
489
  const machineRelation = params.memberOfGroupId
490
490
  ? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
491
491
  SELECT 1 FROM auth.groups target
@@ -497,11 +497,18 @@ export const list = async (
497
497
  WHERE sg.service_account_id = sa.id AND sg.group_id = target.id)`
498
498
  }
499
499
  )`
500
- : sql`TRUE`;
500
+ : params.managerOfGroupId
501
+ ? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
502
+ SELECT 1 FROM auth.group_manager_service_accounts gm JOIN auth.groups g ON g.id = gm.group_id
503
+ WHERE gm.service_account_id = sa.id AND gm.group_id = ${params.managerOfGroupId}::uuid AND g.provider = 'local'
504
+ )`
505
+ : sql`TRUE`;
501
506
  const machineDirect = params.memberOfGroupId
502
507
  ? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
503
508
  WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
504
- : sql`NULL::boolean`;
509
+ : params.managerOfGroupId
510
+ ? sql`TRUE`
511
+ : sql`NULL::boolean`;
505
512
  const where = sql`
506
513
  ${visibilityCondition}
507
514
  AND ${kindsCondition}
@@ -264,9 +264,12 @@ export const addManager = async (params: {
264
264
  provider?: UserProvider;
265
265
  user?: string;
266
266
  group?: string;
267
+ serviceAccount?: string;
267
268
  }): Promise<MutationResult<void>> => {
268
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
269
- if (provider === "local") return localGroups.addManager({ id: params.id, user: params.user, group: params.group });
269
+ const provider = (await getGroup(params.id))?.provider;
270
+ if (provider === "local")
271
+ return localGroups.addManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
272
+ if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
270
273
  const serviceSession = await getServiceIpaSession();
271
274
  if (!serviceSession.ok) return serviceSession;
272
275
  return providers.ipa.groups.addManager({
@@ -282,9 +285,12 @@ export const removeManager = async (params: {
282
285
  provider?: UserProvider;
283
286
  user?: string;
284
287
  group?: string;
288
+ serviceAccount?: string;
285
289
  }): Promise<MutationResult<void>> => {
286
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
287
- if (provider === "local") return localGroups.removeManager({ id: params.id, user: params.user, group: params.group });
290
+ const provider = (await getGroup(params.id))?.provider;
291
+ if (provider === "local")
292
+ return localGroups.removeManager({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
293
+ if (params.serviceAccount) return { ok: false, error: "Machine management requires a local group", status: 400 };
288
294
  const serviceSession = await getServiceIpaSession();
289
295
  if (!serviceSession.ok) return serviceSession;
290
296
  return providers.ipa.groups.removeManager({
@@ -1,4 +1,4 @@
1
- import { sql } from "bun";
1
+ import { sql, type TransactionSQL } from "bun";
2
2
  import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
3
3
  import { escapeLikePattern, isUniqueViolation } from "../postgres";
4
4
  import { buildBaseGroup, personalOwnerJoin } from "./base-group";
@@ -23,8 +23,8 @@ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
23
23
  personalOwner: null,
24
24
  });
25
25
 
26
- const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
27
- const [row] = await sql<DbRow[]>`
26
+ const getLocalGroupById = async (id: string, db: typeof sql | TransactionSQL = sql): Promise<LocalGroupRow | null> => {
27
+ const [row] = await db<DbRow[]>`
28
28
  SELECT id, provider, name, description, gid_number
29
29
  FROM auth.groups
30
30
  WHERE id = ${id}::uuid AND provider = 'local'
@@ -39,20 +39,23 @@ const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
39
39
  };
40
40
  };
41
41
 
42
- const getUserProvider = async (userId: string): Promise<UserProvider | null> => {
43
- const [row] = await sql<DbRow[]>`SELECT provider FROM auth.users WHERE id = ${userId}`;
42
+ const getUserProvider = async (userId: string, db: typeof sql | TransactionSQL = sql): Promise<UserProvider | null> => {
43
+ const [row] = await db<DbRow[]>`SELECT provider FROM auth.users WHERE id = ${userId}`;
44
44
  return (row?.provider as UserProvider | undefined) ?? null;
45
45
  };
46
46
 
47
- const ensureLocalGroupTreeMember = async (groupId: string): Promise<boolean> => {
48
- const [row] = await sql<DbRow[]>`SELECT 1 FROM auth.groups WHERE id = ${groupId} AND provider = 'local'`;
47
+ const ensureLocalGroupTreeMember = async (groupId: string, db: typeof sql | TransactionSQL = sql): Promise<boolean> => {
48
+ const [row] = await db<DbRow[]>`SELECT 1 FROM auth.groups WHERE id = ${groupId} AND provider = 'local'`;
49
49
  return Boolean(row);
50
50
  };
51
51
 
52
- const wouldCreateLocalGroupCycle = async (params: { parentGroupId: string; childGroupId: string }): Promise<boolean> => {
52
+ const wouldCreateLocalGroupCycle = async (
53
+ params: { parentGroupId: string; childGroupId: string },
54
+ db: typeof sql | TransactionSQL = sql,
55
+ ): Promise<boolean> => {
53
56
  if (params.parentGroupId === params.childGroupId) return true;
54
57
 
55
- const [row] = await sql<DbRow[]>`
58
+ const [row] = await db<DbRow[]>`
56
59
  WITH RECURSIVE descendants AS (
57
60
  SELECT gg.child_group_id
58
61
  FROM auth.group_groups_v2 gg
@@ -272,7 +275,11 @@ export const getMembers = async (params: { id: string; type?: GroupMember["type"
272
275
  return members;
273
276
  };
274
277
 
275
- export const getManagers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
278
+ export const getManagers = async (params: {
279
+ id: string;
280
+ type?: "user" | "group" | "service_account";
281
+ recursive?: boolean;
282
+ }): Promise<GroupMember[]> => {
276
283
  const group = await getLocalGroupById(params.id);
277
284
  if (!group) return [];
278
285
 
@@ -342,6 +349,16 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
342
349
  }
343
350
  }
344
351
 
352
+ if (!params.type || params.type === "service_account") {
353
+ const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
354
+ SELECT sa.id, sa.name, sa.kind FROM auth.group_manager_service_accounts gm
355
+ JOIN auth.service_accounts sa ON sa.id = gm.service_account_id
356
+ WHERE gm.group_id = ${group.id}::uuid AND sa.kind IN ('standalone', 'agent')
357
+ ORDER BY sa.name, sa.id`;
358
+ managers.push(
359
+ ...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
360
+ );
361
+ }
345
362
  return managers;
346
363
  };
347
364
 
@@ -416,25 +433,28 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
416
433
  return rows.map((row) => row.group_id as string);
417
434
  };
418
435
 
419
- export const addMember = async (params: {
420
- id: string;
421
- user?: string;
422
- group?: string;
423
- serviceAccount?: string;
424
- }): Promise<MutationResult<void>> => {
425
- const group = await getLocalGroupById(params.id);
436
+ export const addMember = async (
437
+ params: {
438
+ id: string;
439
+ user?: string;
440
+ group?: string;
441
+ serviceAccount?: string;
442
+ },
443
+ db: typeof sql | TransactionSQL = sql,
444
+ ): Promise<MutationResult<void>> => {
445
+ const group = await getLocalGroupById(params.id, db);
426
446
  if (!group) return { ok: false, error: "Group not found", status: 404 };
427
447
  const [personal] = await sql`SELECT 1 FROM auth.personal_groups
428
448
  WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
429
449
  if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
430
450
 
431
451
  if (params.serviceAccount) {
432
- const [account] = await sql<{ id: string }[]>`
452
+ const [account] = await db<{ id: string }[]>`
433
453
  SELECT id FROM auth.service_accounts
434
454
  WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
435
455
  `;
436
456
  if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
437
- const inserted = await sql`
457
+ const inserted = await db`
438
458
  INSERT INTO auth.service_account_groups (service_account_id, group_id)
439
459
  VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
440
460
  `;
@@ -443,9 +463,9 @@ export const addMember = async (params: {
443
463
  }
444
464
 
445
465
  if (params.user) {
446
- const provider = await getUserProvider(params.user);
466
+ const provider = await getUserProvider(params.user, db);
447
467
  if (!provider) return { ok: false, error: "User not found", status: 404 };
448
- const [existing] = await sql<DbRow[]>`
468
+ const [existing] = await db<DbRow[]>`
449
469
  SELECT 1
450
470
  FROM auth.user_groups_v2
451
471
  WHERE user_id = ${params.user}::uuid
@@ -453,14 +473,14 @@ export const addMember = async (params: {
453
473
  LIMIT 1
454
474
  `;
455
475
  if (existing) return { ok: false, error: "User is already a direct member of this group", status: 409 };
456
- await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${params.user}, ${group.id}) ON CONFLICT DO NOTHING`;
476
+ await db`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${params.user}, ${group.id}) ON CONFLICT DO NOTHING`;
457
477
  return { ok: true, data: undefined };
458
478
  }
459
479
 
460
480
  if (params.group) {
461
- const isLocal = await ensureLocalGroupTreeMember(params.group);
481
+ const isLocal = await ensureLocalGroupTreeMember(params.group, db);
462
482
  if (!isLocal) return { ok: false, error: "Only local groups can be nested into local groups", status: 400 };
463
- const [existing] = await sql<DbRow[]>`
483
+ const [existing] = await db<DbRow[]>`
464
484
  SELECT 1
465
485
  FROM auth.group_groups_v2
466
486
  WHERE parent_group_id = ${group.id}::uuid
@@ -468,51 +488,69 @@ export const addMember = async (params: {
468
488
  LIMIT 1
469
489
  `;
470
490
  if (existing) return { ok: false, error: "Group is already a direct member of this group", status: 409 };
471
- if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group })) {
491
+ if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group }, db)) {
472
492
  return { ok: false, error: "Local group nesting cannot create cycles", status: 400 };
473
493
  }
474
- await sql`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${group.id}, ${params.group}) ON CONFLICT DO NOTHING`;
494
+ await db`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${group.id}, ${params.group}) ON CONFLICT DO NOTHING`;
475
495
  return { ok: true, data: undefined };
476
496
  }
477
497
 
478
498
  return { ok: false, error: "Missing member", status: 400 };
479
499
  };
480
500
 
481
- export const removeMember = async (params: {
482
- id: string;
483
- user?: string;
484
- group?: string;
485
- serviceAccount?: string;
486
- }): Promise<MutationResult<void>> => {
487
- const group = await getLocalGroupById(params.id);
501
+ export const removeMember = async (
502
+ params: {
503
+ id: string;
504
+ user?: string;
505
+ group?: string;
506
+ serviceAccount?: string;
507
+ },
508
+ db: typeof sql | TransactionSQL = sql,
509
+ ): Promise<MutationResult<void>> => {
510
+ const group = await getLocalGroupById(params.id, db);
488
511
  if (!group) return { ok: false, error: "Group not found", status: 404 };
489
512
 
490
513
  if (params.serviceAccount) {
491
- await sql`DELETE FROM auth.service_account_groups
514
+ await db`DELETE FROM auth.service_account_groups
492
515
  WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
493
516
  return { ok: true, data: undefined };
494
517
  }
495
518
 
496
519
  if (params.user) {
497
- await sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
520
+ await db`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
498
521
  return { ok: true, data: undefined };
499
522
  }
500
523
 
501
524
  if (params.group) {
502
- await sql`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${group.id}::uuid AND child_group_id = ${params.group}::uuid`;
525
+ await db`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${group.id}::uuid AND child_group_id = ${params.group}::uuid`;
503
526
  return { ok: true, data: undefined };
504
527
  }
505
528
 
506
529
  return { ok: false, error: "Missing member", status: 400 };
507
530
  };
508
531
 
509
- export const addManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
532
+ export const addManager = async (params: {
533
+ id: string;
534
+ user?: string;
535
+ group?: string;
536
+ serviceAccount?: string;
537
+ }): Promise<MutationResult<void>> => {
510
538
  const group = await getLocalGroupById(params.id);
511
539
  if (!group) return { ok: false, error: "Group not found", status: 404 };
512
540
  const [personal] = await sql`SELECT 1 FROM auth.personal_groups
513
541
  WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
514
542
  if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
515
543
 
544
+ if (params.serviceAccount) {
545
+ const [account] = await sql`SELECT id FROM auth.service_accounts
546
+ WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')`;
547
+ if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
548
+ const inserted = await sql`INSERT INTO auth.group_manager_service_accounts(group_id, service_account_id)
549
+ VALUES (${group.id}::uuid, ${params.serviceAccount}::uuid) ON CONFLICT DO NOTHING`;
550
+ if (inserted.count === 0) return { ok: false, error: "Service account already manages this group", status: 409 };
551
+ return { ok: true, data: undefined };
552
+ }
553
+
516
554
  if (params.user) {
517
555
  const provider = await getUserProvider(params.user);
518
556
  if (!provider) return { ok: false, error: "User not found", status: 404 };
@@ -546,10 +584,21 @@ export const addManager = async (params: { id: string; user?: string; group?: st
546
584
  return { ok: false, error: "Missing manager", status: 400 };
547
585
  };
548
586
 
549
- export const removeManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
587
+ export const removeManager = async (params: {
588
+ id: string;
589
+ user?: string;
590
+ group?: string;
591
+ serviceAccount?: string;
592
+ }): Promise<MutationResult<void>> => {
550
593
  const group = await getLocalGroupById(params.id);
551
594
  if (!group) return { ok: false, error: "Group not found", status: 404 };
552
595
 
596
+ if (params.serviceAccount) {
597
+ await sql`DELETE FROM auth.group_manager_service_accounts
598
+ WHERE group_id = ${group.id}::uuid AND service_account_id = ${params.serviceAccount}::uuid`;
599
+ return { ok: true, data: undefined };
600
+ }
601
+
553
602
  if (params.user) {
554
603
  await sql`DELETE FROM auth.group_manager_users_v2 WHERE group_id = ${group.id}::uuid AND user_id = ${params.user}::uuid`;
555
604
  return { ok: true, data: undefined };
@@ -0,0 +1,94 @@
1
+ import { sql } from "bun";
2
+ import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
3
+ import { err, fail, ok, type Result } from "../../server/services";
4
+ import { audit } from "../audit";
5
+ import * as localGroups from "./local-groups";
6
+
7
+ /** Runs the existing local membership operation while its direct management grant is locked. */
8
+ export async function mutateMachineGroupMember(params: {
9
+ actor: ServiceAccountRequestActor;
10
+ id: string;
11
+ userId?: string;
12
+ groupId?: string;
13
+ serviceAccountId?: string;
14
+ operation: "add" | "remove";
15
+ requestId?: string;
16
+ }): Promise<Result<void>> {
17
+ const { actor } = params;
18
+ const action = `accounts.group.member.${params.operation}`;
19
+ const target = { type: "group", id: params.id };
20
+ const metadata = {
21
+ actorServiceAccountId: actor.serviceAccount.id,
22
+ credentialId: actor.credentialId ?? null,
23
+ oauthClientId: actor.oauthClientId ?? null,
24
+ userId: params.userId ?? null,
25
+ groupId: params.groupId ?? null,
26
+ serviceAccountId: params.serviceAccountId ?? null,
27
+ };
28
+ const unexpired = (expiration: string | Date | null | undefined) =>
29
+ expiration == null || (typeof expiration === "string" ? Date.parse(expiration) : expiration.getTime()) > Date.now();
30
+ const denied = () => fail(err.forbidden("A current management grant for this group and write scope are required"));
31
+ if (
32
+ actor.delegatedUser ||
33
+ !["standalone", "agent"].includes(actor.serviceAccount.kind) ||
34
+ !actor.scopes.some((scope) => scope === "write" || scope === "admin") ||
35
+ !unexpired(actor.credentialExpiresAt) ||
36
+ (!actor.credentialId && (!actor.oauthClientId || !actor.credentialExpiresAt))
37
+ )
38
+ return audit.recordResult({ action, target, metadata, requestId: params.requestId, result: denied() });
39
+
40
+ return sql.begin(async (db) => {
41
+ // Account -> credential -> group -> grant matches the group deletion cascade.
42
+ const [account] = await db`SELECT id FROM auth.service_accounts
43
+ WHERE id = ${actor.serviceAccount.id}::uuid AND status = 'active' AND kind IN ('standalone','agent') FOR SHARE`;
44
+ let allowed = Boolean(account);
45
+ let storedExpiration: Date | null = null;
46
+ if (allowed && actor.credentialId) {
47
+ const [credential] = await db<{ expires_at: Date | null }[]>`SELECT expires_at FROM auth.service_account_credentials
48
+ WHERE id = ${actor.credentialId}::uuid AND service_account_id = ${actor.serviceAccount.id}::uuid
49
+ AND status = 'active' AND (expires_at IS NULL OR expires_at > clock_timestamp())
50
+ AND scopes && ARRAY['write','admin']::text[] FOR SHARE`;
51
+ allowed = Boolean(credential);
52
+ storedExpiration = credential?.expires_at ?? null;
53
+ } else if (allowed) {
54
+ const [client] = await db`SELECT id FROM oauth.clients WHERE client_id=${actor.oauthClientId ?? null} FOR SHARE`;
55
+ allowed = Boolean(client);
56
+ }
57
+ const [group] = allowed ? await db`SELECT id FROM auth.groups WHERE id=${params.id}::uuid AND provider='local' FOR SHARE` : [];
58
+ const [grant] = group
59
+ ? await db`SELECT group_id FROM auth.group_manager_service_accounts
60
+ WHERE group_id=${params.id}::uuid AND service_account_id=${actor.serviceAccount.id}::uuid FOR SHARE`
61
+ : [];
62
+ let result: Result<void> = denied();
63
+ // The authorization locks define admission; time must be current after any lock wait.
64
+ if (grant && unexpired(actor.credentialExpiresAt) && unexpired(storedExpiration)) {
65
+ if ([params.userId, params.groupId, params.serviceAccountId].filter(Boolean).length !== 1) {
66
+ result = fail(err.badInput("Choose exactly one member"));
67
+ } else {
68
+ const changed = await localGroups[params.operation === "add" ? "addMember" : "removeMember"](
69
+ {
70
+ id: params.id,
71
+ user: params.userId,
72
+ group: params.groupId,
73
+ serviceAccount: params.serviceAccountId,
74
+ },
75
+ db,
76
+ );
77
+ result = changed.ok
78
+ ? ok()
79
+ : fail({ code: changed.status === 403 ? "FORBIDDEN" : "GROUP_MUTATION_FAILED", message: changed.error, status: changed.status });
80
+ }
81
+ }
82
+ await audit.record(
83
+ {
84
+ action,
85
+ target,
86
+ metadata,
87
+ requestId: params.requestId,
88
+ outcome: result.ok ? "allowed" : result.error.status === 403 ? "denied" : "failed",
89
+ },
90
+ db,
91
+ );
92
+ return result;
93
+ });
94
+ }