@k2b/cloud 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Default window in which a second automatic reload for the same key is
3
+ * suppressed. A page load, hydration, and the first live subscription finish
4
+ * within a few seconds, so a condition that fails again right after a reload
5
+ * lands well inside it; a genuinely new change minutes later still reloads.
6
+ */
7
+ export const AUTOMATIC_RELOAD_WINDOW_MS = 30_000;
8
+
9
+ const storageKey = (key: string) => `cloud.reload.${key}`;
10
+
11
+ /**
12
+ * Reloads the page for an automatic reason (a live event, a failed live
13
+ * subscription, a changed view) at most once per `key` and window in this tab.
14
+ *
15
+ * Returns `false` without reloading when the same key already reloaded within
16
+ * the window, or when `sessionStorage` is unavailable and a repeat therefore
17
+ * cannot be ruled out. The caller then keeps the page usable and offers a
18
+ * user-initiated reload instead. Reloads after an explicit user action do not
19
+ * need this guard.
20
+ */
21
+ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): boolean => {
22
+ const windowMs = options.windowMs ?? AUTOMATIC_RELOAD_WINDOW_MS;
23
+ const now = Date.now();
24
+ try {
25
+ const storage = window.sessionStorage;
26
+ const last = Number(storage.getItem(storageKey(key)));
27
+ if (last > 0 && now >= last && now - last < windowMs) return false;
28
+ storage.setItem(storageKey(key), String(now));
29
+ } catch {
30
+ return false;
31
+ }
32
+ window.location.reload();
33
+ return true;
34
+ };
@@ -85,12 +85,22 @@ export type LocalUser = z.infer<typeof LocalUserSchema>;
85
85
  export const UserSchema = z.discriminatedUnion("provider", [IpaUserSchema, LocalUserSchema]);
86
86
  export type User = z.infer<typeof UserSchema>;
87
87
 
88
+ /** The user whose personal Linux group (user private group) this is. */
89
+ export const PersonalGroupOwnerSchema = z.object({
90
+ id: z.uuid(),
91
+ uid: z.string(),
92
+ displayName: z.string(),
93
+ });
94
+ export type PersonalGroupOwner = z.infer<typeof PersonalGroupOwnerSchema>;
95
+
88
96
  export const BaseGroupSchema = z.object({
89
97
  id: z.uuid(),
90
98
  provider: UserProviderSchema,
91
99
  name: z.string(),
92
100
  description: z.string().nullable(),
93
101
  gidnumber: z.number().nullable(),
102
+ /** Set when the group is a user's personal Linux group; `null` for ordinary groups. */
103
+ personalOwner: PersonalGroupOwnerSchema.nullable(),
94
104
  });
95
105
  export type BaseGroup = z.infer<typeof BaseGroupSchema>;
96
106
 
@@ -831,7 +831,7 @@ export const accountLifecycle = {
831
831
  OR LOWER(COALESCE(display_name, '')) LIKE ${pattern} ESCAPE '\\'
832
832
  OR LOWER(COALESCE(mail, '')) LIKE ${pattern} ESCAPE '\\'
833
833
  )
834
- ORDER BY deleted_at DESC
834
+ ORDER BY deleted_at DESC, id DESC
835
835
  LIMIT ${config.perPage}
836
836
  OFFSET ${offset}
837
837
  `;
@@ -905,7 +905,7 @@ export const accountLifecycle = {
905
905
  OR LOWER(COALESCE(r.mail, u.mail, '')) LIKE ${pattern} ESCAPE '\\'
906
906
  OR LOWER(COALESCE(r.display_name, u.display_name, '')) LIKE ${pattern} ESCAPE '\\'
907
907
  )
908
- ORDER BY r.created_at DESC
908
+ ORDER BY r.created_at DESC, r.id DESC
909
909
  LIMIT ${config.perPage}
910
910
  OFFSET ${offset}
911
911
  `;
@@ -909,7 +909,7 @@ export const accountsAppService = {
909
909
  group: {
910
910
  list: async (config: {
911
911
  pagination?: PageParams;
912
- filter?: { search?: string };
912
+ filter?: { search?: string; includePersonal?: boolean };
913
913
  scope?: { userId?: string; ids?: string[]; provider?: UserProvider; mode?: "all" | "member" | "managed" };
914
914
  }): Promise<Paginated<BaseGroup>> => {
915
915
  const { page, perPage } = paginate(config.pagination);
@@ -921,6 +921,7 @@ export const accountsAppService = {
921
921
  scope: config.scope?.mode,
922
922
  ids: config.scope?.ids,
923
923
  provider: config.scope?.provider,
924
+ includePersonal: config.filter?.includePersonal,
924
925
  });
925
926
 
926
927
  return {
@@ -1202,6 +1203,7 @@ export const accountsAppService = {
1202
1203
  parentGroupId?: string;
1203
1204
  managedByUserId?: string;
1204
1205
  recursive?: boolean;
1206
+ includePersonal?: boolean;
1205
1207
  }): Promise<Paginated<EntityListItem>> => {
1206
1208
  const canSearchDirectory = config.actor.roles.includes("user");
1207
1209
  const usesRelationFilter = Boolean(
@@ -1232,6 +1234,7 @@ export const accountsAppService = {
1232
1234
  parentGroupId: config.parentGroupId,
1233
1235
  managedByUserId: config.managedByUserId,
1234
1236
  recursive: config.recursive,
1237
+ includePersonal: config.includePersonal,
1235
1238
  page,
1236
1239
  perPage,
1237
1240
  });
@@ -1261,7 +1264,7 @@ export const accountsAppService = {
1261
1264
  FROM auth.account_requests r
1262
1265
  JOIN auth.users u ON u.id = r.user_id
1263
1266
  WHERE ${where}
1264
- ORDER BY r.created_at DESC
1267
+ ORDER BY r.created_at DESC, r.id DESC
1265
1268
  LIMIT ${perPage}
1266
1269
  OFFSET ${offset}
1267
1270
  `;
@@ -1,11 +1,40 @@
1
+ import { sql } from "bun";
1
2
  import type { BaseGroup, UserProvider } from "../../contracts/shared";
2
3
 
3
4
  type DbRow = Record<string, unknown>;
4
5
 
6
+ /**
7
+ * Joins the owner of a personal Linux group (the group stored as a user's
8
+ * `auth.user_posix.primary_group_id`) onto `auth.groups g` as
9
+ * `personal_owner_id`, `personal_owner_uid`, and `personal_owner_display_name`.
10
+ * A function, so importing this module never binds Bun's default `sql` handle.
11
+ */
12
+ export const personalOwnerJoin = () => sql`
13
+ LEFT JOIN LATERAL (
14
+ SELECT
15
+ owner.id AS personal_owner_id,
16
+ owner.uid AS personal_owner_uid,
17
+ COALESCE(NULLIF(owner.display_name, ''), NULLIF(owner.mail, ''), owner.uid) AS personal_owner_display_name
18
+ FROM auth.user_posix owner_posix
19
+ JOIN auth.users owner ON owner.id = owner_posix.user_id
20
+ WHERE owner_posix.primary_group_id = g.id
21
+ ORDER BY owner.id
22
+ LIMIT 1
23
+ ) personal_owner ON TRUE
24
+ `;
25
+
5
26
  export const buildBaseGroup = (row: DbRow): BaseGroup => ({
6
27
  id: row.id as string,
7
28
  provider: row.provider as UserProvider,
8
29
  name: row.name as string,
9
30
  description: (row.description as string | null | undefined) ?? null,
10
31
  gidnumber: (row.gid_number as number | null | undefined) ?? null,
32
+ personalOwner:
33
+ typeof row.personal_owner_id === "string"
34
+ ? {
35
+ id: row.personal_owner_id,
36
+ uid: String(row.personal_owner_uid),
37
+ displayName: String(row.personal_owner_display_name),
38
+ }
39
+ : null,
11
40
  });
@@ -2,7 +2,7 @@ import { sql } from "bun";
2
2
  import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
3
3
  import { getFreeIpaConfig } from "../freeipa-config";
4
4
  import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
5
- import { buildBaseGroup } from "./base-group";
5
+ import { buildBaseGroup, personalOwnerJoin } from "./base-group";
6
6
  import { buildBaseUser } from "./base-user";
7
7
  import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
8
8
 
@@ -26,6 +26,11 @@ export type EntityListParams = {
26
26
  parentGroupId?: string;
27
27
  managedByUserId?: string;
28
28
  recursive?: boolean;
29
+ /**
30
+ * Personal Linux groups are left out of directory browsing and search by
31
+ * default. Exact `groupIds` lookups and relation filters always return them.
32
+ */
33
+ includePersonal?: boolean;
29
34
  page?: number;
30
35
  perPage?: number;
31
36
  };
@@ -415,6 +420,11 @@ export const list = async (
415
420
  const groupsAdmin = (await getFreeIpaConfig()).groupsAdmin;
416
421
  const groupsAdminLiteral = toPgTextArray(groupsAdmin);
417
422
  const spec = buildQuerySpec(params);
423
+ const includePersonal =
424
+ params.includePersonal === true ||
425
+ params.groupIds !== undefined ||
426
+ Boolean(params.memberOfGroupId || params.managerOfGroupId || params.parentGroupId || params.managedByUserId);
427
+ const personalCondition = includePersonal ? sql`TRUE` : sql`(kind <> 'group' OR personal_owner_id IS NULL)`;
418
428
  const visibilityCondition =
419
429
  params.visibility.type === "directory"
420
430
  ? sql`TRUE`
@@ -465,6 +475,7 @@ export const list = async (
465
475
  AND ${excludeGroupCondition}
466
476
  AND ${excludeServiceAccountCondition}
467
477
  AND ${userMemberOfGroupCondition}
478
+ AND ${personalCondition}
468
479
  AND (
469
480
  ${pattern}::text IS NULL
470
481
  OR (
@@ -529,6 +540,9 @@ export const list = async (
529
540
  NULL::text AS resource_id,
530
541
  NULL::uuid AS created_by,
531
542
  NULL::timestamptz AS created_at,
543
+ NULL::uuid AS personal_owner_id,
544
+ NULL::text AS personal_owner_uid,
545
+ NULL::text AS personal_owner_display_name,
532
546
  LOWER(COALESCE(NULLIF(u.display_name, ''), NULLIF(u.mail, ''), u.uid)) AS sort_label
533
547
  ${spec.userFrom}
534
548
  WHERE ${spec.userWhere}
@@ -558,8 +572,12 @@ export const list = async (
558
572
  NULL::text AS resource_id,
559
573
  NULL::uuid AS created_by,
560
574
  NULL::timestamptz AS created_at,
575
+ personal_owner.personal_owner_id,
576
+ personal_owner.personal_owner_uid,
577
+ personal_owner.personal_owner_display_name,
561
578
  LOWER(g.name) AS sort_label
562
579
  ${spec.groupFrom}
580
+ ${personalOwnerJoin()}
563
581
  WHERE ${spec.groupWhere}
564
582
  ),
565
583
  service_account_rows AS (
@@ -590,6 +608,9 @@ export const list = async (
590
608
  sa.resource_id,
591
609
  sa.created_by,
592
610
  sa.created_at,
611
+ NULL::uuid AS personal_owner_id,
612
+ NULL::text AS personal_owner_uid,
613
+ NULL::text AS personal_owner_display_name,
593
614
  LOWER(sa.name) AS sort_label
594
615
  FROM auth.service_accounts sa
595
616
  ),
@@ -5,7 +5,7 @@ import { getServiceIpaSession } from "../ipa/service-account";
5
5
  import { toPgUuidArray } from "../postgres";
6
6
  import { providers } from "../providers";
7
7
  import type { AccountsActor } from "./authz";
8
- import { buildBaseGroup } from "./base-group";
8
+ import { buildBaseGroup, personalOwnerJoin } from "./base-group";
9
9
  import { buildManagedGroupScopeCondition, buildMemberGroupScopeCondition } from "./group-sql";
10
10
  import * as localGroups from "./local-groups";
11
11
  import { posix } from "./posix";
@@ -16,9 +16,10 @@ type GroupListScope = "all" | "member" | "managed";
16
16
 
17
17
  const getGroup = async (id: string): Promise<BaseGroup | null> => {
18
18
  const [row] = await sql<DbRow[]>`
19
- SELECT id, provider, name, description, gid_number
20
- FROM auth.groups
21
- WHERE id = ${id}::uuid
19
+ SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
20
+ FROM auth.groups g
21
+ ${personalOwnerJoin()}
22
+ WHERE g.id = ${id}::uuid
22
23
  `;
23
24
  if (!row) return null;
24
25
  return buildBaseGroup(row);
@@ -30,6 +31,8 @@ const listCanonical = async (params: {
30
31
  scope?: GroupListScope;
31
32
  search?: string;
32
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;
33
36
  page?: number;
34
37
  perPage?: number;
35
38
  }): Promise<{
@@ -45,6 +48,7 @@ const listCanonical = async (params: {
45
48
  const scope = params.scope ?? (params.userId ? "member" : "all");
46
49
  const scopeUserId = params.userId ?? "00000000-0000-0000-0000-000000000000";
47
50
  const idsCondition = ids.length === 0 ? sql`TRUE` : sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`;
51
+ const includePersonal = params.includePersonal === true || params.ids !== undefined;
48
52
 
49
53
  if (params.ids && params.ids.length === 0) {
50
54
  return {
@@ -60,10 +64,12 @@ const listCanonical = async (params: {
60
64
  }
61
65
 
62
66
  const rows = await sql<DbRow[]>`
63
- SELECT g.id, g.provider, g.name, g.description, g.gid_number, COUNT(*) OVER() AS total
67
+ SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*, COUNT(*) OVER() AS total
64
68
  FROM auth.groups g
69
+ ${personalOwnerJoin()}
65
70
  WHERE (${params.provider ?? null}::text IS NULL OR g.provider = ${params.provider ?? null})
66
71
  AND ${idsCondition}
72
+ AND (${includePersonal} = true OR personal_owner.personal_owner_id IS NULL)
67
73
  AND (
68
74
  ${scope === "all"} = true
69
75
  OR ${params.userId ?? null}::uuid IS NULL
@@ -99,6 +105,8 @@ export const list = async (params: {
99
105
  scope?: GroupListScope;
100
106
  search?: string;
101
107
  provider?: UserProvider;
108
+ /** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
109
+ includePersonal?: boolean;
102
110
  page?: number;
103
111
  perPage?: number;
104
112
  }) => {
@@ -16,7 +16,8 @@ export type AccountIdentityUser = {
16
16
  profile: UserProfile;
17
17
  posix: { uidNumber: number; primaryGidNumber: number } | null;
18
18
  };
19
- export type AccountIdentityGroup = { id: string; provider: UserProvider; name: string; gidNumber: number | null };
19
+ /** `personal` marks a user's personal Linux group (their stored primary group). */
20
+ export type AccountIdentityGroup = { id: string; provider: UserProvider; name: string; gidNumber: number | null; personal: boolean };
20
21
  export type AccountIdentityPage<T> = { items: T[]; nextCursor: string | null };
21
22
  export type AccountIdentityAvailability = { localLinuxEnabled: boolean; freeipaEnabled: boolean };
22
23
  type InventoryFilter = { provider: UserProvider; after?: string; id?: string; name?: string };
@@ -119,8 +120,10 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
119
120
  return current;
120
121
  };
121
122
  const groupRows = async (filter: SQLQuery) => {
122
- const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null }[]>`
123
- SELECT g.id, g.provider, g.name, g.gid_number FROM auth.groups g
123
+ const rows = await db<{ id: string; provider: UserProvider; name: string; gid_number: number | null; personal: boolean }[]>`
124
+ SELECT g.id, g.provider, g.name, g.gid_number,
125
+ EXISTS(SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id) AS personal
126
+ FROM auth.groups g
124
127
  WHERE ${filter} ORDER BY g.id LIMIT ${PAGE_SIZE + 1}
125
128
  `;
126
129
  return rows.map(
@@ -129,6 +132,7 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
129
132
  provider: row.provider,
130
133
  name: row.name,
131
134
  gidNumber: positiveId(row.gid_number) ? row.gid_number : null,
135
+ personal: row.personal,
132
136
  }),
133
137
  );
134
138
  };
@@ -1,6 +1,7 @@
1
1
  import { sql } from "bun";
2
2
  import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
3
3
  import { escapeLikePattern, isUniqueViolation } from "../postgres";
4
+ import { buildBaseGroup, personalOwnerJoin } from "./base-group";
4
5
 
5
6
  type DbRow = Record<string, unknown>;
6
7
 
@@ -18,6 +19,7 @@ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
18
19
  name: row.name,
19
20
  description: row.description,
20
21
  gidnumber: row.gidNumber,
22
+ personalOwner: null,
21
23
  });
22
24
 
23
25
  const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
@@ -73,8 +75,13 @@ const wouldCreateLocalGroupCycle = async (params: { parentGroupId: string; child
73
75
  };
74
76
 
75
77
  export const get = async (params: { id: string }): Promise<BaseGroup | null> => {
76
- const row = await getLocalGroupById(params.id);
77
- return row ? toBaseGroup(row) : null;
78
+ const [row] = await sql<DbRow[]>`
79
+ SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
80
+ FROM auth.groups g
81
+ ${personalOwnerJoin()}
82
+ WHERE g.id = ${params.id}::uuid AND g.provider = 'local'
83
+ `;
84
+ return row ? buildBaseGroup(row) : null;
78
85
  };
79
86
 
80
87
  export const create = async (params: { name: string; description?: string }, db: typeof sql = sql): Promise<MutationResult<BaseGroup>> => {
@@ -119,8 +126,9 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
119
126
  `;
120
127
  const total = Number(countRow?.count ?? 0);
121
128
  const rows = await sql<DbRow[]>`
122
- SELECT id, provider, name, description, gid_number
129
+ SELECT g.id, g.provider, g.name, g.description, g.gid_number, personal_owner.*
123
130
  FROM auth.groups g
131
+ ${personalOwnerJoin()}
124
132
  WHERE g.provider = 'local'
125
133
  AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
126
134
  ORDER BY g.name
@@ -128,15 +136,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
128
136
  `;
129
137
 
130
138
  return {
131
- groups: rows.map((row) =>
132
- toBaseGroup({
133
- id: row.id as string,
134
- provider: row.provider as "local",
135
- name: row.name as string,
136
- description: row.description as string | null,
137
- gidNumber: row.gid_number as number | null,
138
- }),
139
- ),
139
+ groups: rows.map(buildBaseGroup),
140
140
  total,
141
141
  pagination: {
142
142
  page,
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
2
2
  import { type SQL, sql } from "bun";
3
+ import { lazySync } from "../_internal/process-sync";
3
4
  import { env } from "../config/env";
4
5
  import { type AccountCategory, accountCategory } from "../contracts/account-categories";
5
6
  import {
@@ -17,6 +18,7 @@ import {
17
18
  import { publicCloudOrigin } from "../shared/app-url";
18
19
  import { isAccountCategoryAllowed } from "./account-category-policy";
19
20
  import { audit } from "./audit";
21
+ import { logger } from "./logging";
20
22
  import { CORE_SETTINGS } from "./settings/core-settings";
21
23
  import { decryptValue } from "./settings/crypto";
22
24
 
@@ -136,6 +138,33 @@ type LoginRow = {
136
138
  };
137
139
  export type AppApprovalActor = { userId: string; sid: string; admin: boolean };
138
140
  export type AppDeviceEnrollmentNotice = { deviceId: string; userId: string; name: string; assisted: boolean };
141
+ /** Best-effort wake-ups for browsers waiting on a login decision. Postgres stays authoritative. */
142
+ export type AppLoginDecisionHints = {
143
+ /** Call after the decision commits. Must not throw or delay the caller. */
144
+ publish(requestId: string): void;
145
+ /** Settles on the first hint for this request, or when `signal` aborts. */
146
+ wait(requestId: string, signal: AbortSignal): Promise<void>;
147
+ };
148
+ const log = logger("app-approval");
149
+ const decisionTopic = lazySync((sync) =>
150
+ sync.topic<null>({
151
+ id: "cloud:app-login-decisions",
152
+ owner: "cloud",
153
+ retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
154
+ maxPayloadBytes: 2000,
155
+ }),
156
+ );
157
+ /** Core NATS broadcast reaches the waiting browser on any replica. */
158
+ export const syncLoginDecisionHints: AppLoginDecisionHints = {
159
+ publish: (requestId) => {
160
+ void Promise.resolve()
161
+ .then(() => decisionTopic().publish({ tenantId: requestId, data: null }))
162
+ .catch((error) => log.warn("App login decision hint failed", { error: String(error) }));
163
+ },
164
+ wait: async (requestId, signal) => {
165
+ for await (const _event of decisionTopic().live({ tenantId: requestId, signal })) return;
166
+ },
167
+ };
139
168
  const view = (row: DeviceRow): AppDeviceView => ({
140
169
  id: row.id,
141
170
  name: row.name,
@@ -163,6 +192,7 @@ const verify = async (key: AppDevicePublicKey, message: string, signature: strin
163
192
  export const createAppApprovalService = (
164
193
  db: SQL = sql,
165
194
  configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
195
+ hints: AppLoginDecisionHints = syncLoginDecisionHints,
166
196
  ) => {
167
197
  const config = async (requireEnabled = true) => {
168
198
  const value = await configuration(requireEnabled);
@@ -409,7 +439,7 @@ export const createAppApprovalService = (
409
439
  )
410
440
  return reject("FORBIDDEN", 403);
411
441
  await cleanup();
412
- return db.begin(async (tx) => {
442
+ const outcome = await db.begin(async (tx) => {
413
443
  const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
414
444
  if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
415
445
  return reject("FORBIDDEN", 403);
@@ -460,12 +490,39 @@ export const createAppApprovalService = (
460
490
  await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
461
491
  return { state };
462
492
  });
493
+ // Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
494
+ if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
495
+ return outcome;
463
496
  },
464
- browserStatus: async (id: string, token: string) => {
497
+ /** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
498
+ browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
465
499
  const cfg = await config();
466
- const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
467
- if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
468
- return { state: new Date(row.expires_at).getTime() <= Date.now() ? "expired" : row.state, pollAfterSeconds: limits.pollSeconds };
500
+ const read = async () => {
501
+ const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
502
+ if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
503
+ const expiresIn = new Date(row.expires_at).getTime() - Date.now();
504
+ return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
505
+ };
506
+ const result = (state: string) => ({ state, pollAfterSeconds: limits.pollSeconds });
507
+ if (!hold) return result((await read()).state);
508
+ // Listen before the first read so a decision committed in between still wakes this request.
509
+ const waiting = new AbortController();
510
+ const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
511
+ const stop = () => waiting.abort();
512
+ hold.signal?.addEventListener("abort", stop, { once: true });
513
+ if (hold.signal?.aborted) stop();
514
+ const hint = hints.wait(id, waiting.signal).catch(() => {});
515
+ try {
516
+ const first = await read();
517
+ if (first.state !== "pending") return result(first.state);
518
+ const timer = setTimeout(stop, Math.min(hold.ms, first.expiresIn));
519
+ await Promise.race([hint, ended]);
520
+ clearTimeout(timer);
521
+ return result((await read()).state);
522
+ } finally {
523
+ hold.signal?.removeEventListener("abort", stop);
524
+ stop();
525
+ }
469
526
  },
470
527
  consumeLogin: async (id: string, token: string) => {
471
528
  const cfg = await config();
@@ -22,6 +22,7 @@ const toBaseGroup = (row: IpaGroupRow): BaseGroup => ({
22
22
  name: row.name,
23
23
  description: row.description,
24
24
  gidnumber: row.gidNumber,
25
+ personalOwner: null,
25
26
  });
26
27
 
27
28
  const getIpaGroupById = async (id: string): Promise<IpaGroupRow | null> => {
@@ -142,6 +143,7 @@ export const list = async (params: {
142
143
  name: row.name as string,
143
144
  description: row.description as string | null,
144
145
  gidnumber: row.gid_number as number | null,
146
+ personalOwner: null,
145
147
  })),
146
148
  total,
147
149
  pagination: { page, perPage, totalPages, hasNext: page < totalPages },
@@ -377,6 +379,7 @@ export const add = async (params: {
377
379
  name: row.name as string,
378
380
  description: row.description as string | null,
379
381
  gidnumber: row.gid_number as number | null,
382
+ personalOwner: null,
380
383
  },
381
384
  };
382
385
  };
@@ -147,6 +147,7 @@ export const search = async (query: string, options: SearchOptions): Promise<{ u
147
147
  name: row.name,
148
148
  description: row.description ?? null,
149
149
  gidnumber: row.gid_number ?? null,
150
+ personalOwner: null,
150
151
  }));
151
152
  }
152
153
 
@@ -205,7 +205,7 @@ const list = async (
205
205
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
206
206
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
207
207
  AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
208
- ORDER BY created_at DESC
208
+ ORDER BY created_at DESC, id DESC
209
209
  LIMIT ${perPage} OFFSET ${offset}
210
210
  `;
211
211
  } else if (hasSourceList) {
@@ -225,7 +225,7 @@ const list = async (
225
225
  AND (${filterLevel}::text IS NULL OR level = ${filterLevel})
226
226
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
227
227
  AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
228
- ORDER BY created_at DESC
228
+ ORDER BY created_at DESC, id DESC
229
229
  LIMIT ${perPage} OFFSET ${offset}
230
230
  `;
231
231
  } else {
@@ -243,7 +243,7 @@ const list = async (
243
243
  WHERE (${filterLevel}::text IS NULL OR level = ${filterLevel})
244
244
  AND (${filterSinceHours}::int IS NULL OR created_at >= now() - (${filterSinceHours}::int * INTERVAL '1 hour'))
245
245
  AND (${searchPattern}::text IS NULL OR message ILIKE ${searchPattern} ESCAPE '\\' OR metadata::text ILIKE ${searchPattern} ESCAPE '\\')
246
- ORDER BY created_at DESC
246
+ ORDER BY created_at DESC, id DESC
247
247
  LIMIT ${perPage} OFFSET ${offset}
248
248
  `;
249
249
  }
@@ -789,7 +789,7 @@ const list = async (
789
789
  ) AS event_count
790
790
  FROM logging.trace_spans s
791
791
  WHERE ${where}
792
- ORDER BY COALESCE(s.ended_at, s.updated_at, s.started_at) DESC
792
+ ORDER BY COALESCE(s.ended_at, s.updated_at, s.started_at) DESC, s.trace_id DESC, s.span_id DESC
793
793
  LIMIT ${perPage} OFFSET ${offset}
794
794
  `,
795
795
  ]);
@@ -460,7 +460,7 @@ export const list = async (params?: {
460
460
  SELECT *, COUNT(*) OVER() AS total
461
461
  FROM notifications.batches
462
462
  WHERE (${params?.status ?? null}::text IS NULL OR status = ${params?.status ?? null})
463
- ORDER BY created_at DESC
463
+ ORDER BY created_at DESC, id DESC
464
464
  LIMIT ${perPage} OFFSET ${offset}
465
465
  `;
466
466
  return {
@@ -235,7 +235,7 @@ export const list = async (
235
235
  WHERE
236
236
  (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
237
237
  AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
238
- ORDER BY m.created_at DESC
238
+ ORDER BY m.created_at DESC, m.id DESC
239
239
  LIMIT ${perPage} OFFSET ${offset}
240
240
  `;
241
241
  } else {
@@ -251,7 +251,7 @@ export const list = async (
251
251
  FROM notifications.messages m
252
252
  LEFT JOIN auth.users u ON m.sent_by = u.id
253
253
  WHERE ${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter}
254
- ORDER BY m.created_at DESC
254
+ ORDER BY m.created_at DESC, m.id DESC
255
255
  LIMIT ${perPage} OFFSET ${offset}
256
256
  `;
257
257
  }
@@ -276,7 +276,7 @@ export const list = async (
276
276
  m.sent_by = ${sentBy}
277
277
  AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
278
278
  AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
279
- ORDER BY m.created_at DESC
279
+ ORDER BY m.created_at DESC, m.id DESC
280
280
  LIMIT ${perPage} OFFSET ${offset}
281
281
  `;
282
282
  } else {
@@ -296,7 +296,7 @@ export const list = async (
296
296
  WHERE
297
297
  m.sent_by = ${sentBy}
298
298
  AND (${statusFilter}::text IS NULL OR CASE WHEN m.sent_at IS NOT NULL THEN 'sent' WHEN m.error IS NOT NULL THEN 'error' ELSE 'pending' END = ${statusFilter})
299
- ORDER BY m.created_at DESC
299
+ ORDER BY m.created_at DESC, m.id DESC
300
300
  LIMIT ${perPage} OFFSET ${offset}
301
301
  `;
302
302
  }
@@ -553,7 +553,7 @@ export const listOverview = async (config?: {
553
553
  OR sa.resource_type ILIKE '%' || ${search} || '%'
554
554
  OR sa.resource_id ILIKE '%' || ${search} || '%'
555
555
  )
556
- ORDER BY c.created_at DESC
556
+ ORDER BY c.created_at DESC, c.id DESC
557
557
  LIMIT ${perPage}
558
558
  OFFSET ${offset}
559
559
  `;
@@ -21,7 +21,7 @@ import { coreSettings } from "./settings/api";
21
21
  const CHALLENGE_TTL_SECONDS = 300;
22
22
  const REGISTRATION_CHALLENGE_PREFIX = "webauthn:registration:";
23
23
  const AUTHENTICATION_CHALLENGE_PREFIX = "webauthn:authentication:";
24
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;
24
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
25
25
  const log = logger("auth:webauthn");
26
26
 
27
27
  type DbPasskeyRow = {
@@ -13,3 +13,17 @@ export const getAccountTypeLabel = (user: Pick<AccountLike, "profile">): string
13
13
  export const getManagementLabel = (user: Pick<AccountLike, "provider">): string => (user.provider === "ipa" ? "FreeIPA" : "Local");
14
14
 
15
15
  export const getSupplementalRoleLabel = (role: SupplementalRole): string => (role === "group-manager" ? "Group Manager" : "Admin");
16
+
17
+ const isGerman = (locale: string | null | undefined): boolean => locale?.toLowerCase().split(/[-_]/)[0] === "de";
18
+
19
+ /**
20
+ * Display form of a group name for people. In German, the first letter of each
21
+ * word separated by whitespace or a hyphen is capitalised (`buchhaltung` →
22
+ * `Buchhaltung`); other locales get the name unchanged. Use it only for rendered
23
+ * text: stored names, identifiers, URLs, search input, and API or JSON output
24
+ * keep the original name.
25
+ */
26
+ export const groupDisplayName = (name: string, locale: string | null | undefined): string =>
27
+ isGerman(locale)
28
+ ? name.replace(/(^|[\s-])(\p{L})/gu, (_, separator: string, letter: string) => separator + letter.toLocaleUpperCase("de"))
29
+ : name;