@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
@@ -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({
@@ -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
 
@@ -2,6 +2,7 @@ import { sql } 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
 
@@ -122,6 +123,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
122
123
  SELECT COUNT(*)::int AS count
123
124
  FROM auth.groups g
124
125
  WHERE g.provider = 'local'
126
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
125
127
  AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
126
128
  `;
127
129
  const total = Number(countRow?.count ?? 0);
@@ -130,6 +132,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
130
132
  FROM auth.groups g
131
133
  ${personalOwnerJoin()}
132
134
  WHERE g.provider = 'local'
135
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
133
136
  AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
134
137
  ORDER BY g.name
135
138
  LIMIT ${perPage} OFFSET ${offset}
@@ -177,7 +180,7 @@ export const remove = async (params: { id: string }): Promise<MutationResult<voi
177
180
  return { ok: true, data: undefined };
178
181
  };
179
182
 
180
- export const getMembers = async (params: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
183
+ export const getMembers = async (params: { id: string; type?: GroupMember["type"]; recursive?: boolean }): Promise<GroupMember[]> => {
181
184
  const group = await getLocalGroupById(params.id);
182
185
  if (!group) return [];
183
186
  const members: GroupMember[] = [];
@@ -231,6 +234,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
231
234
  SELECT DISTINCT g.id, g.name
232
235
  FROM local_group_tree tree
233
236
  JOIN auth.groups g ON g.id = tree.child_group_id
237
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
234
238
  ORDER BY g.name
235
239
  `
236
240
  : await sql<DbRow[]>`
@@ -238,6 +242,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
238
242
  FROM auth.group_groups_v2 gg
239
243
  JOIN auth.groups g ON g.id = gg.child_group_id
240
244
  WHERE gg.parent_group_id = ${group.id} AND g.provider = 'local'
245
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
241
246
  ORDER BY g.name
242
247
  `;
243
248
  for (const row of groupRows) {
@@ -245,6 +250,25 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
245
250
  }
246
251
  }
247
252
 
253
+ if (!params.type || params.type === "service_account") {
254
+ const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
255
+ SELECT sa.id, sa.name, sa.kind
256
+ FROM auth.service_accounts sa
257
+ WHERE sa.kind IN ('standalone', 'agent') AND ${
258
+ params.recursive === true
259
+ ? sql`${group.id}::uuid IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
260
+ : sql`EXISTS (
261
+ SELECT 1 FROM auth.service_account_groups sg
262
+ WHERE sg.service_account_id = sa.id AND sg.group_id = ${group.id}::uuid
263
+ )`
264
+ }
265
+ ORDER BY sa.name, sa.id
266
+ `;
267
+ members.push(
268
+ ...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
269
+ );
270
+ }
271
+
248
272
  return members;
249
273
  };
250
274
 
@@ -310,6 +334,7 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
310
334
  FROM auth.group_manager_groups_v2 gmg
311
335
  JOIN auth.groups g ON g.id = gmg.manager_group_id
312
336
  WHERE gmg.group_id = ${group.id} AND g.provider = 'local'
337
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
313
338
  ORDER BY g.name
314
339
  `;
315
340
  for (const row of groupRows) {
@@ -340,7 +365,8 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
340
365
  WHERE g.provider = 'local'
341
366
  )
342
367
  SELECT DISTINCT parent_group_id
343
- FROM local_parent_tree
368
+ FROM local_parent_tree tree
369
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = tree.parent_group_id)
344
370
  `
345
371
  : await sql<DbRow[]>`
346
372
  SELECT gg.parent_group_id
@@ -348,6 +374,7 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
348
374
  JOIN auth.groups g ON g.id = gg.parent_group_id
349
375
  WHERE gg.child_group_id = ${group.id}
350
376
  AND g.provider = 'local'
377
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
351
378
  ORDER BY g.name
352
379
  `;
353
380
 
@@ -374,6 +401,7 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
374
401
  JOIN auth.group_manager_groups_v2 gmg ON gmg.manager_group_id = tree.manager_group_id
375
402
  JOIN auth.groups g ON g.id = gmg.group_id
376
403
  WHERE g.provider = 'local'
404
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
377
405
  `
378
406
  : await sql<DbRow[]>`
379
407
  SELECT gmg.group_id
@@ -381,15 +409,38 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
381
409
  JOIN auth.groups g ON g.id = gmg.group_id
382
410
  WHERE gmg.manager_group_id = ${group.id}
383
411
  AND g.provider = 'local'
412
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
384
413
  ORDER BY g.name
385
414
  `;
386
415
 
387
416
  return rows.map((row) => row.group_id as string);
388
417
  };
389
418
 
390
- export const addMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
419
+ export const addMember = async (params: {
420
+ id: string;
421
+ user?: string;
422
+ group?: string;
423
+ serviceAccount?: string;
424
+ }): Promise<MutationResult<void>> => {
391
425
  const group = await getLocalGroupById(params.id);
392
426
  if (!group) return { ok: false, error: "Group not found", status: 404 };
427
+ const [personal] = await sql`SELECT 1 FROM auth.personal_groups
428
+ WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
429
+ if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
430
+
431
+ if (params.serviceAccount) {
432
+ const [account] = await sql<{ id: string }[]>`
433
+ SELECT id FROM auth.service_accounts
434
+ WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
435
+ `;
436
+ if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
437
+ const inserted = await sql`
438
+ INSERT INTO auth.service_account_groups (service_account_id, group_id)
439
+ VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
440
+ `;
441
+ if (inserted.count === 0) return { ok: false, error: "Service account is already a direct member of this group", status: 409 };
442
+ return { ok: true, data: undefined };
443
+ }
393
444
 
394
445
  if (params.user) {
395
446
  const provider = await getUserProvider(params.user);
@@ -427,10 +478,21 @@ export const addMember = async (params: { id: string; user?: string; group?: str
427
478
  return { ok: false, error: "Missing member", status: 400 };
428
479
  };
429
480
 
430
- export const removeMember = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
481
+ export const removeMember = async (params: {
482
+ id: string;
483
+ user?: string;
484
+ group?: string;
485
+ serviceAccount?: string;
486
+ }): Promise<MutationResult<void>> => {
431
487
  const group = await getLocalGroupById(params.id);
432
488
  if (!group) return { ok: false, error: "Group not found", status: 404 };
433
489
 
490
+ if (params.serviceAccount) {
491
+ await sql`DELETE FROM auth.service_account_groups
492
+ WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
493
+ return { ok: true, data: undefined };
494
+ }
495
+
434
496
  if (params.user) {
435
497
  await sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
436
498
  return { ok: true, data: undefined };
@@ -447,6 +509,9 @@ export const removeMember = async (params: { id: string; user?: string; group?:
447
509
  export const addManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
448
510
  const group = await getLocalGroupById(params.id);
449
511
  if (!group) return { ok: false, error: "Group not found", status: 404 };
512
+ const [personal] = await sql`SELECT 1 FROM auth.personal_groups
513
+ WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
514
+ if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
450
515
 
451
516
  if (params.user) {
452
517
  const provider = await getUserProvider(params.user);
@@ -1,5 +1,14 @@
1
1
  import { type SQL, sql } from "bun";
2
- import type { BaseUser, MutationResult, PaginationResponse, Role, User, UserProfile, UserProvider } from "../../contracts/shared";
2
+ import type {
3
+ BaseUser,
4
+ MutationResult,
5
+ PaginationResponse,
6
+ RequestActor,
7
+ Role,
8
+ User,
9
+ UserProfile,
10
+ UserProvider,
11
+ } from "../../contracts/shared";
3
12
  import { freeipa } from "../../server/services";
4
13
  import { createAuthLoginUrl } from "../../shared/redirect";
5
14
  import { isAccountCategoryAllowed } from "../account-category-policy";
@@ -225,9 +234,10 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
225
234
  };
226
235
 
227
236
  /** Audience roles for a resolved access subject, including delegated users and group managers. */
228
- export const getRoles = async (userId: string): Promise<Role[]> => {
229
- const { groupsAdmin } = await getFreeIpaConfig();
230
- const [row] = await sql<DbRow[]>`SELECT u.provider, u.profile,
237
+ export const getRoles = async (userId: string, options?: { db: typeof sql }): Promise<Role[]> => {
238
+ const db = options?.db ?? sql;
239
+ const groupsAdmin = options ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
240
+ const [row] = await db<DbRow[]>`SELECT u.provider, u.profile,
231
241
  CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
232
242
  SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
233
243
  AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
@@ -239,6 +249,26 @@ export const getRoles = async (userId: string): Promise<Role[]> => {
239
249
  return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
240
250
  };
241
251
 
252
+ /**
253
+ * Revalidate installation authority and retain it until the caller commits.
254
+ * Lock users before grant/membership or mandate/profile locks, matching user
255
+ * deletion and provider sync. Projection and settings locks also cover rows
256
+ * inserted or replaced while lock acquisition was waiting.
257
+ */
258
+ export const hasInstallationAdminInTransaction = async (actor: RequestActor, db: typeof sql): Promise<boolean> => {
259
+ if (actor.kind !== "user" || actor.sessionKind === "app" || !actor.user.roles.includes("admin")) return false;
260
+ const [user] = await db<{ provider: UserProvider; profile: UserProfile; expired: boolean }[]>`
261
+ SELECT provider, profile, (account_expires IS NOT NULL AND account_expires <= statement_timestamp()) AS expired
262
+ FROM auth.users WHERE id=${actor.user.id}::uuid FOR SHARE`;
263
+ if (!user || user.expired || user.profile !== "user") return false;
264
+ await db`LOCK TABLE settings.entries IN SHARE MODE`;
265
+ if (!(await isAccountCategoryAllowed(user, db))) return false;
266
+ if (user.provider === "ipa") {
267
+ await db`LOCK TABLE auth.ipa_user_effective_groups IN SHARE MODE`;
268
+ }
269
+ return (await getRoles(actor.user.id, { db })).includes("admin");
270
+ };
271
+
242
272
  export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
243
273
  const { groupsAdmin } = await getFreeIpaConfig();
244
274
  const rows = await sql<DbRow[]>`
@@ -347,15 +377,15 @@ export const list = async (params: {
347
377
  };
348
378
  };
349
379
 
350
- /** Technical membership reads stay complete; human-facing lists exclude personal groups explicitly. */
351
- export const getGroups = async (params: { id: string; recursive?: boolean; includePersonal?: boolean }): Promise<string[]> => {
380
+ /** Exported group names exclude personal groups; authorization uses the complete group ID projection. */
381
+ export const getGroups = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
352
382
  if (params.recursive) {
353
383
  const rows = await sql<DbRow[]>`${recursiveUserGroupsSubquery({
354
384
  userId: params.id,
355
385
  select: sql`
356
386
  SELECT DISTINCT g.name FROM user_all_groups ag
357
387
  JOIN auth.groups g ON g.id = ag.group_id
358
- WHERE (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
388
+ WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
359
389
  ORDER BY g.name
360
390
  `,
361
391
  })}`;
@@ -364,7 +394,7 @@ export const getGroups = async (params: { id: string; recursive?: boolean; inclu
364
394
  const rows = await sql<DbRow[]>`
365
395
  SELECT g.name FROM auth.groups g
366
396
  WHERE g.id IN (SELECT group_id FROM auth.user_groups_v2 WHERE user_id = ${params.id}::uuid)
367
- AND (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
397
+ AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
368
398
  ORDER BY g.name
369
399
  `;
370
400
  return rows.map((row) => row.name as string);
@@ -207,7 +207,7 @@ const validSubject = async (subject: MandateSubject, db: SQL): Promise<boolean>
207
207
  SELECT EXISTS (
208
208
  SELECT 1 FROM auth.service_accounts
209
209
  WHERE id = ${subject.id}::uuid
210
- AND kind = 'resource_bound'
210
+ AND kind IN ('resource_bound', 'standalone', 'agent')
211
211
  AND status = 'active'
212
212
  ) AS active
213
213
  `;
@@ -687,7 +687,7 @@ export const validateMandateIssueAuthority = async (
687
687
  WHEN 'user' THEN user_account.id IS NOT NULL
688
688
  AND (user_account.account_expires IS NULL OR user_account.account_expires > now())
689
689
  WHEN 'service_account' THEN service_account.id IS NOT NULL
690
- AND service_account.kind = 'resource_bound'
690
+ AND service_account.kind IN ('resource_bound', 'standalone', 'agent')
691
691
  AND service_account.status = 'active'
692
692
  ELSE false
693
693
  END AS subject_active
@@ -14,6 +14,7 @@ const operationSchema = z.union([
14
14
  z.literal("search.query"),
15
15
  z.literal("runtime.http"),
16
16
  z.literal("runtime.database"),
17
+ z.literal("workflow.run"),
17
18
  z.string().refine((value) => {
18
19
  const [prefix, localId, extra] = value.split(":");
19
20
  return (
@@ -648,11 +648,20 @@ export const session = {
648
648
 
649
649
  revoke: revokeToken,
650
650
 
651
- /** Signs out the web session (or an explicit bearer session). The mobile app's session is unaffected. */
652
- delete: async (c: Context): Promise<void> => {
651
+ /** Signs out the web session (or an explicit bearer). False when an expected account no longer owns the request's credential. */
652
+ delete: async (c: Context, expectedUserId?: string | null): Promise<boolean> => {
653
+ // Capture once: another tab may replace the browser cookie while this
654
+ // request runs, but both the comparison and revocation concern this token.
653
655
  const token = getWebOrBearerToken(c);
656
+ if (token && expectedUserId !== undefined) {
657
+ const claims = await verifySessionToken(token);
658
+ if (claims && claims.sub !== expectedUserId) return false;
659
+ }
654
660
  if (token) await revokeToken(token, c.req.header("x-request-id"));
655
- deleteCookie(c, "session_token", { path: "/" });
661
+ // Bound browser logout revokes the captured family without mutating the
662
+ // shared cookie: its response may arrive after another tab signs in.
663
+ if (expectedUserId === undefined) deleteCookie(c, "session_token", { path: "/" });
664
+ return true;
656
665
  },
657
666
 
658
667
  issueInTransaction,
@@ -6,6 +6,7 @@ export const EMAIL_HTML_TAGS = [
6
6
  "br",
7
7
  "code",
8
8
  "div",
9
+ "del",
9
10
  "em",
10
11
  "h1",
11
12
  "h2",
@@ -8,6 +8,7 @@
8
8
 
9
9
  import katex from "katex";
10
10
  import type { MarkedExtension, Tokens } from "marked";
11
+ import { escapeHtml } from "../shared";
11
12
 
12
13
  /**
13
14
  * Token shape produced by the math tokenizers below. The marked extension API
@@ -40,7 +41,7 @@ function renderInlineMath(latex: string): string {
40
41
  }
41
42
  }
42
43
 
43
- export function katexExtension(): MarkedExtension {
44
+ export function katexExtension(literal = false): MarkedExtension {
44
45
  return {
45
46
  extensions: [
46
47
  // ```math code blocks
@@ -63,6 +64,10 @@ export function katexExtension(): MarkedExtension {
63
64
  return undefined;
64
65
  },
65
66
  renderer(token: Tokens.Generic) {
67
+ if (literal) {
68
+ const source = token.raw.slice("```math\n".length, -"\n```".length);
69
+ return `<pre>${escapeHtml(source)}</pre>\n`;
70
+ }
66
71
  const latex = (token as MathToken).latex;
67
72
  return renderBlockMath(latex);
68
73
  },
@@ -87,6 +92,7 @@ export function katexExtension(): MarkedExtension {
87
92
  return undefined;
88
93
  },
89
94
  renderer(token: Tokens.Generic) {
95
+ if (literal) return `<p>${escapeHtml(token.raw)}</p>\n`;
90
96
  const latex = (token as MathToken).latex;
91
97
  return renderBlockMath(latex);
92
98
  },
@@ -111,6 +117,7 @@ export function katexExtension(): MarkedExtension {
111
117
  return undefined;
112
118
  },
113
119
  renderer(token: Tokens.Generic) {
120
+ if (literal) return escapeHtml(token.raw);
114
121
  const latex = (token as MathToken).latex;
115
122
  return renderInlineMath(latex);
116
123
  },
@@ -66,11 +66,24 @@ const renderCell = (alignCls: string, originalText: string, ctx: EvalContext, ht
66
66
  return `<td><span class="md-table-cell md-formula-error${alignCls}" title="${escapeHtml(tooltip)}">⚠ ${escapeHtml(originalText)}</span></td>`;
67
67
  };
68
68
 
69
- export function tablesExtension(): MarkedExtension {
69
+ export function tablesExtension(plain = false): MarkedExtension {
70
70
  return {
71
71
  renderer: {
72
72
  table(token: Tokens.Table): string {
73
73
  const align = token.align ?? [];
74
+ // Email has no Cloud stylesheet or formula widgets. Keep alignment on the
75
+ // structural cells and escape formula source before Markdown can interpret it.
76
+ if (plain) {
77
+ const cellHtml = (cell: Tokens.TableCell, tag: "th" | "td", column: number): string => {
78
+ const alignment = align[column];
79
+ const attribute = alignment ? ` align="${alignment}"` : "";
80
+ const content = isFormula(cell.text) ? escapeHtml(cell.text) : this.parser.parseInline(cell.tokens);
81
+ return `<${tag}${attribute}>${content}</${tag}>`;
82
+ };
83
+ const header = token.header.map((cell, column) => cellHtml(cell, "th", column)).join("");
84
+ const rows = token.rows.map((row) => `<tr>${row.map((cell, column) => cellHtml(cell, "td", column)).join("")}</tr>`).join("");
85
+ return `<table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`;
86
+ }
74
87
 
75
88
  // Build the EvalContext once per table — formulas reference
76
89
  // raw cell text, not other formulas' results, so a single pass
@@ -25,8 +25,15 @@ import { escapeHtml } from "./shared";
25
25
  // Create a configured marked instance
26
26
  type MarkdownProfile = "content" | "help";
27
27
  type LinkStyle = "widget" | "plain";
28
+ type MarkdownOutput = "cloud" | "email";
28
29
 
29
- const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension, locale?: string) => {
30
+ const createMarked = (
31
+ profile: MarkdownProfile,
32
+ links: LinkStyle,
33
+ infoBlocks: MarkedExtension,
34
+ locale?: string,
35
+ output: MarkdownOutput = "cloud",
36
+ ) => {
30
37
  const marked = new Marked();
31
38
 
32
39
  marked.use({
@@ -38,12 +45,12 @@ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: Ma
38
45
  // Note: katexExtension must come before codeExtension to handle ```math blocks
39
46
  marked.use(infoBlocks);
40
47
  marked.use(taskListExtension());
41
- marked.use(tablesExtension());
48
+ marked.use(tablesExtension(output === "email"));
42
49
  // Plain output keeps marked's own link renderer and renders code blocks as a bare `pre`.
43
50
  // Cloud links are calm reference pills and quiet web links from `@k2b/ui`.
44
51
  if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank", locale }));
45
52
  marked.use(imagesExtension());
46
- marked.use(katexExtension());
53
+ marked.use(katexExtension(output === "email"));
47
54
  marked.use(links === "widget" ? codeExtension() : plainCodeExtension());
48
55
  // Inline-style decorators come last so they run after structural tokenizers.
49
56
  marked.use(markExtension());
@@ -57,12 +64,12 @@ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: Ma
57
64
  // headers choose the locale, and `markdownInfoBlocks()` returns one extension
58
65
  // for every locale that shares a catalog, so the cache stays bounded.
59
66
  const instances = new Map<MarkedExtension, Map<string, Marked>>();
60
- const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale?: string): Marked => {
67
+ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale?: string, output: MarkdownOutput = "cloud"): Marked => {
61
68
  const infoBlocks = markdownInfoBlocks({ locale });
62
69
  const byStyle = instances.get(infoBlocks) ?? new Map<string, Marked>();
63
70
  instances.set(infoBlocks, byStyle);
64
- const key = `${profile}:${links}`;
65
- const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks, locale);
71
+ const key = `${profile}:${links}:${output}`;
72
+ const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks, locale, output);
66
73
  byStyle.set(key, instance);
67
74
  return instance;
68
75
  };
@@ -70,18 +77,21 @@ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale
70
77
  const marked = markedFor("content");
71
78
 
72
79
  export type MarkdownRenderOptions = {
80
+ /** `"email"` keeps strikethrough and structural table alignment, and renders table formulas and math as literal source. Defaults to Cloud's styled output with formula evaluation. */
81
+ output?: MarkdownOutput;
73
82
  /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. It also renders a code block, fenced or indented and including a Mermaid fence, as its source in a `pre` without the language label. The default renders references to Cloud content as calm pills and web links as quiet text links. */
74
83
  links?: LinkStyle;
75
84
  /** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
76
85
  locale?: string;
77
86
  };
78
87
 
79
- const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content"): string => {
88
+ const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content", output: MarkdownOutput = "cloud"): string => {
80
89
  const callouts = markdownCalloutSanitizeOptions();
81
90
  return sanitizeHtml(html, {
82
91
  ...callouts,
83
92
  allowedTags: [
84
93
  ...sanitizeHtml.defaults.allowedTags,
94
+ ...(output === "email" ? ["del"] : []),
85
95
  "annotation",
86
96
  "aside",
87
97
  "br",
@@ -137,6 +147,7 @@ const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content"
137
147
  input: ["checked", "class", "disabled", "type"],
138
148
  math: ["xmlns"],
139
149
  pre: ["class"],
150
+ ...(output === "email" ? { th: ["align"], td: ["align"] } : {}),
140
151
  ...callouts.allowedAttributes,
141
152
  span: ["aria-hidden", "class", "style", "title"],
142
153
  },
@@ -192,10 +203,10 @@ const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content"
192
203
  export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
193
204
  if (!content || typeof content !== "string") return "";
194
205
 
195
- const html = markedFor("content", options.links, options.locale).parse(content);
206
+ const html = markedFor("content", options.links, options.locale, options.output).parse(content);
196
207
  if (typeof html !== "string") return "";
197
208
 
198
- return sanitizeRenderedHtml(html);
209
+ return sanitizeRenderedHtml(html, "content", options.output);
199
210
  }
200
211
 
201
212
  /**
@@ -204,10 +215,10 @@ export function renderMarkdown(content: string, options: MarkdownRenderOptions =
204
215
  export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
205
216
  if (!content || typeof content !== "string") return "";
206
217
 
207
- const html = markedFor("content", options.links, options.locale).parse(content);
218
+ const html = markedFor("content", options.links, options.locale, options.output).parse(content);
208
219
  if (typeof html !== "string") return "";
209
220
 
210
- return sanitizeRenderedHtml(html);
221
+ return sanitizeRenderedHtml(html, "content", options.output);
211
222
  }
212
223
 
213
224
  /**
@@ -0,0 +1,29 @@
1
+ import * as Y from "yjs";
2
+
3
+ /** Capture an existing character, never a boundary that could drift to a neighbour. */
4
+ export const captureTextAnchor = (text: Y.Text, index: number): Uint8Array => {
5
+ if (!text.doc || !Number.isSafeInteger(index) || index < 0 || index >= text.length)
6
+ throw new RangeError("A text anchor must identify an existing character in an attached Y.Text");
7
+ return Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, index, 0));
8
+ };
9
+
10
+ /**
11
+ * Resolve only the original live character. Ordinary relative positions also
12
+ * resolve beside deleted characters and follow undo replacements; neither is
13
+ * a safe target for a delayed structural action.
14
+ */
15
+ export const resolveTextAnchor = (text: Y.Text, bytes: Uint8Array): number | null => {
16
+ if (!text.doc) return null;
17
+ try {
18
+ const anchor = Y.decodeRelativePosition(bytes);
19
+ if (!anchor.item || anchor.assoc !== 0) return null;
20
+ const position = Y.createAbsolutePositionFromRelativePosition(anchor, text.doc, false);
21
+ if (!position || position.type !== text || position.index < 0 || position.index >= text.length) return null;
22
+ // Item encodings omit the root type name. Compare two decoded positions,
23
+ // rather than a decoded anchor with a fresh position containing that name.
24
+ const live = Y.decodeRelativePosition(captureTextAnchor(text, position.index));
25
+ return Y.compareRelativePositions(anchor, live) ? position.index : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ };