@k2b/cloud 0.44.0 → 0.45.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 (73) hide show
  1. package/package.json +6 -2
  2. package/src/access/GroupCoverage.tsx +20 -5
  3. package/src/access/PermissionEditor.tsx +11 -2
  4. package/src/access/PrincipalAvatar.tsx +30 -21
  5. package/src/access/messages.ts +4 -4
  6. package/src/account/EntitySearch.tsx +12 -0
  7. package/src/ai/dictation-runtime.ts +6 -2
  8. package/src/ai/executor.ts +9 -3
  9. package/src/ai/index.ts +1 -0
  10. package/src/ai/project-tool.ts +4 -4
  11. package/src/ai/projects-authorization.ts +53 -0
  12. package/src/ai/projects-routes.ts +56 -46
  13. package/src/ai/projects.ts +100 -79
  14. package/src/ai/recent-resources.ts +3 -3
  15. package/src/ai/routes.ts +30 -30
  16. package/src/ai/skill-tool.ts +4 -4
  17. package/src/ai/skills-routes.ts +27 -26
  18. package/src/ai/skills.ts +120 -81
  19. package/src/api/accounts-entities.ts +6 -3
  20. package/src/api/admin-ai-skills.ts +4 -1
  21. package/src/api/auth.ts +9 -2
  22. package/src/browser/session.ts +39 -0
  23. package/src/browser/shared-text-history-validation.ts +151 -0
  24. package/src/browser/shared-text-history.ts +464 -0
  25. package/src/browser/shared-text-offline-authorized.ts +29 -0
  26. package/src/browser/shared-text-offline.ts +351 -0
  27. package/src/browser/shared-text-provider.ts +714 -0
  28. package/src/browser/shared-text-table-contract.ts +150 -0
  29. package/src/browser/shared-text.ts +19 -0
  30. package/src/contracts/shared.ts +2 -1
  31. package/src/server/api/respond.ts +10 -2
  32. package/src/server/index.ts +1 -0
  33. package/src/server/services/access.ts +23 -10
  34. package/src/server/services/index.ts +1 -0
  35. package/src/services/accounts/app.ts +30 -13
  36. package/src/services/accounts/entities.ts +29 -16
  37. package/src/services/accounts/group-sql.ts +23 -0
  38. package/src/services/accounts/groups.ts +16 -13
  39. package/src/services/accounts/identities.ts +8 -1
  40. package/src/services/accounts/identity-reconciliation.ts +3 -1
  41. package/src/services/accounts/local-groups.ts +69 -4
  42. package/src/services/accounts/users.ts +38 -8
  43. package/src/services/mandates/index.ts +2 -2
  44. package/src/services/mandates/policy.ts +1 -0
  45. package/src/services/session/index.ts +12 -3
  46. package/src/shared/email-html.ts +1 -0
  47. package/src/shared/markdown/extensions/katex.ts +8 -1
  48. package/src/shared/markdown/extensions/tables.ts +14 -1
  49. package/src/shared/markdown/index.ts +22 -11
  50. package/src/shared-text/anchors.ts +29 -0
  51. package/src/shared-text/awareness.ts +87 -0
  52. package/src/shared-text/changes.ts +27 -0
  53. package/src/shared-text/documents.ts +428 -77
  54. package/src/shared-text/events.ts +12 -2
  55. package/src/shared-text/index.ts +16 -1
  56. package/src/shared-text/log.ts +24 -5
  57. package/src/shared-text/snapshots.ts +69 -23
  58. package/src/shared-text/structural-history.ts +377 -0
  59. package/src/shared-text/table-actions.ts +432 -0
  60. package/src/shared-text/types.ts +24 -5
  61. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  62. package/src/ssr/CloudShell.island.tsx +19 -3
  63. package/src/ssr/Layout.tsx +1 -1
  64. package/src/ssr/MobileProfileActions.tsx +2 -2
  65. package/src/ssr/ProfilePreferences.tsx +2 -1
  66. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  67. package/src/ssr/admin-navigation.ts +1 -11
  68. package/src/ssr/app-launchpad.tsx +6 -1
  69. package/src/ssr/platform-messages.ts +2 -0
  70. package/src/ssr/profile-actions.ts +3 -6
  71. package/src/ssr/shell-data.tsx +1 -1
  72. package/src/workflows/language/expressions.ts +3 -2
  73. package/src/workflows/runtime/executor.ts +2 -1
@@ -0,0 +1,150 @@
1
+ import { encoding } from "@k2b/stdlib";
2
+ import type { TableActionIntent, TableFocusWitness } from "../shared-text/table-actions";
3
+
4
+ const { fromBase64, toBase64 } = encoding;
5
+
6
+ import * as Y from "yjs";
7
+ import { z } from "zod";
8
+
9
+ /** Approved editor limit, measured in UTF-16 code units. Rejection must preserve the existing text. */
10
+ export const TEXT_TABLE_ACTION_MAX_LENGTH = 2_097_152;
11
+
12
+ // An item relative position is one tag, two safe-integer varuints (at most
13
+ // ceil(53 / 7) bytes each), and the zero-association byte. No root-name/sentinel
14
+ // positions are accepted. The base64 bound follows this Yjs wire format.
15
+ const ITEM_ANCHOR_MAX_BYTES = 1 + 2 * Math.ceil(53 / 7) + 1;
16
+ const ITEM_ANCHOR_MAX_BASE64_LENGTH = 4 * Math.ceil(ITEM_ANCHOR_MAX_BYTES / 3);
17
+ const AnchorSchema = z
18
+ .string()
19
+ .min(1)
20
+ .max(ITEM_ANCHOR_MAX_BASE64_LENGTH)
21
+ .refine((encoded) => {
22
+ if (encoded.length > ITEM_ANCHOR_MAX_BASE64_LENGTH) return false;
23
+ try {
24
+ const bytes = fromBase64(encoded);
25
+ const position = Y.decodeRelativePosition(bytes);
26
+ return (
27
+ bytes.length <= ITEM_ANCHOR_MAX_BYTES &&
28
+ position.item !== null &&
29
+ position.assoc === 0 &&
30
+ Number.isSafeInteger(position.item.client) &&
31
+ position.item.client >= 0 &&
32
+ Number.isSafeInteger(position.item.clock) &&
33
+ position.item.clock >= 0 &&
34
+ toBase64(Y.encodeRelativePosition(position)) === encoded
35
+ );
36
+ } catch {
37
+ return false;
38
+ }
39
+ }, "Expected a canonical Yjs character anchor");
40
+
41
+ const TableSchema = z.object({ headerBreak: AnchorSchema, separatorHyphen: AnchorSchema }).strict();
42
+ const RowSchema = z.discriminatedUnion("kind", [
43
+ z.object({ kind: z.literal("header") }).strict(),
44
+ z.object({ kind: z.literal("body"), beforeBreak: AnchorSchema, inside: AnchorSchema, insideKind: z.enum(["pipe", "break"]) }).strict(),
45
+ ]);
46
+ const ColumnSchema = z.object({ hyphen: AnchorSchema, left: AnchorSchema, right: AnchorSchema }).strict();
47
+
48
+ export const TextTableFocusSchema = z.object({ table: TableSchema, row: RowSchema, column: ColumnSchema }).strict();
49
+ export type EncodedTableFocus = z.infer<typeof TextTableFocusSchema>;
50
+ export const encodeTableFocus = (focus: TableFocusWitness): EncodedTableFocus => ({
51
+ table: { headerBreak: toBase64(focus.table.headerBreak), separatorHyphen: toBase64(focus.table.separatorHyphen) },
52
+ row:
53
+ focus.row.kind === "header"
54
+ ? focus.row
55
+ : { ...focus.row, beforeBreak: toBase64(focus.row.beforeBreak), inside: toBase64(focus.row.inside) },
56
+ column: { hyphen: toBase64(focus.column.hyphen), left: toBase64(focus.column.left), right: toBase64(focus.column.right) },
57
+ });
58
+ export const decodeTableFocus = (focus: EncodedTableFocus): TableFocusWitness => ({
59
+ table: { headerBreak: fromBase64(focus.table.headerBreak), separatorHyphen: fromBase64(focus.table.separatorHyphen) },
60
+ row:
61
+ focus.row.kind === "header"
62
+ ? focus.row
63
+ : { ...focus.row, beforeBreak: fromBase64(focus.row.beforeBreak), inside: fromBase64(focus.row.inside) },
64
+ column: { hyphen: fromBase64(focus.column.hyphen), left: fromBase64(focus.column.left), right: fromBase64(focus.column.right) },
65
+ });
66
+
67
+ export const EncodedTableActionIntentSchema = z.discriminatedUnion("action", [
68
+ z.object({ action: z.enum(["moveRowUp", "moveRowDown"]), table: TableSchema, row: RowSchema, neighbor: RowSchema }).strict(),
69
+ z.object({ action: z.enum(["moveColLeft", "moveColRight"]), table: TableSchema, column: ColumnSchema, neighbor: ColumnSchema }).strict(),
70
+ z.object({ action: z.enum(["undo", "redo"]), receiptCursor: z.string().min(1).max(256) }).strict(),
71
+ z
72
+ .object({
73
+ action: z.literal("replaceSource"),
74
+ table: TableSchema,
75
+ expected: z.string().max(TEXT_TABLE_ACTION_MAX_LENGTH),
76
+ source: z.string().max(TEXT_TABLE_ACTION_MAX_LENGTH),
77
+ })
78
+ .strict(),
79
+ z.object({ action: z.enum(["completeColumns", "toggleHeader", "deleteTable"]), table: TableSchema }).strict(),
80
+ z.object({ action: z.enum(["rowAbove", "rowBelow", "deleteRow"]), table: TableSchema, row: RowSchema }).strict(),
81
+ z
82
+ .object({
83
+ action: z.enum(["colLeft", "colRight", "deleteCol", "alignLeft", "alignCenter", "alignRight"]),
84
+ table: TableSchema,
85
+ column: ColumnSchema,
86
+ })
87
+ .strict(),
88
+ ]);
89
+
90
+ export const TextTableActionRequestSchema = z
91
+ .object({
92
+ // The application stores this token in a nonnegative Postgres bigint.
93
+ restoreRevision: z
94
+ .string()
95
+ .min(1)
96
+ .max(19)
97
+ .regex(/^(0|[1-9]\d*)$/)
98
+ .refine((value) => value.length <= 19 && /^(0|[1-9]\d*)$/.test(value) && BigInt(value) <= 9_223_372_036_854_775_807n),
99
+ actionId: z.uuid(),
100
+ intent: EncodedTableActionIntentSchema,
101
+ focus: TextTableFocusSchema.optional(),
102
+ caret: z
103
+ .object({
104
+ row: z.number().int().nonnegative().max(TEXT_TABLE_ACTION_MAX_LENGTH),
105
+ column: z.number().int().nonnegative().max(TEXT_TABLE_ACTION_MAX_LENGTH),
106
+ })
107
+ .strict()
108
+ .optional(),
109
+ })
110
+ .strict();
111
+
112
+ export type EncodedTableActionIntent = z.infer<typeof EncodedTableActionIntentSchema>;
113
+ export type TextTableActionRequest = z.infer<typeof TextTableActionRequestSchema>;
114
+
115
+ /** Decode only schema-validated transport; planning still validates identity and current context. */
116
+ export type TextTableIntent = TableActionIntent | { action: "undo" | "redo"; receiptCursor: string };
117
+ export const decodeTableActionIntent = (intent: EncodedTableActionIntent): TextTableIntent => {
118
+ if ("receiptCursor" in intent) return intent;
119
+ const table = { headerBreak: fromBase64(intent.table.headerBreak), separatorHyphen: fromBase64(intent.table.separatorHyphen) };
120
+ if ("source" in intent) return { action: intent.action, table, expected: intent.expected, source: intent.source };
121
+ if (!("row" in intent) && !("column" in intent)) return { action: intent.action, table };
122
+ const decodeRow = (row: z.infer<typeof RowSchema>) =>
123
+ row.kind === "header"
124
+ ? row
125
+ : { kind: "body" as const, beforeBreak: fromBase64(row.beforeBreak), inside: fromBase64(row.inside), insideKind: row.insideKind };
126
+ const decodeColumn = (column: z.infer<typeof ColumnSchema>) => ({
127
+ hyphen: fromBase64(column.hyphen),
128
+ left: fromBase64(column.left),
129
+ right: fromBase64(column.right),
130
+ });
131
+ if ("row" in intent) {
132
+ const row = decodeRow(intent.row);
133
+ return "neighbor" in intent
134
+ ? { action: intent.action, table, row, neighbor: decodeRow(intent.neighbor) }
135
+ : { action: intent.action, table, row };
136
+ }
137
+ const column = decodeColumn(intent.column);
138
+ return "neighbor" in intent
139
+ ? { action: intent.action, table, column, neighbor: decodeColumn(intent.neighbor) }
140
+ : { action: intent.action, table, column };
141
+ };
142
+
143
+ export const TextTableActionReceiptSchema = z.object({ actionId: z.uuid(), update: z.string() });
144
+ export const TextTableActionResponseSchema = z.object({
145
+ changed: z.boolean(),
146
+ focus: TextTableFocusSchema.nullable().optional(),
147
+ cursor: z.string().nullable(),
148
+ receipt: TextTableActionReceiptSchema.nullable(),
149
+ });
150
+ export type TextTableActionResponse = z.infer<typeof TextTableActionResponseSchema>;
@@ -0,0 +1,19 @@
1
+ /** Browser-safe identity helpers; no server log, storage, or process state. */
2
+ export { captureTextAnchor, resolveTextAnchor } from "../shared-text/anchors";
3
+ export {
4
+ captureTableAction,
5
+ captureTableFocus,
6
+ captureTableSource,
7
+ prepareTableActionFocus,
8
+ resolveTableFocus,
9
+ type TableActionIntent,
10
+ TableActionLimitError,
11
+ TableActionRejectedError,
12
+ type TableFocusWitness,
13
+ } from "../shared-text/table-actions";
14
+ export { createSharedTextActionHistory, type SharedTextInverseRequest } from "./shared-text-history";
15
+ export * from "./shared-text-offline";
16
+ export { openAuthorizedSharedTextOffline } from "./shared-text-offline-authorized";
17
+ export * from "./shared-text-provider";
18
+
19
+ export * from "./shared-text-table-contract";
@@ -146,9 +146,10 @@ export const EntityListItemSchema = z.discriminatedUnion("kind", [
146
146
  export type EntityListItem = z.infer<typeof EntityListItemSchema>;
147
147
 
148
148
  export const GroupMemberSchema = z.object({
149
- type: z.enum(["user", "group"]),
149
+ type: z.enum(["user", "group", "service_account"]),
150
150
  id: z.string(),
151
151
  displayName: z.string().nullable(),
152
+ serviceAccountKind: ServiceAccountKindSchema.optional(),
152
153
  });
153
154
  export type GroupMember = z.infer<typeof GroupMemberSchema>;
154
155
 
@@ -43,6 +43,12 @@ const toErrorResponse = (result: AnyResult): [ApiErrorBody, number] => {
43
43
  return [{ message: "Internal server error", code: "INTERNAL" }, 500];
44
44
  };
45
45
 
46
+ /** Add explicitly selected transport recovery fields without changing the error envelope. */
47
+ export async function respond<D extends Record<string, unknown>, S extends LegacyErrorStatus>(
48
+ c: Context,
49
+ resultOrFn: ResultOrFn<LegacyErrorResult<S>>,
50
+ options: { errorDetails: D },
51
+ ): Promise<JsonTypedResponse<Omit<D, keyof ApiErrorBody> & ApiErrorBody, S>>;
46
52
  export async function respond<T>(
47
53
  c: Context,
48
54
  resultOrFn: ResultOrFn<AnyResult<T>>,
@@ -76,15 +82,17 @@ export async function respond<T>(
76
82
  export async function respond<T>(
77
83
  c: Context,
78
84
  resultOrFn: ResultOrFn<AnyResult<T>>,
79
- successStatus: SuccessStatus | 202 = 200,
85
+ statusOrOptions: SuccessStatus | 202 | { errorDetails: Record<string, unknown> } = 200,
80
86
  ): Promise<JsonTypedResponse<T, SuccessStatus | 202> | ApiErrorResponse> {
81
87
  const result = typeof resultOrFn === "function" ? await resultOrFn() : await resultOrFn;
82
88
 
83
89
  if (!result.ok) {
84
90
  const [body, status] = toErrorResponse(result);
85
- return c.json(body, status as ApiErrorStatus) as ApiErrorResponse;
91
+ const details = typeof statusOrOptions === "object" ? statusOrOptions.errorDetails : {};
92
+ return c.json({ ...details, ...body, code: body.code }, status as ApiErrorStatus) as ApiErrorResponse;
86
93
  }
87
94
 
95
+ const successStatus = typeof statusOrOptions === "number" ? statusOrOptions : 200;
88
96
  return c.json(result.data, successStatus) as JsonTypedResponse<T, SuccessStatus | 202>;
89
97
  }
90
98
 
@@ -89,6 +89,7 @@ export {
89
89
  paginate,
90
90
  paginateItems,
91
91
  password,
92
+ recursiveServiceAccountGroupIdsSubquery,
92
93
  resolveDisplayNames,
93
94
  services,
94
95
  tryCatch,
@@ -2,7 +2,7 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
2
2
  import { type SQLQuery, sql } from "bun";
3
3
  import { type ManagerCandidate, removesLastManager } from "../../access/managers";
4
4
  import { accessMessages } from "../../access/messages";
5
- import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
5
+ import { recursiveGroupIdsSubquery, recursiveServiceAccountGroupIdsSubquery } from "../../services/accounts/group-sql";
6
6
  import { toPgUuidArray } from "../../services/postgres";
7
7
  import type { ServiceAccountKind } from "../../services/service-accounts";
8
8
  import type { RequestAuthority } from "../middleware/auth";
@@ -266,19 +266,26 @@ export const ensureManagerRemains = (params: {
266
266
  return fail({ code: "LAST_MANAGER", message: t.lastManager({ level: params.level ?? t.manage }), status: 409 });
267
267
  };
268
268
 
269
- /** Resolve direct and nested group memberships from the authoritative database mirror. */
270
- export const getEffectiveGroups = async (params: { userId: string | null }, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
271
- if (!params.userId) return [];
269
+ type EffectiveGroupParams = { userId: string | null; serviceAccountId?: never } | { serviceAccountId: string | null; userId?: never };
270
+
271
+ /** Resolve authoritative memberships; machine recipients inherit local groups only. */
272
+ export const getEffectiveGroups = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<EffectiveGroup[]> => {
273
+ const groupIds = params.userId
274
+ ? recursiveGroupIdsSubquery(params.userId)
275
+ : params.serviceAccountId
276
+ ? recursiveServiceAccountGroupIdsSubquery(params.serviceAccountId)
277
+ : null;
278
+ if (!groupIds) return [];
272
279
  const rows = await db<{ id: string; name: string }[]>`
273
280
  SELECT g.id, g.name
274
281
  FROM auth.groups g
275
- WHERE g.id IN (${recursiveGroupIdsSubquery(params.userId)})
282
+ WHERE g.id IN (${groupIds})
276
283
  ORDER BY g.name
277
284
  `;
278
285
  return rows;
279
286
  };
280
287
 
281
- export const getEffectiveGroupIds = async (params: { userId: string | null }, db: AccessDb = sql): Promise<string[]> =>
288
+ export const getEffectiveGroupIds = async (params: EffectiveGroupParams, db: AccessDb = sql): Promise<string[]> =>
282
289
  (await getEffectiveGroups(params, db)).map((group) => group.id);
283
290
 
284
291
  /**
@@ -329,7 +336,7 @@ export type ResourceAccessAdapter<TResourceId = string> = {
329
336
  *
330
337
  * Apps provide column fragments because access rows are commonly joined under
331
338
  * different aliases. The subject is authoritative: group membership is always
332
- * resolved recursively from its user id and never accepted from a caller.
339
+ * resolved recursively from its user or service-account id and never accepted from a caller.
333
340
  * Resource binding, credential scopes, and permission precedence remain the
334
341
  * responsibility of the app using the predicate.
335
342
  */
@@ -346,8 +353,13 @@ export const buildAccessPrincipalTierConditions = (params: {
346
353
  serviceAccount: sql`${columns.serviceAccountId} = ${serviceAccountId}::uuid`,
347
354
  user: sql`${columns.userId} = ${userId}::uuid`,
348
355
  group: sql`(
349
- ${userId}::uuid IS NOT NULL
350
- AND ${columns.groupId} IN (${userId ? recursiveGroupIdsSubquery(userId) : sql`SELECT NULL::uuid WHERE FALSE`})
356
+ ${columns.groupId} IN (${
357
+ userId
358
+ ? recursiveGroupIdsSubquery(userId)
359
+ : serviceAccountId
360
+ ? recursiveServiceAccountGroupIdsSubquery(serviceAccountId)
361
+ : sql`SELECT NULL::uuid WHERE FALSE`
362
+ })
351
363
  )`,
352
364
  authenticated: sql`${authenticated} AND ${columns.authenticatedOnly} = true`,
353
365
  public: sql`(
@@ -369,7 +381,7 @@ export const buildAccessPrincipalCondition = (params: { subject: AccessSubject |
369
381
  * Get the effective permission level for an actor on a resource.
370
382
  * Returns the highest permission from:
371
383
  * - Direct user or service-account access
372
- * - Direct and nested user group memberships resolved from the database
384
+ * - Direct and nested group memberships for users and active standalone/agent accounts
373
385
  * - Authenticated access for every authenticated subject
374
386
  * - Public access for every subject, including anonymous callers
375
387
  *
@@ -465,6 +477,7 @@ export const getEffectivePermissions = async (params: {
465
477
  a.service_account_id = s.service_account_id
466
478
  OR a.user_id = s.user_id
467
479
  OR (s.user_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(sql`s.user_id`)}))
480
+ OR (s.service_account_id IS NOT NULL AND a.group_id IN (${recursiveServiceAccountGroupIdsSubquery(sql`s.service_account_id`)}))
468
481
  OR a.authenticated_only = true
469
482
  OR (a.user_id IS NULL AND a.group_id IS NULL AND a.service_account_id IS NULL AND a.authenticated_only = false)
470
483
  )
@@ -1,4 +1,5 @@
1
1
  // Cloud-specific server services
2
+ export { recursiveServiceAccountGroupIdsSubquery } from "../../services/accounts/group-sql";
2
3
 
3
4
  export type {
4
5
  AccessDb,
@@ -415,7 +415,6 @@ export const accountsAppService = {
415
415
  const userGroups = await users.getGroups({
416
416
  id: config.userId,
417
417
  recursive: config.recursive,
418
- includePersonal: false,
419
418
  });
420
419
  const query = config.filter?.query?.trim().toLowerCase();
421
420
  const filtered = query ? userGroups.filter((groupName) => groupName.toLowerCase().includes(query)) : userGroups;
@@ -970,7 +969,7 @@ export const accountsAppService = {
970
969
  group: {
971
970
  list: async (config: {
972
971
  pagination?: PageParams;
973
- filter?: { search?: string; includePersonal?: boolean };
972
+ filter?: { search?: string };
974
973
  scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
975
974
  }): Promise<Paginated<BaseGroup>> => {
976
975
  const { page, perPage } = paginate(config.pagination);
@@ -982,7 +981,6 @@ export const accountsAppService = {
982
981
  scope: config.scope?.mode,
983
982
  ids: config.scope?.ids,
984
983
  provider: config.scope?.provider,
985
- includePersonal: config.filter?.includePersonal,
986
984
  });
987
985
 
988
986
  return {
@@ -993,7 +991,10 @@ export const accountsAppService = {
993
991
  hasNext: result.pagination.hasNext,
994
992
  };
995
993
  },
996
- get: async (config: { id: string }) => groups.get(config),
994
+ get: async (config: { id: string }) => {
995
+ const result = await groups.list({ ids: [config.id], perPage: 1 });
996
+ return result.groups[0] ?? null;
997
+ },
997
998
  member: {
998
999
  list: async (config: {
999
1000
  id: string;
@@ -1016,7 +1017,14 @@ export const accountsAppService = {
1016
1017
  });
1017
1018
  return paginateItems(filtered, config.pagination);
1018
1019
  },
1019
- add: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1020
+ add: async (config: {
1021
+ actor: AccountsActor;
1022
+ id: string;
1023
+ provider?: UserProvider;
1024
+ userId?: string;
1025
+ groupId?: string;
1026
+ serviceAccountId?: string;
1027
+ }) => {
1020
1028
  const group = await groups.get({ id: config.id });
1021
1029
  if (!group) {
1022
1030
  const result = fail(err.notFound("Group not found"));
@@ -1035,17 +1043,25 @@ export const accountsAppService = {
1035
1043
  provider: config.provider,
1036
1044
  user: config.userId,
1037
1045
  group: config.groupId,
1046
+ serviceAccount: config.serviceAccountId,
1038
1047
  }),
1039
1048
  );
1040
1049
  return recordCompletedMutation({
1041
1050
  action: "accounts.group.member.add",
1042
1051
  actor: auditActor(config.actor),
1043
1052
  target: groupTarget(group),
1044
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1053
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1045
1054
  result,
1046
1055
  });
1047
1056
  },
1048
- remove: async (config: { actor: AccountsActor; id: string; provider?: UserProvider; userId?: string; groupId?: string }) => {
1057
+ remove: async (config: {
1058
+ actor: AccountsActor;
1059
+ id: string;
1060
+ provider?: UserProvider;
1061
+ userId?: string;
1062
+ groupId?: string;
1063
+ serviceAccountId?: string;
1064
+ }) => {
1049
1065
  const group = await groups.get({ id: config.id });
1050
1066
  if (!group) {
1051
1067
  const result = fail(err.notFound("Group not found"));
@@ -1064,13 +1080,14 @@ export const accountsAppService = {
1064
1080
  provider: config.provider,
1065
1081
  user: config.userId,
1066
1082
  group: config.groupId,
1083
+ serviceAccount: config.serviceAccountId,
1067
1084
  }),
1068
1085
  );
1069
1086
  return recordCompletedMutation({
1070
1087
  action: "accounts.group.member.remove",
1071
1088
  actor: auditActor(config.actor),
1072
1089
  target: groupTarget(group),
1073
- metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null },
1090
+ metadata: { userId: config.userId ?? null, groupId: config.groupId ?? null, serviceAccountId: config.serviceAccountId ?? null },
1074
1091
  result,
1075
1092
  });
1076
1093
  },
@@ -1255,6 +1272,7 @@ export const accountsAppService = {
1255
1272
  profile?: UserProfile;
1256
1273
  userIds?: string[];
1257
1274
  groupIds?: string[];
1275
+ serviceAccountIds?: string[];
1258
1276
  serviceAccountKinds?: ServiceAccountKind[];
1259
1277
  serviceAccountStatus?: ServiceAccountStatus;
1260
1278
  excludeUserIds?: string[];
@@ -1266,7 +1284,6 @@ export const accountsAppService = {
1266
1284
  parentGroupId?: string;
1267
1285
  managedByUserId?: string;
1268
1286
  recursive?: boolean;
1269
- includePersonal?: boolean;
1270
1287
  }): Promise<Paginated<EntityListItem>> => {
1271
1288
  const canSearchDirectory = config.actor.roles.includes("user");
1272
1289
  const usesRelationFilter = Boolean(
@@ -1288,6 +1305,7 @@ export const accountsAppService = {
1288
1305
  profile: config.profile,
1289
1306
  userIds: config.userIds,
1290
1307
  groupIds: config.groupIds,
1308
+ serviceAccountIds: config.serviceAccountIds,
1291
1309
  serviceAccountKinds: config.serviceAccountKinds,
1292
1310
  serviceAccountStatus: config.serviceAccountStatus,
1293
1311
  excludeUserIds: config.excludeUserIds,
@@ -1299,7 +1317,6 @@ export const accountsAppService = {
1299
1317
  parentGroupId: config.parentGroupId,
1300
1318
  managedByUserId: config.managedByUserId,
1301
1319
  recursive: config.recursive,
1302
- includePersonal: config.includePersonal,
1303
1320
  page,
1304
1321
  perPage,
1305
1322
  });
@@ -1661,9 +1678,9 @@ export const accountsAppService = {
1661
1678
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local') AS local_accounts_total,
1662
1679
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'user') AS local_user_accounts_total,
1663
1680
  (SELECT COUNT(*)::int FROM auth.users WHERE provider = 'local' AND profile = 'guest') AS local_guest_accounts_total,
1664
- (SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS groups_total,
1665
- (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS ipa_groups_total,
1666
- (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id)) AS local_groups_total,
1681
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS groups_total,
1682
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'ipa' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS ipa_groups_total,
1683
+ (SELECT COUNT(*)::int FROM auth.groups g WHERE provider = 'local' AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)) AS local_groups_total,
1667
1684
  (SELECT COUNT(*)::int FROM auth.account_requests WHERE status = 'pending') AS open_requests,
1668
1685
  (
1669
1686
  SELECT COUNT(*)::int
@@ -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,30 @@ export const list = async (
491
484
  AND ug.group_id = ANY(${toPgUuidArray(params.userMemberOfGroupIds ?? [])}::uuid[])
492
485
  ))`;
493
486
 
487
+ // Machine recipients have local membership, but no directory management/parent relation.
488
+ const otherRelation = Boolean(params.managerOfGroupId || 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
+ : sql`TRUE`;
501
+ const machineDirect = params.memberOfGroupId
502
+ ? sql`EXISTS (SELECT 1 FROM auth.service_account_groups sg
503
+ WHERE sg.service_account_id = sa.id AND sg.group_id = ${params.memberOfGroupId}::uuid)`
504
+ : sql`NULL::boolean`;
494
505
  const where = sql`
495
506
  ${visibilityCondition}
496
507
  AND ${kindsCondition}
497
508
  AND ${includeIdCondition}
498
- AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null})
509
+ AND (${params.provider ?? null}::text IS NULL OR provider = ${params.provider ?? null}
510
+ OR (kind = 'service_account' AND ${params.provider ?? null}::text = 'local'))
499
511
  AND (${params.profile ?? null}::text IS NULL OR kind = 'group' OR profile = ${params.profile ?? null})
500
512
  AND ${excludeUserCondition}
501
513
  AND ${excludeGroupCondition}
@@ -612,7 +624,7 @@ export const list = async (
612
624
  service_account_rows AS (
613
625
  SELECT
614
626
  'service_account'::text AS kind,
615
- NULL::boolean AS direct,
627
+ ${machineDirect} AS direct,
616
628
  sa.id,
617
629
  NULL::text AS provider,
618
630
  NULL::text AS profile,
@@ -644,6 +656,7 @@ export const list = async (
644
656
  NULL::text AS personal_owner_display_name,
645
657
  LOWER(sa.name) AS sort_label
646
658
  FROM auth.service_accounts sa
659
+ WHERE NOT ${otherRelation} AND ${machineRelation}
647
660
  ),
648
661
  entity_rows AS (
649
662
  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
+ `;