@k2b/cloud 0.44.0 → 0.46.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 (137) hide show
  1. package/package.json +9 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/access/GroupCoverage.tsx +20 -5
  9. package/src/access/PermissionEditor.tsx +11 -2
  10. package/src/access/PrincipalAvatar.tsx +30 -21
  11. package/src/access/messages.ts +4 -4
  12. package/src/account/EntitySearch.tsx +12 -0
  13. package/src/ai/capabilities.ts +4 -2
  14. package/src/ai/chat/map-messages.ts +270 -0
  15. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  16. package/src/ai/chat/map-notebook.tsx +301 -0
  17. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  18. package/src/ai/chat/map-widget.css +107 -0
  19. package/src/ai/chat/map-widget.tsx +1279 -0
  20. package/src/ai/chat/message-utils.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +2 -1
  23. package/src/ai/chat/turn-view.tsx +17 -1
  24. package/src/ai/default-tools.ts +3 -0
  25. package/src/ai/dictation-runtime.ts +6 -2
  26. package/src/ai/executor.ts +22 -4
  27. package/src/ai/index.ts +1 -0
  28. package/src/ai/map-contracts.ts +209 -0
  29. package/src/ai/map-execution.ts +14 -0
  30. package/src/ai/map-notebook-save.ts +276 -0
  31. package/src/ai/map-notebook.ts +32 -0
  32. package/src/ai/map-opening-hours.ts +40 -0
  33. package/src/ai/map-routes.ts +84 -0
  34. package/src/ai/map-service.ts +288 -0
  35. package/src/ai/map-store.ts +137 -0
  36. package/src/ai/map-tool.ts +73 -0
  37. package/src/ai/migrate.ts +3 -0
  38. package/src/ai/project-tool.ts +4 -4
  39. package/src/ai/projects-authorization.ts +53 -0
  40. package/src/ai/projects-routes.ts +56 -46
  41. package/src/ai/projects.ts +100 -79
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/recent-resources.ts +3 -3
  44. package/src/ai/routes.ts +32 -30
  45. package/src/ai/skill-tool.ts +4 -4
  46. package/src/ai/skills-routes.ts +27 -26
  47. package/src/ai/skills.ts +120 -81
  48. package/src/ai/store.ts +19 -6
  49. package/src/ai/tools.ts +14 -0
  50. package/src/ai/types.ts +3 -0
  51. package/src/api/accounts-entities.ts +6 -3
  52. package/src/api/admin-ai-skills.ts +4 -1
  53. package/src/api/auth.ts +9 -2
  54. package/src/api/capabilities.ts +90 -12
  55. package/src/api/mcp.ts +4 -2
  56. package/src/browser/notifications.ts +25 -8
  57. package/src/browser/session.ts +39 -0
  58. package/src/browser/shared-text-history-validation.ts +151 -0
  59. package/src/browser/shared-text-history.ts +464 -0
  60. package/src/browser/shared-text-offline-authorized.ts +29 -0
  61. package/src/browser/shared-text-offline.ts +351 -0
  62. package/src/browser/shared-text-provider.ts +714 -0
  63. package/src/browser/shared-text-table-contract.ts +150 -0
  64. package/src/browser/shared-text.ts +19 -0
  65. package/src/capabilities/discussion-providers.ts +99 -0
  66. package/src/capabilities/executions.ts +2 -2
  67. package/src/capabilities/migrate.ts +10 -1
  68. package/src/capabilities/resource-readers.ts +58 -0
  69. package/src/capabilities/server-invoke.ts +23 -13
  70. package/src/capabilities/server.ts +17 -6
  71. package/src/capabilities/testing.ts +13 -0
  72. package/src/contracts/capabilities.ts +137 -4
  73. package/src/contracts/discussions.ts +91 -0
  74. package/src/contracts/geo-document.ts +60 -0
  75. package/src/contracts/geo-snapshot.ts +86 -0
  76. package/src/contracts/geo.ts +14 -0
  77. package/src/contracts/index.ts +2 -0
  78. package/src/contracts/outgoing-mail.ts +1 -1
  79. package/src/contracts/resource-types.ts +21 -0
  80. package/src/contracts/shared.ts +2 -1
  81. package/src/geo/AddressAutocomplete.tsx +213 -0
  82. package/src/geo/messages.ts +29 -0
  83. package/src/geo/navigation.ts +25 -0
  84. package/src/geo/ui.ts +2 -0
  85. package/src/server/api/respond.ts +10 -2
  86. package/src/server/index.ts +1 -0
  87. package/src/server/services/access.ts +23 -10
  88. package/src/server/services/index.ts +1 -0
  89. package/src/services/accounts/app.ts +56 -17
  90. package/src/services/accounts/entities.ts +36 -16
  91. package/src/services/accounts/group-sql.ts +23 -0
  92. package/src/services/accounts/groups.ts +26 -17
  93. package/src/services/accounts/identities.ts +8 -1
  94. package/src/services/accounts/identity-reconciliation.ts +3 -1
  95. package/src/services/accounts/local-groups.ts +141 -27
  96. package/src/services/accounts/machine-group-management.ts +94 -0
  97. package/src/services/accounts/users.ts +38 -8
  98. package/src/services/geo/cache.ts +2 -1
  99. package/src/services/geo/geoapify.ts +3 -0
  100. package/src/services/identity/invocation-token.ts +110 -0
  101. package/src/services/mandates/index.ts +2 -2
  102. package/src/services/mandates/policy.ts +1 -0
  103. package/src/services/pdf/geo-snapshot.ts +107 -0
  104. package/src/services/pdf/index.ts +2 -0
  105. package/src/services/session/index.ts +12 -3
  106. package/src/shared/email-html.ts +1 -0
  107. package/src/shared/markdown/extensions/katex.ts +8 -1
  108. package/src/shared/markdown/extensions/tables.ts +14 -1
  109. package/src/shared/markdown/index.ts +22 -11
  110. package/src/shared-text/anchors.ts +29 -0
  111. package/src/shared-text/awareness.ts +87 -0
  112. package/src/shared-text/changes.ts +27 -0
  113. package/src/shared-text/documents.ts +428 -77
  114. package/src/shared-text/events.ts +12 -2
  115. package/src/shared-text/index.ts +16 -1
  116. package/src/shared-text/log.ts +24 -5
  117. package/src/shared-text/snapshots.ts +69 -23
  118. package/src/shared-text/structural-history.ts +377 -0
  119. package/src/shared-text/table-actions.ts +432 -0
  120. package/src/shared-text/types.ts +24 -5
  121. package/src/ssr/AdminArea.tsx +2 -2
  122. package/src/ssr/AdminLayout.tsx +1 -1
  123. package/src/ssr/AdminSidebar.tsx +1 -1
  124. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  125. package/src/ssr/CloudShell.island.tsx +19 -3
  126. package/src/ssr/Layout.tsx +1 -1
  127. package/src/ssr/MobileProfileActions.tsx +2 -2
  128. package/src/ssr/ProfilePreferences.tsx +2 -1
  129. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  130. package/src/ssr/admin-navigation.ts +1 -11
  131. package/src/ssr/app-launchpad.tsx +6 -1
  132. package/src/ssr/platform-messages.ts +2 -0
  133. package/src/ssr/profile-actions.ts +3 -6
  134. package/src/ssr/shell-data.tsx +1 -1
  135. package/src/styles/global.css +2 -0
  136. package/src/workflows/language/expressions.ts +3 -2
  137. package/src/workflows/runtime/executor.ts +2 -1
@@ -1,14 +1,14 @@
1
- import { sql } from "bun";
1
+ import { type SQLQuery, 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
5
  import type { ServiceAccountKind, ServiceAccountStatus } from "../service-accounts";
6
6
  import { buildBaseGroup, personalOwnerJoin } from "./base-group";
7
7
  import { buildBaseUser } from "./base-user";
8
- import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
8
+ import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery, recursiveServiceAccountGroupIdsSubquery } from "./group-sql";
9
9
 
10
10
  type DbRow = Record<string, unknown>;
11
- type SqlFragment = any;
11
+ type SqlFragment = SQLQuery;
12
12
 
13
13
  export type EntityListParams = {
14
14
  visibility:
@@ -21,6 +21,7 @@ export type EntityListParams = {
21
21
  profile?: UserProfile;
22
22
  userIds?: string[];
23
23
  groupIds?: string[];
24
+ serviceAccountIds?: string[];
24
25
  serviceAccountKinds?: ServiceAccountKind[];
25
26
  serviceAccountStatus?: ServiceAccountStatus;
26
27
  excludeUserIds?: string[];
@@ -32,11 +33,6 @@ export type EntityListParams = {
32
33
  parentGroupId?: string;
33
34
  managedByUserId?: string;
34
35
  recursive?: boolean;
35
- /**
36
- * Personal Linux groups are left out of directory browsing and search by
37
- * default. Exact `groupIds` lookups and relation filters always return them.
38
- */
39
- includePersonal?: boolean;
40
36
  page?: number;
41
37
  perPage?: number;
42
38
  };
@@ -429,11 +425,7 @@ export const list = async (
429
425
  const groupsAdmin = (await getFreeIpaConfig()).groupsAdmin;
430
426
  const groupsAdminLiteral = toPgTextArray(groupsAdmin);
431
427
  const spec = buildQuerySpec(params);
432
- const includePersonal =
433
- params.includePersonal === true ||
434
- params.groupIds !== undefined ||
435
- Boolean(params.memberOfGroupId || params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
436
- const personalCondition = includePersonal ? sql`TRUE` : sql`(kind <> 'group' OR personal_owner_id IS NULL)`;
428
+ const personalCondition = sql`(kind <> 'group' OR NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = entity_rows.id))`;
437
429
  const visibilityCondition =
438
430
  params.visibility.type === "directory"
439
431
  ? sql`TRUE`
@@ -455,12 +447,13 @@ export const list = async (
455
447
  )
456
448
  )`;
457
449
  const kindsCondition = (params.kinds?.length ?? 0) === 0 ? sql`TRUE` : sql`kind = ANY(${toPgTextArray(params.kinds ?? [])}::text[])`;
458
- const hasIdFilter = params.userIds !== undefined || params.groupIds !== undefined;
450
+ const hasIdFilter = params.userIds !== undefined || params.groupIds !== undefined || params.serviceAccountIds !== undefined;
459
451
  const includeIdCondition = !hasIdFilter
460
452
  ? sql`TRUE`
461
453
  : sql`(
462
454
  (kind = 'user' AND id = ANY(${toPgUuidArray(params.userIds ?? [])}::uuid[]))
463
455
  OR (kind = 'group' AND id = ANY(${toPgUuidArray(params.groupIds ?? [])}::uuid[]))
456
+ OR (kind = 'service_account' AND id = ANY(${toPgUuidArray(params.serviceAccountIds ?? [])}::uuid[]))
464
457
  )`;
465
458
  const excludeUserCondition =
466
459
  (params.excludeUserIds?.length ?? 0) === 0
@@ -491,11 +484,37 @@ export const list = async (
491
484
  AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
492
485
  ))`;
493
486
 
487
+ // Machine management is direct only; membership never implies management.
488
+ const otherRelation = Boolean(params.parentGroupId || params.managedByUserId);
489
+ const machineRelation = params.memberOfGroupId
490
+ ? sql`sa.kind IN ('standalone', 'agent') AND EXISTS (
491
+ SELECT 1 FROM auth.groups target
492
+ WHERE target.id = ${params.memberOfGroupId}::uuid AND target.provider = 'local'
493
+ AND ${
494
+ params.recursive === true
495
+ ? sql`target.id IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
496
+ : sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
497
+ WHERE sg.service_account_id = sa.id AND sg.group_id = target.id)`
498
+ }
499
+ )`
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`;
506
+ const machineDirect = params.memberOfGroupId
507
+ ? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
508
+ WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
509
+ : params.managerOfGroupId
510
+ ? sql`TRUE`
511
+ : sql`NULL::boolean`;
494
512
  const where = sql`
495
513
  ${visibilityCondition}
496
514
  AND ${kindsCondition}
497
515
  AND ${includeIdCondition}
498
- AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null})
516
+ AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null}
517
+ OR (kind = 'service_account' AND ${params.provider ?? null}::text = 'local'))
499
518
  AND (${params.profile ?? null}::text IS NULL OR kind = 'group' OR profile = ${params.profile ?? null})
500
519
  AND ${excludeUserCondition}
501
520
  AND ${excludeGroupCondition}
@@ -612,7 +631,7 @@ export const list = async (
612
631
  service_account_rows AS (
613
632
  SELECT
614
633
  'service_account'::text AS kind,
615
- NULL::boolean AS direct,
634
+ ${machineDirect} AS direct,
616
635
  sa.id,
617
636
  NULL::text AS provider,
618
637
  NULL::text AS profile,
@@ -644,6 +663,7 @@ export const list = async (
644
663
  NULL::text AS personal_owner_display_name,
645
664
  LOWER(sa.name) AS sort_label
646
665
  FROM auth.service_accounts sa
666
+ WHERE NOT ${otherRelation} AND ${machineRelation}
647
667
  ),
648
668
  entity_rows AS (
649
669
  SELECT * FROM user_rows
@@ -104,3 +104,26 @@ export const buildManagedGroupScopeCondition = (params: { userId: SqlValue; grou
104
104
  })}
105
105
  )
106
106
  `;
107
+
108
+ /** Local recipient membership only; never supplies user roles or directory identity. */
109
+ export const recursiveServiceAccountGroupIdsSubquery = (serviceAccountId: SqlValue) => sql`
110
+ WITH RECURSIVE account_subject AS (
111
+ -- Resolve a correlated column before introducing aliases used by directory callers (for example sa.id).
112
+ SELECT ${serviceAccountId}::uuid AS id
113
+ ), account_groups AS (
114
+ SELECT sg.group_id
115
+ FROM auth.service_account_groups sg
116
+ JOIN auth.service_accounts sa ON sa.id = sg.service_account_id
117
+ JOIN account_subject subject ON subject.id = sa.id
118
+ JOIN auth.groups g ON g.id = sg.group_id
119
+ WHERE sa.status = 'active' AND sa.kind IN ('standalone', 'agent')
120
+ AND g.provider = 'local'
121
+ UNION
122
+ SELECT gg.parent_group_id
123
+ FROM auth.group_groups_v2 gg
124
+ JOIN auth.groups parent ON parent.id = gg.parent_group_id
125
+ JOIN account_groups ag ON ag.group_id = gg.child_group_id
126
+ WHERE parent.provider = 'local'
127
+ )
128
+ SELECT group_id FROM account_groups
129
+ `;
@@ -31,8 +31,6 @@ const listCanonical = async (params: {
31
31
  scope?: GroupListScope;
32
32
  search?: string;
33
33
  provider?: UserProvider;
34
- /** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
35
- includePersonal?: boolean;
36
34
  page?: number;
37
35
  perPage?: number;
38
36
  }): Promise<{
@@ -48,7 +46,6 @@ const listCanonical = async (params: {
48
46
  const scope = params.scope ?? (params.userId ? "member" : "all");
49
47
  const scopeUserId = params.userId ?? "00000000-0000-0000-0000-000000000000";
50
48
  const idsCondition = ids.length === 0 ? sql`TRUE` : sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`;
51
- const includePersonal = params.includePersonal === true || params.ids !== undefined;
52
49
 
53
50
  if (params.ids && params.ids.length === 0) {
54
51
  return {
@@ -69,7 +66,7 @@ const listCanonical = async (params: {
69
66
  ${personalOwnerJoin()}
70
67
  WHERE (${params.provider ?? null}::text IS NULL OR g.provider = ${params.provider ?? null})
71
68
  AND ${idsCondition}
72
- AND (${includePersonal} = true OR personal_owner.personal_owner_id IS NULL)
69
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
73
70
  AND (
74
71
  ${scope === "all"} = true
75
72
  OR ${params.userId ?? null}::uuid IS NULL
@@ -107,8 +104,6 @@ export const list = async (params: {
107
104
  scope?: GroupListScope;
108
105
  search?: string;
109
106
  provider?: UserProvider;
110
- /** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
111
- includePersonal?: boolean;
112
107
  page?: number;
113
108
  perPage?: number;
114
109
  }) => {
@@ -122,13 +117,13 @@ export const get = async (params: { id: string }): Promise<BaseGroup | null> =>
122
117
  export const getMembers = async (params: {
123
118
  id: string;
124
119
  provider?: UserProvider;
125
- type?: "user" | "group";
120
+ type?: GroupMember["type"];
126
121
  recursive?: boolean;
127
122
  }): Promise<GroupMember[]> => {
128
123
  const provider = params.provider ?? (await getGroup(params.id))?.provider;
129
124
  if (provider === "local") return localGroups.getMembers(params);
130
- if (!provider) return [];
131
- return providers.ipa.groups.getMembers(params);
125
+ if (!provider || params.type === "service_account") return [];
126
+ return providers.ipa.groups.getMembers({ ...params, type: params.type });
132
127
  };
133
128
 
134
129
  export const getManagers = async (params: {
@@ -225,9 +220,13 @@ export const addMember = async (params: {
225
220
  provider?: UserProvider;
226
221
  user?: string;
227
222
  group?: string;
223
+ serviceAccount?: string;
228
224
  }): Promise<MutationResult<void>> => {
229
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
230
- if (provider === "local") return localGroups.addMember({ id: params.id, user: params.user, group: params.group });
225
+ const provider = (await getGroup(params.id))?.provider;
226
+ if (provider === "local")
227
+ return localGroups.addMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
228
+ if (params.serviceAccount)
229
+ return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
231
230
  const serviceSession = await getServiceIpaSession();
232
231
  if (!serviceSession.ok) return serviceSession;
233
232
  return providers.ipa.groups.addMember({
@@ -243,9 +242,13 @@ export const removeMember = async (params: {
243
242
  provider?: UserProvider;
244
243
  user?: string;
245
244
  group?: string;
245
+ serviceAccount?: string;
246
246
  }): Promise<MutationResult<void>> => {
247
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
248
- if (provider === "local") return localGroups.removeMember({ id: params.id, user: params.user, group: params.group });
247
+ const provider = (await getGroup(params.id))?.provider;
248
+ if (provider === "local")
249
+ return localGroups.removeMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
250
+ if (params.serviceAccount)
251
+ return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
249
252
  const serviceSession = await getServiceIpaSession();
250
253
  if (!serviceSession.ok) return serviceSession;
251
254
  return providers.ipa.groups.removeMember({
@@ -261,9 +264,12 @@ export const addManager = async (params: {
261
264
  provider?: UserProvider;
262
265
  user?: string;
263
266
  group?: string;
267
+ serviceAccount?: string;
264
268
  }): Promise<MutationResult<void>> => {
265
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
266
- 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 };
267
273
  const serviceSession = await getServiceIpaSession();
268
274
  if (!serviceSession.ok) return serviceSession;
269
275
  return providers.ipa.groups.addManager({
@@ -279,9 +285,12 @@ export const removeManager = async (params: {
279
285
  provider?: UserProvider;
280
286
  user?: string;
281
287
  group?: string;
288
+ serviceAccount?: string;
282
289
  }): Promise<MutationResult<void>> => {
283
- const provider = params.provider ?? (await getGroup(params.id))?.provider;
284
- 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 };
285
294
  const serviceSession = await getServiceIpaSession();
286
295
  if (!serviceSession.ok) return serviceSession;
287
296
  return providers.ipa.groups.removeManager({
@@ -189,18 +189,25 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
189
189
  eligible: current.profile === "user",
190
190
  };
191
191
  }
192
+ const [ownership] = await db<{ personal: boolean }[]>`SELECT EXISTS (
193
+ SELECT 1 FROM auth.personal_groups p
194
+ WHERE (${input.identityId ?? null}::uuid IS NOT NULL AND p.group_id = ${input.identityId ?? null}::uuid)
195
+ OR (${input.identityId ?? null}::uuid IS NULL AND p.group_name = ${input.name})
196
+ ) AS personal`;
197
+ const personal = ownership?.personal === true;
192
198
  const rows = await groupRows(sql`(g.provider = 'local' AND g.name = ${input.name}) OR g.id = ${input.identityId ?? null}::uuid`);
193
199
  const expected = rows.find((row) => row.id === input.identityId);
194
200
  if (expected && (expected.provider !== "local" || expected.name !== input.name))
195
201
  return { state: "unknown", reason: "identity_conflict" };
196
202
  const matches = rows.filter((row) => row.provider === "local" && row.name === input.name);
197
- if (!matches.length) return { state: "absent" };
203
+ if (!matches.length) return personal ? { state: "absent", personal: true } : { state: "absent" };
198
204
  if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
199
205
  const current = matches[0]!;
200
206
  return {
201
207
  state: "present",
202
208
  identity: { id: current.id, name: current.name, uidNumber: null, gidNumber: current.gidNumber },
203
209
  eligible: current.gidNumber !== null,
210
+ ...(personal ? { personal: true } : {}),
204
211
  };
205
212
  };
206
213
  const validateLookup = (input: { kind: "users" | "groups"; name: string }) => {
@@ -9,8 +9,10 @@ export type AccountIdentityReconciliation =
9
9
  state: "present";
10
10
  identity: { id: string | null; name: string; uidNumber: number | null; gidNumber: number | null };
11
11
  eligible: boolean;
12
+ /** Proven Cloud personal-group ownership, including retained directory bindings. */
13
+ personal?: boolean;
12
14
  }
13
- | { state: "absent" }
15
+ | { state: "absent"; personal?: boolean }
14
16
  | { state: "unknown"; reason: "provider_disabled" | "provider_unavailable" | "invalid_response" | "identity_conflict" };
15
17
  export type IdentityLookup = { kind: "users" | "groups"; name: string; signal?: AbortSignal };
16
18
 
@@ -1,7 +1,8 @@
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";
5
+ import { recursiveServiceAccountGroupIdsSubquery } from "./group-sql";
5
6
 
6
7
  type DbRow = Record<string, unknown>;
7
8
 
@@ -22,8 +23,8 @@ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
22
23
  personalOwner: null,
23
24
  });
24
25
 
25
- const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
26
- 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[]>`
27
28
  SELECT id, provider, name, description, gid_number
28
29
  FROM auth.groups
29
30
  WHERE id = ${id}::uuid AND provider = 'local'
@@ -38,20 +39,23 @@ const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
38
39
  };
39
40
  };
40
41
 
41
- const getUserProvider = async (userId: string): Promise<UserProvider | null> => {
42
- 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}`;
43
44
  return (row?.provider as UserProvider | undefined) ?? null;
44
45
  };
45
46
 
46
- const ensureLocalGroupTreeMember = async (groupId: string): Promise<boolean> => {
47
- 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'`;
48
49
  return Boolean(row);
49
50
  };
50
51
 
51
- 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> => {
52
56
  if (params.parentGroupId === params.childGroupId) return true;
53
57
 
54
- const [row] = await sql<DbRow[]>`
58
+ const [row] = await db<DbRow[]>`
55
59
  WITH RECURSIVE descendants AS (
56
60
  SELECT gg.child_group_id
57
61
  FROM auth.group_groups_v2 gg
@@ -122,6 +126,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
122
126
  SELECT COUNT(*)::int AS count
123
127
  FROM auth.groups g
124
128
  WHERE g.provider = 'local'
129
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
125
130
  AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
126
131
  `;
127
132
  const total = Number(countRow?.count ?? 0);
@@ -130,6 +135,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
130
135
  FROM auth.groups g
131
136
  ${personalOwnerJoin()}
132
137
  WHERE g.provider = 'local'
138
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
133
139
  AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
134
140
  ORDER BY g.name
135
141
  LIMIT ${perPage} OFFSET ${offset}
@@ -177,7 +183,7 @@ export const remove = async (params: { id: string }): Promise<MutationResult<voi
177
183
  return { ok: true, data: undefined };
178
184
  };
179
185
 
180
- export const getMembers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
186
+ export const getMembers = async (params: { id: string; type?: GroupMember["type"]; recursive?: boolean }): Promise<GroupMember[]> => {
181
187
  const group = await getLocalGroupById(params.id);
182
188
  if (!group) return [];
183
189
  const members: GroupMember[] = [];
@@ -231,6 +237,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
231
237
  SELECT DISTINCT g.id, g.name
232
238
  FROM local_group_tree tree
233
239
  JOIN auth.groups g ON g.id = tree.child_group_id
240
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
234
241
  ORDER BY g.name
235
242
  `
236
243
  : await sql<DbRow[]>`
@@ -238,6 +245,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
238
245
  FROM auth.group_groups_v2 gg
239
246
  JOIN auth.groups g ON g.id = gg.child_group_id
240
247
  WHERE gg.parent_group_id = ${group.id} AND g.provider = 'local'
248
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
241
249
  ORDER BY g.name
242
250
  `;
243
251
  for (const row of groupRows) {
@@ -245,10 +253,33 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
245
253
  }
246
254
  }
247
255
 
256
+ if (!params.type || params.type === "service_account") {
257
+ const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
258
+ SELECT sa.id, sa.name, sa.kind
259
+ FROM auth.service_accounts sa
260
+ WHERE sa.kind IN ('standalone', 'agent') AND ${
261
+ params.recursive === true
262
+ ? sql`${group.id}::uuid IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
263
+ : sql`EXISTS (
264
+ SELECT 1 FROM auth.service_account_groups sg
265
+ WHERE sg.service_account_id = sa.id AND sg.group_id = ${group.id}::uuid
266
+ )`
267
+ }
268
+ ORDER BY sa.name, sa.id
269
+ `;
270
+ members.push(
271
+ ...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
272
+ );
273
+ }
274
+
248
275
  return members;
249
276
  };
250
277
 
251
- 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[]> => {
252
283
  const group = await getLocalGroupById(params.id);
253
284
  if (!group) return [];
254
285
 
@@ -310,6 +341,7 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
310
341
  FROM auth.group_manager_groups_v2 gmg
311
342
  JOIN auth.groups g ON g.id = gmg.manager_group_id
312
343
  WHERE gmg.group_id = ${group.id} AND g.provider = 'local'
344
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
313
345
  ORDER BY g.name
314
346
  `;
315
347
  for (const row of groupRows) {
@@ -317,6 +349,16 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
317
349
  }
318
350
  }
319
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
+ }
320
362
  return managers;
321
363
  };
322
364
 
@@ -340,7 +382,8 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
340
382
  WHERE g.provider = 'local'
341
383
  )
342
384
  SELECT DISTINCT parent_group_id
343
- FROM local_parent_tree
385
+ FROM local_parent_tree tree
386
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = tree.parent_group_id)
344
387
  `
345
388
  : await sql<DbRow[]>`
346
389
  SELECT gg.parent_group_id
@@ -348,6 +391,7 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
348
391
  JOIN auth.groups g ON g.id = gg.parent_group_id
349
392
  WHERE gg.child_group_id = ${group.id}
350
393
  AND g.provider = 'local'
394
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
351
395
  ORDER BY g.name
352
396
  `;
353
397
 
@@ -374,6 +418,7 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
374
418
  JOIN auth.group_manager_groups_v2 gmg ON gmg.manager_group_id = tree.manager_group_id
375
419
  JOIN auth.groups g ON g.id = gmg.group_id
376
420
  WHERE g.provider = 'local'
421
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
377
422
  `
378
423
  : await sql<DbRow[]>`
379
424
  SELECT gmg.group_id
@@ -381,20 +426,46 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
381
426
  JOIN auth.groups g ON g.id = gmg.group_id
382
427
  WHERE gmg.manager_group_id = ${group.id}
383
428
  AND g.provider = 'local'
429
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
384
430
  ORDER BY g.name
385
431
  `;
386
432
 
387
433
  return rows.map((row) => row.group_id as string);
388
434
  };
389
435
 
390
- export const addMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
391
- 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);
392
446
  if (!group) return { ok: false, error: "Group not found", status: 404 };
447
+ const [personal] = await sql`SELECT 1 FROM auth.personal_groups
448
+ WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
449
+ if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
450
+
451
+ if (params.serviceAccount) {
452
+ const [account] = await db<{ id: string }[]>`
453
+ SELECT id FROM auth.service_accounts
454
+ WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
455
+ `;
456
+ if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
457
+ const inserted = await db`
458
+ INSERT INTO auth.service_account_groups (service_account_id, group_id)
459
+ VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
460
+ `;
461
+ if (inserted.count === 0) return { ok: false, error: "Service account is already a direct member of this group", status: 409 };
462
+ return { ok: true, data: undefined };
463
+ }
393
464
 
394
465
  if (params.user) {
395
- const provider = await getUserProvider(params.user);
466
+ const provider = await getUserProvider(params.user, db);
396
467
  if (!provider) return { ok: false, error: "User not found", status: 404 };
397
- const [existing] = await sql<DbRow[]>`
468
+ const [existing] = await db<DbRow[]>`
398
469
  SELECT 1
399
470
  FROM auth.user_groups_v2
400
471
  WHERE user_id = ${params.user}::uuid
@@ -402,14 +473,14 @@ export const addMember = async (params: { id: string; user?: string; group?: str
402
473
  LIMIT 1
403
474
  `;
404
475
  if (existing) return { ok: false, error: "User is already a direct member of this group", status: 409 };
405
- 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`;
406
477
  return { ok: true, data: undefined };
407
478
  }
408
479
 
409
480
  if (params.group) {
410
- const isLocal = await ensureLocalGroupTreeMember(params.group);
481
+ const isLocal = await ensureLocalGroupTreeMember(params.group, db);
411
482
  if (!isLocal) return { ok: false, error: "Only local groups can be nested into local groups", status: 400 };
412
- const [existing] = await sql<DbRow[]>`
483
+ const [existing] = await db<DbRow[]>`
413
484
  SELECT 1
414
485
  FROM auth.group_groups_v2
415
486
  WHERE parent_group_id = ${group.id}::uuid
@@ -417,36 +488,68 @@ export const addMember = async (params: { id: string; user?: string; group?: str
417
488
  LIMIT 1
418
489
  `;
419
490
  if (existing) return { ok: false, error: "Group is already a direct member of this group", status: 409 };
420
- if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group })) {
491
+ if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group }, db)) {
421
492
  return { ok: false, error: "Local group nesting cannot create cycles", status: 400 };
422
493
  }
423
- 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`;
424
495
  return { ok: true, data: undefined };
425
496
  }
426
497
 
427
498
  return { ok: false, error: "Missing member", status: 400 };
428
499
  };
429
500
 
430
- export const removeMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
431
- 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);
432
511
  if (!group) return { ok: false, error: "Group not found", status: 404 };
433
512
 
513
+ if (params.serviceAccount) {
514
+ await db`DELETE FROM auth.service_account_groups
515
+ WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
516
+ return { ok: true, data: undefined };
517
+ }
518
+
434
519
  if (params.user) {
435
- 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`;
436
521
  return { ok: true, data: undefined };
437
522
  }
438
523
 
439
524
  if (params.group) {
440
- 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`;
441
526
  return { ok: true, data: undefined };
442
527
  }
443
528
 
444
529
  return { ok: false, error: "Missing member", status: 400 };
445
530
  };
446
531
 
447
- 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>> => {
448
538
  const group = await getLocalGroupById(params.id);
449
539
  if (!group) return { ok: false, error: "Group not found", status: 404 };
540
+ const [personal] = await sql`SELECT 1 FROM auth.personal_groups
541
+ WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
542
+ if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
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
+ }
450
553
 
451
554
  if (params.user) {
452
555
  const provider = await getUserProvider(params.user);
@@ -481,10 +584,21 @@ export const addManager = async (params: { id: string; user?: string; group?: st
481
584
  return { ok: false, error: "Missing manager", status: 400 };
482
585
  };
483
586
 
484
- 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>> => {
485
593
  const group = await getLocalGroupById(params.id);
486
594
  if (!group) return { ok: false, error: "Group not found", status: 404 };
487
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
+
488
602
  if (params.user) {
489
603
  await sql`DELETE FROM auth.group_manager_users_v2 WHERE group_id = ${group.id}::uuid AND user_id = ${params.user}::uuid`;
490
604
  return { ok: true, data: undefined };