@k2b/cloud 0.12.0 → 0.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -32,6 +32,7 @@
32
32
  "./browser/app-approval": "./src/browser/app-approval.ts",
33
33
  "./browser/live": "./src/browser/live-websocket.ts",
34
34
  "./browser/mermaid": "./src/browser/mermaid.ts",
35
+ "./browser/reload": "./src/browser/reload.ts",
35
36
  "./browser/notifications": "./src/browser/notifications.ts",
36
37
  "./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
37
38
  "./browser/commands": "./src/browser/commands.ts",
@@ -98,8 +99,8 @@
98
99
  "@tailwindcss/typography": "0.5.20",
99
100
  "@k2b/nessi": "0.12.1",
100
101
  "@k2b/ssr": "0.14.0",
101
- "@k2b/ui": "0.5.1",
102
- "@k2b/stdlib": "0.25.0",
102
+ "@k2b/ui": "0.6.1",
103
+ "@k2b/stdlib": "0.26.0",
103
104
  "@k2b/sync": "6.5.0",
104
105
  "@nats-io/transport-node": "3.4.0",
105
106
  "@modelcontextprotocol/sdk": "1.30.0",
@@ -3,6 +3,7 @@ import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from
3
3
  import { createSignal, For, Show } from "solid-js";
4
4
  import { CloudAvatar } from "../account/Avatar";
5
5
  import type { AccessEntry, PermissionLevel, Principal } from "../contracts/shared";
6
+ import { groupDisplayName } from "../shared/account-display";
6
7
  import { accessMessages } from "./messages";
7
8
  import PrincipalPicker from "./PrincipalPicker";
8
9
 
@@ -112,8 +113,8 @@ const resolveEntryDisplay = (
112
113
  return defaults[permission] ?? defaults.none;
113
114
  };
114
115
 
115
- const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
116
- if (entry.displayName) return entry.displayName;
116
+ const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
117
+ if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
117
118
  if (entry.principal.type === "authenticated") return t.allUsers;
118
119
  if (entry.principal.type === "public") return t.public;
119
120
  if (entry.principal.type === "user") return entry.principal.userId;
@@ -184,7 +185,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
184
185
 
185
186
  const revokeMut = mutation.create<string | null, AccessEntry>({
186
187
  mutation: async (entry) => {
187
- const displayName = getEntryDisplayName(entry, t());
188
+ const displayName = getEntryDisplayName(entry, t(), locale());
188
189
  const confirmed = await prompts.confirm(t().removeAccessConfirm({ name: displayName }), {
189
190
  title: t().removeAccess,
190
191
  variant: "danger",
@@ -263,7 +264,7 @@ function AccessEntryRow(props: {
263
264
  }) {
264
265
  const locale = useLocale();
265
266
  const t = () => accessMessages.resolve([locale()]).t;
266
- const displayName = () => getEntryDisplayName(props.entry, t());
267
+ const displayName = () => getEntryDisplayName(props.entry, t(), locale());
267
268
  const display = () => resolveEntryDisplay(props.entry.permission, props.allowed, t());
268
269
  const isInteractive = () =>
269
270
  props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.level === props.entry.permission);
@@ -1,5 +1,6 @@
1
1
  import { Combobox, type ComboboxOption, useLocale } from "@k2b/ui";
2
2
  import type { Principal } from "../contracts/shared";
3
+ import { groupDisplayName } from "../shared/account-display";
3
4
  import { accessMessages } from "./messages";
4
5
 
5
6
  export const principalKey = (p: Principal) =>
@@ -66,7 +67,7 @@ export default function PrincipalPicker(props: {
66
67
  else if (e.kind === "group")
67
68
  entries.push({
68
69
  principal: { type: "group", groupId: e.group.id },
69
- label: e.group.name,
70
+ label: groupDisplayName(e.group.name, locale()),
70
71
  icon: "ti ti-users-group",
71
72
  description: e.group.description ?? undefined,
72
73
  });
@@ -1,7 +1,8 @@
1
1
  import { timed } from "@k2b/stdlib/solid";
2
- import { Button, ScrollArea, TextInput } from "@k2b/ui";
2
+ import { Button, ScrollArea, TextInput, useLocale } from "@k2b/ui";
3
3
  import type { JSX } from "solid-js";
4
4
  import { createSignal, For, Show } from "solid-js";
5
+ import { groupDisplayName } from "../shared/account-display";
5
6
  import { CloudAvatar } from "./Avatar";
6
7
 
7
8
  /**
@@ -108,6 +109,7 @@ type ApiServiceAccount = {
108
109
  };
109
110
 
110
111
  const EntitySearch = (props: EntitySearchProps) => {
112
+ const locale = useLocale();
111
113
  const [search, setSearch] = createSignal("");
112
114
  const [users, setUsers] = createSignal<ApiUser[]>([]);
113
115
  const [groups, setGroups] = createSignal<ApiGroup[]>([]);
@@ -270,7 +272,7 @@ const EntitySearch = (props: EntitySearchProps) => {
270
272
  {(group) => (
271
273
  <ResultRow
272
274
  icon="ti-users-group"
273
- title={group.name}
275
+ title={groupDisplayName(group.name, locale())}
274
276
  subtitle={group.description ?? undefined}
275
277
  disabled={props.disabled}
276
278
  onSelect={() =>
@@ -339,7 +341,7 @@ const ResultRow = (props: {
339
341
  disabled?: boolean;
340
342
  onSelect: () => void;
341
343
  }) => (
342
- <Button onClick={props.onSelect} disabled={props.disabled} variant="ghost" class="w-full justify-start gap-3 text-left">
344
+ <Button onClick={props.onSelect} disabled={props.disabled} variant="ghost" align="start" class="w-full">
343
345
  <Show
344
346
  when={props.avatar}
345
347
  fallback={
@@ -4,7 +4,7 @@ import type { AiSkillTemplate } from "./skills";
4
4
 
5
5
  export const ASSISTANT_CODE_MODE_SKILL = {
6
6
  "key": "assistant:code-mode",
7
- "version": 51,
7
+ "version": 52,
8
8
  "name": "assistant-code-mode",
9
9
  "description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
10
10
  "instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
@@ -56,7 +56,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
56
56
  },
57
57
  {
58
58
  "path": "references/einvoice.md",
59
- "content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes and self-billing,\ncategory S VAT, units C62/HUR/DAY/KGM. It does not support UBL, XRechnung,\ndiscounts, prepayments or exemptions. Readers preserve declared totals;\nparsing is not arithmetic verification. Validation is not XSD or Schematron\ncertification. No XSD validator is exposed.\n\n## Complete input and result shapes\n\nType descriptions only; no imports are needed. All fields are required unless\nmarked `?`; unknown fields are rejected.\n\n```ts\ntype Party = {\n name: string; vatId: string;\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype InvoiceLine = {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n taxRate: string; netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: {taxRate: string; netAmount: string; taxAmount: string}[];\n};\ntype Invoice = {\n kind: \"invoice\" | \"creditNote\" | \"selfBilling\";\n number: string; invoiceDate: string; serviceDate: string; dueDate: string;\n currency: \"EUR\"; seller: Party; buyer: Party; buyerReference: string;\n notes?: string[];\n precedingInvoice?: {number: string; invoiceDate: string};\n payment: {iban: string; accountName: string};\n lines: InvoiceLine[]; totals?: InvoiceTotals;\n};\ntype InvoiceCalculation = InvoiceTotals & {\n lines: (InvoiceLine & {netAmount: string})[];\n};\ntype ParsedInvoice = {\n format: \"zugferd-2.5-en16931\"; profile: string; xml: string;\n invoice: Invoice; filename?: string;\n};\ntype ParseOptions = {maxCharacters?: number; maxElements?: number; maxDepth?: number};\ntype PdfOptions = ParseOptions & {maxPdfBytes?: number};\n```\n\nXML options default to 10 Mi UTF-16 code units, 100,000 elements, depth 64.\nPDF input defaults to 25 MiB. Overrides must be positive safe integers.\nA parser result's business fields are under **`data.invoice`**. Calculated\namounts are directly under **`data.netAmount`**, etc., with no `data.totals` wrapper.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n Credit notes require `precedingInvoice`, whose date cannot be later than the\n credit note; other kinds cannot supply it. Credit-note amounts stay unsigned.\n- Lines: 1–1000, unique IDs. Quantities are positive, prices nonnegative,\n VAT rates greater than 0 and at most 100. Decimal strings allow up to four\n fractional digits and no leading zeros. Totals/net amounts require exactly\n two fractional digits; do not convert through JavaScript Number.\n- Country codes: two uppercase letters. `payment.iban` must be valid.\n Required text is nonblank valid XML text. Limits: number/reference/line ID/VAT ID\n 100; names/address line/accountName 200; city 100; postalCode 20;\n line description and each note 4000; at most 100 notes.\n- `calculate` rounds each line half up to cents, then VAT per rate. It recalculates\n line `netAmount`; `serialize` also rejects supplied line/totals values that\n disagree. Render these calculated amounts in HTML instead of another arithmetic path.\n\n## Minimal supported invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture demonstrates the required fields; it is not a document to issue.\n\n```js\nconst invoice = {\n kind: \"invoice\",\n number: \"EXAMPLE-42\",\n invoiceDate: \"2026-09-15\",\n serviceDate: \"2026-09-15\",\n dueDate: \"2026-09-30\",\n currency: \"EUR\",\n seller: {\n name: \"Example Seller\", vatId: \"DE123456789\",\n address: { line1: \"Street 1\", city: \"Ulm\", postalCode: \"89073\", countryCode: \"DE\" },\n },\n buyer: {\n name: \"Example Buyer\", vatId: \"DE987654321\",\n address: { line1: \"Street 2\", city: \"Berlin\", postalCode: \"10115\", countryCode: \"DE\" },\n },\n buyerReference: \"ORDER-42\",\n payment: { iban: \"DE89370400440532013000\", accountName: \"Example Seller\" },\n lines: [{ id: \"1\", name: \"Service\", quantity: \"2.0000\", unitPrice: \"50.0000\", unitCode: \"HUR\", taxRate: \"19.00\" }],\n};\nconst result = einvoice.serialize(invoice, { format: \"zugferd-2.5-en16931\" });\nif (!result.ok) throw new Error(JSON.stringify(result.error));\nawait files.save(new Blob([result.data.bytes], { type: \"application/xml\" }), \"invoice.xml\");\n```\n\nNever infer a missing VAT identifier, account or business reference merely to\nsatisfy input validation.\n\nFor an invoice PDF, pass `serialized.data.xml` to\n[`pdf.facturX`](pdf.md) with profile `\"EN 16931\"` and matching HTML.\nNumbering, business mapping, issuance and persistence belong to the app.\n"
59
+ "content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes and self-billing,\nVAT categories S/Z/E/AE/K/G/O, units C62/HUR/DAY/KGM. Generation does not\nsupport UBL, XRechnung, discounts or prepayments. Readers preserve declared totals;\nparsing is not arithmetic verification. Validation is not XSD or Schematron\ncertification. No XSD validator is exposed.\n\n## Complete input and result shapes\n\nType descriptions only; no imports are needed. All fields are required unless\nmarked `?`; unknown fields are rejected.\n\n```ts\ntype Party = {\n name: string; id?: string; vatId: string; // \"\" when the party has no VAT ID\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype Tax = {\n taxCategory?: \"S\" | \"Z\" | \"E\" | \"AE\" | \"K\" | \"G\" | \"O\"; // default \"S\"\n taxRate: string; taxExemptionReason?: string; taxExemptionReasonCode?: string;\n};\ntype InvoiceLine = Tax & {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: (Tax & {netAmount: string; taxAmount: string})[];\n};\ntype Invoice = {\n kind: \"invoice\" | \"creditNote\" | \"selfBilling\";\n number: string; invoiceDate: string; serviceDate: string; dueDate: string;\n currency: \"EUR\"; seller: Party & {taxRegistrationId?: string}; buyer: Party;\n deliverToCountryCode?: string; buyerReference: string;\n notes?: string[];\n precedingInvoice?: {number: string; invoiceDate: string};\n payment: {iban: string; accountName: string};\n lines: InvoiceLine[]; totals?: InvoiceTotals;\n};\ntype InvoiceCalculation = InvoiceTotals & {\n lines: (InvoiceLine & {netAmount: string})[];\n};\ntype ParsedInvoice = {\n format: \"zugferd-2.5-en16931\"; profile: string; xml: string;\n invoice: Invoice; filename?: string;\n};\ntype ParseOptions = {maxCharacters?: number; maxElements?: number; maxDepth?: number};\ntype PdfOptions = ParseOptions & {maxPdfBytes?: number};\n```\n\nXML options default to 10 Mi UTF-16 code units, 100,000 elements, depth 64.\nPDF input defaults to 25 MiB. Overrides must be positive safe integers.\nA parser result's business fields are under **`data.invoice`**. Calculated\namounts are directly under **`data.netAmount`**, etc., with no `data.totals` wrapper.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n Credit notes require `precedingInvoice`, whose date cannot be later than the\n credit note; other kinds cannot supply it. Credit-note amounts stay unsigned.\n- Lines: 1–1000, unique IDs. Quantities are positive, prices nonnegative,\n VAT rates at most 100. Decimal strings allow up to four\n fractional digits and no leading zeros. Totals/net amounts require exactly\n two fractional digits; do not convert through JavaScript Number.\n- Country codes: two uppercase letters. `payment.iban` must be valid.\n Required text is nonblank valid XML text. Limits: number/reference/line ID/VAT ID\n 100; names/address line/accountName 200; city 100; postalCode 20;\n line description and each note 4000; at most 100 notes.\n- Category S needs a positive rate; every other category uses `taxRate: \"0\"`\n and zero tax. E/AE/K/G/O need `taxExemptionReason` or a VATEX\n `taxExemptionReasonCode`; S/Z forbid both. O cannot be mixed with other\n categories and requires `vatId: \"\"` for both parties. A seller without a VAT\n ID needs `seller.id` and, outside O, `seller.taxRegistrationId`. AE/K need a\n buyer VAT ID, K/G a seller VAT ID, and K `deliverToCountryCode`.\n- `calculate` rounds each line half up to cents, then VAT per category and rate. It recalculates\n line `netAmount`; `serialize` also rejects supplied line/totals values that\n disagree. Render these calculated amounts in HTML instead of another arithmetic path.\n\n## Minimal supported invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture demonstrates the required fields; it is not a document to issue.\n\n```js\nconst invoice = {\n kind: \"invoice\",\n number: \"EXAMPLE-42\",\n invoiceDate: \"2026-09-15\",\n serviceDate: \"2026-09-15\",\n dueDate: \"2026-09-30\",\n currency: \"EUR\",\n seller: {\n name: \"Example Seller\", vatId: \"DE123456789\",\n address: { line1: \"Street 1\", city: \"Ulm\", postalCode: \"89073\", countryCode: \"DE\" },\n },\n buyer: {\n name: \"Example Buyer\", vatId: \"DE987654321\",\n address: { line1: \"Street 2\", city: \"Berlin\", postalCode: \"10115\", countryCode: \"DE\" },\n },\n buyerReference: \"ORDER-42\",\n payment: { iban: \"DE89370400440532013000\", accountName: \"Example Seller\" },\n lines: [{ id: \"1\", name: \"Service\", quantity: \"2.0000\", unitPrice: \"50.0000\", unitCode: \"HUR\", taxRate: \"19.00\" }],\n};\nconst result = einvoice.serialize(invoice, { format: \"zugferd-2.5-en16931\" });\nif (!result.ok) throw new Error(JSON.stringify(result.error));\nawait files.save(new Blob([result.data.bytes], { type: \"application/xml\" }), \"invoice.xml\");\n```\n\nNever infer a missing VAT identifier, tax category, exemption reason, account\nor business reference merely to satisfy input validation.\n\n## Reading received invoices\n\n`{mode: \"incoming\"}` (plus the same limits) reads a broader separate model:\nalso XRechnung 3.0/2.3 CII, other currencies, discounts, prepayments, all\npayment means and optional references. `data` is\n`{format: \"cii-en16931\", profile, xml, invoice, unmapped, filename?}`.\nAmounts are declared strings, never recalculated; O lines have no `taxRate`.\n`unmapped` lists supplementary XML elements and attributes with their paths; review it before\naccounting. Do not pass this `invoice` to `validate` or `serialize`.\n\nFor an invoice PDF, pass `serialized.data.xml` to\n[`pdf.facturX`](pdf.md) with profile `\"EN 16931\"` and matching HTML.\nNumbering, business mapping, issuance and persistence belong to the app.\n"
60
60
  },
61
61
  {
62
62
  "path": "references/examples.md",
@@ -41,6 +41,7 @@ const QuerySchema = z
41
41
  parent_group_id: z.uuid().optional(),
42
42
  managed_by_user_id: z.uuid().optional(),
43
43
  recursive: z.enum(["true", "false"]).optional(),
44
+ include_personal: z.enum(["true", "false"]).optional(),
44
45
  })
45
46
  .refine(
46
47
  (value) => {
@@ -98,7 +99,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
98
99
  tags: ["Accounts"],
99
100
  summary: "List mixed users and groups",
100
101
  description:
101
- "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account.",
102
+ "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account. Personal Linux groups (a user's private primary group, flagged with `personalOwner`) are left out unless `include_personal=true`; exact `group_ids` lookups and relation filters always return them.",
102
103
  ...requiresAuth,
103
104
  responses: {
104
105
  200: jsonResponse(EntitiesListResponseSchema, "Paginated mixed entity list"),
@@ -159,6 +160,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
159
160
  parentGroupId: query.parent_group_id,
160
161
  managedByUserId: query.managed_by_user_id,
161
162
  recursive: query.recursive === "true",
163
+ includePersonal: query.include_personal === "true",
162
164
  });
163
165
 
164
166
  return respond(c, {
@@ -113,7 +113,10 @@ export const createAppApprovalRoutes = (service: Service = appApproval) => {
113
113
  )
114
114
  .post("/login/status", v("json", AppLoginReferenceSchema), async (c) => {
115
115
  const input = c.req.valid("json");
116
- return c.json(await service.browserStatus(input.requestId, input.browserSecret));
116
+ // Hold a pending request for one poll interval: the browser learns a decision at once,
117
+ // keeps its request cadence, and stays below Bun's 10-second idle timeout.
118
+ const hold = { ms: APP_APPROVAL_LIMITS.pollSeconds * 1000, signal: c.req.raw.signal };
119
+ return c.json(await service.browserStatus(input.requestId, input.browserSecret, hold));
117
120
  })
118
121
  .post(
119
122
  "/login/complete",
@@ -215,6 +215,12 @@ const connect = async (options: {
215
215
  throw new AppApprovalClientError("INVALID_RESPONSE");
216
216
  return result;
217
217
  },
218
+ /** Hands this device's push token to the Cloud. Older Clouds answer HTTP 400. */
219
+ push: async (device: AppApprovalDevice, token: string, signal?: AbortSignal) => {
220
+ const result = await command(device, { operation: "push", token }, signal);
221
+ if (!("state" in result) || result.state !== "updated") throw new AppApprovalClientError("INVALID_RESPONSE");
222
+ return result;
223
+ },
218
224
  revoke: async (device: AppApprovalDevice, signal?: AbortSignal) => {
219
225
  const result = await command(device, { operation: "revoke" }, signal);
220
226
  if (!("state" in result) || result.state !== "revoked") throw new AppApprovalClientError("INVALID_RESPONSE");
@@ -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
+ };
@@ -53,6 +53,8 @@ export const AppDeviceMutationSchema = z.discriminatedUnion("operation", [
53
53
  export const AppDeviceCommandSchema = z.discriminatedUnion("operation", [
54
54
  z.object({ operation: z.literal("pending") }).strict(),
55
55
  z.object({ operation: z.literal("revoke") }).strict(),
56
+ /** Stores the authenticator's opaque push token for sign-in wake-ups on this device. */
57
+ z.object({ operation: z.literal("push"), token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict(),
56
58
  z
57
59
  .object({
58
60
  operation: z.literal("decide"),
@@ -91,6 +93,7 @@ export const appDeviceProofMessage = (proof: AppDeviceProof): string => {
91
93
  proof.expiresAt,
92
94
  c.operation,
93
95
  ...(c.operation === "decide" ? [c.requestId, c.challenge, c.comparison, c.decision] : []),
96
+ ...(c.operation === "push" ? [c.token] : []),
94
97
  ]);
95
98
  };
96
99
  export const appPairingProofMessage = (issuer: string, claim: Omit<z.infer<typeof AppPairingClaimSchema>, "signature">): string =>
@@ -182,7 +185,7 @@ export const AppDeviceResponseSchema = z.union([
182
185
  pollAfterSeconds: z.number().positive(),
183
186
  })
184
187
  .strict(),
185
- z.object({ state: z.enum(["approved", "denied", "revoked"]) }).strict(),
188
+ z.object({ state: z.enum(["approved", "denied", "revoked", "updated"]) }).strict(),
186
189
  ]);
187
190
  export const AppLoginStartResultSchema = z
188
191
  .object({ requestId: Id, browserSecret: Secret, comparison: Comparison, expiresAt: DateTime, pollAfterSeconds: z.number().positive() })
@@ -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
 
@@ -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
  });
@@ -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,46 @@ 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
+ /** Best-effort sign-in wake-ups through the trusted authenticator; returns the HTTP status. */
149
+ export type AppLoginPushSender = (appOrigin: string, body: { token: string; cloudOrigin: string; requestRef: string }) => Promise<number>;
150
+ export const fetchPushSender: AppLoginPushSender = async (appOrigin, body) => {
151
+ const response = await fetch(`${appOrigin}/push/notify`, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify(body),
155
+ redirect: "error",
156
+ signal: AbortSignal.timeout(5_000),
157
+ });
158
+ await response.body?.cancel();
159
+ return response.status;
160
+ };
161
+ const log = logger("app-approval");
162
+ const decisionTopic = lazySync((sync) =>
163
+ sync.topic<null>({
164
+ id: "cloud:app-login-decisions",
165
+ owner: "cloud",
166
+ retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
167
+ maxPayloadBytes: 2000,
168
+ }),
169
+ );
170
+ /** Core NATS broadcast reaches the waiting browser on any replica. */
171
+ export const syncLoginDecisionHints: AppLoginDecisionHints = {
172
+ publish: (requestId) => {
173
+ void Promise.resolve()
174
+ .then(() => decisionTopic().publish({ tenantId: requestId, data: null }))
175
+ .catch((error) => log.warn("App login decision hint failed", { error: String(error) }));
176
+ },
177
+ wait: async (requestId, signal) => {
178
+ for await (const _event of decisionTopic().live({ tenantId: requestId, signal })) return;
179
+ },
180
+ };
139
181
  const view = (row: DeviceRow): AppDeviceView => ({
140
182
  id: row.id,
141
183
  name: row.name,
@@ -163,6 +205,8 @@ const verify = async (key: AppDevicePublicKey, message: string, signature: strin
163
205
  export const createAppApprovalService = (
164
206
  db: SQL = sql,
165
207
  configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
208
+ hints: AppLoginDecisionHints = syncLoginDecisionHints,
209
+ push: AppLoginPushSender = fetchPushSender,
166
210
  ) => {
167
211
  const config = async (requireEnabled = true) => {
168
212
  const value = await configuration(requireEnabled);
@@ -215,6 +259,24 @@ export const createAppApprovalService = (
215
259
  return device;
216
260
  };
217
261
 
262
+ /** Runs after the login commit and never delays or fails it. The body names only
263
+ * this Cloud and the request id; a 410 means the phone's subscription is gone. */
264
+ const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string) => {
265
+ try {
266
+ const rows = await db<{ push_token: string }[]>`SELECT DISTINCT push_token FROM auth.app_devices
267
+ WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL LIMIT ${limits.devicesPerAccount}`;
268
+ await Promise.all(
269
+ rows.map(async ({ push_token: token }) => {
270
+ const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
271
+ if (status === 410)
272
+ await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
273
+ }),
274
+ );
275
+ } catch (error) {
276
+ log.warn("App login push wake-up failed", { error: error instanceof Error ? error.name : "UnknownError" });
277
+ }
278
+ };
279
+
218
280
  return {
219
281
  config,
220
282
  cleanup,
@@ -369,6 +431,7 @@ export const createAppApprovalService = (
369
431
  challenge = secret(),
370
432
  code = comparison(),
371
433
  expiresAt = future(limits.loginSeconds);
434
+ let owner: string | undefined;
372
435
  await db.begin(async (tx) => {
373
436
  const candidates = await tx<
374
437
  AccountRow[]
@@ -392,7 +455,10 @@ export const createAppApprovalService = (
392
455
  // public shape and a decoy pending transaction; no account enumeration.
393
456
  await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at)
394
457
  VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt})`;
458
+ owner = candidate?.id;
395
459
  });
460
+ // Not awaited: decoy and real requests answer alike, and push is only a wake-up.
461
+ if (owner) void wake(cfg, owner, id);
396
462
  return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
397
463
  },
398
464
  deviceCommand: async (request: AppDeviceRequest) => {
@@ -409,7 +475,7 @@ export const createAppApprovalService = (
409
475
  )
410
476
  return reject("FORBIDDEN", 403);
411
477
  await cleanup();
412
- return db.begin(async (tx) => {
478
+ const outcome = await db.begin(async (tx) => {
413
479
  const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
414
480
  if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
415
481
  return reject("FORBIDDEN", 403);
@@ -418,6 +484,10 @@ export const createAppApprovalService = (
418
484
  if (!used.length) return reject("CONFLICT", 409);
419
485
  await tx`UPDATE auth.app_devices SET last_used_at=now() WHERE id=${device.id}::uuid`;
420
486
  const command = proof.command;
487
+ if (command.operation === "push") {
488
+ await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
489
+ return { state: "updated" as const };
490
+ }
421
491
  if (command.operation === "revoke") {
422
492
  await tx`UPDATE auth.app_devices SET revoked_at=now() WHERE id=${device.id}::uuid`;
423
493
  await record(tx, "device.revoke", device.user_id, device.id);
@@ -460,12 +530,39 @@ export const createAppApprovalService = (
460
530
  await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
461
531
  return { state };
462
532
  });
533
+ // Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
534
+ if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
535
+ return outcome;
463
536
  },
464
- browserStatus: async (id: string, token: string) => {
537
+ /** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
538
+ browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
465
539
  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 };
540
+ const read = async () => {
541
+ const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
542
+ if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
543
+ const expiresIn = new Date(row.expires_at).getTime() - Date.now();
544
+ return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
545
+ };
546
+ const result = (state: string) => ({ state, pollAfterSeconds: limits.pollSeconds });
547
+ if (!hold) return result((await read()).state);
548
+ // Listen before the first read so a decision committed in between still wakes this request.
549
+ const waiting = new AbortController();
550
+ const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
551
+ const stop = () => waiting.abort();
552
+ hold.signal?.addEventListener("abort", stop, { once: true });
553
+ if (hold.signal?.aborted) stop();
554
+ const hint = hints.wait(id, waiting.signal).catch(() => {});
555
+ try {
556
+ const first = await read();
557
+ if (first.state !== "pending") return result(first.state);
558
+ const timer = setTimeout(stop, Math.min(hold.ms, first.expiresIn));
559
+ await Promise.race([hint, ended]);
560
+ clearTimeout(timer);
561
+ return result((await read()).state);
562
+ } finally {
563
+ hold.signal?.removeEventListener("abort", stop);
564
+ stop();
565
+ }
469
566
  },
470
567
  consumeLogin: async (id: string, token: string) => {
471
568
  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
 
@@ -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;
@@ -11,6 +11,10 @@
11
11
  * `:::warning Before deleting`.
12
12
  *
13
13
  * Supported types: note, info, success, warning, danger
14
+ *
15
+ * `notices: "minimal"` renders only the tone colour: no icon and no automatic
16
+ * label. The type name stays available to screen readers, and an explicit
17
+ * title is still shown.
14
18
  */
15
19
 
16
20
  import { NOTICE_CARD_CLASSES, NOTICE_CARD_ICONS, type NoticeTone } from "@k2b/ui";
@@ -50,7 +54,9 @@ const renderInlineContent = (content: string): string => {
50
54
  .replace(/\n/g, "<br>");
51
55
  };
52
56
 
53
- export function infoBlocksExtension(): MarkedExtension {
57
+ export type NoticeStyle = "card" | "minimal";
58
+
59
+ export function infoBlocksExtension(notices: NoticeStyle = "card"): MarkedExtension {
54
60
  return {
55
61
  extensions: [
56
62
  {
@@ -78,9 +84,14 @@ export function infoBlocksExtension(): MarkedExtension {
78
84
  const blockType = token.blockType as BlockType;
79
85
  const config = blockConfig[blockType];
80
86
  const content = escapeHtml(token.content as string);
81
- const title = escapeHtml((token.title as string | undefined) ?? config.label);
82
87
  const renderedContent = renderInlineContent(content);
83
88
 
89
+ if (notices === "minimal") {
90
+ const title = token.title ? `<p class="${NOTICE_CARD_CLASSES.title}">${escapeHtml(token.title as string)}</p>` : "";
91
+ return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}" role="note"><span class="sr-only">${config.label}: </span>${title}<div class="${NOTICE_CARD_CLASSES.body}">${renderedContent}</div></aside>`;
92
+ }
93
+
94
+ const title = escapeHtml((token.title as string | undefined) ?? config.label);
84
95
  return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}">
85
96
  <div class="${NOTICE_CARD_CLASSES.inner}">
86
97
  <i class="${NOTICE_CARD_ICONS[config.tone]} ${NOTICE_CARD_CLASSES.icon}" aria-hidden="true"></i>
@@ -11,7 +11,7 @@ import { markdownClient } from "./client";
11
11
  import { codeExtension } from "./extensions/code";
12
12
  import { guidedHelpExtension } from "./extensions/guided-help";
13
13
  import { imagesExtension } from "./extensions/images";
14
- import { infoBlocksExtension } from "./extensions/info-blocks";
14
+ import { infoBlocksExtension, type NoticeStyle } from "./extensions/info-blocks";
15
15
  import { katexExtension } from "./extensions/katex";
16
16
  import { linksExtension } from "./extensions/links";
17
17
  import { markExtension } from "./extensions/mark";
@@ -22,7 +22,7 @@ import { taskListExtension } from "./extensions/task-list";
22
22
  // Create a configured marked instance
23
23
  type MarkdownProfile = "content" | "help";
24
24
 
25
- const createMarked = (profile: MarkdownProfile = "content") => {
25
+ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle = "card") => {
26
26
  const marked = new Marked();
27
27
 
28
28
  marked.use({
@@ -32,7 +32,7 @@ const createMarked = (profile: MarkdownProfile = "content") => {
32
32
 
33
33
  // Apply extensions in order
34
34
  // Note: katexExtension must come before codeExtension to handle ```math blocks
35
- marked.use(infoBlocksExtension());
35
+ marked.use(infoBlocksExtension(notices));
36
36
  marked.use(taskListExtension());
37
37
  marked.use(tablesExtension());
38
38
  marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank" }));
@@ -48,8 +48,16 @@ const createMarked = (profile: MarkdownProfile = "content") => {
48
48
  };
49
49
 
50
50
  const marked = createMarked();
51
+ const minimalNoticeMarked = createMarked("content", "minimal");
51
52
  const helpMarked = createMarked("help");
52
53
 
54
+ export type MarkdownRenderOptions = {
55
+ /** `"minimal"` shows notices as tone colour only; the type name stays for screen readers. */
56
+ notices?: NoticeStyle;
57
+ };
58
+
59
+ const markedFor = (options: MarkdownRenderOptions): Marked => (options.notices === "minimal" ? minimalNoticeMarked : marked);
60
+
53
61
  const sanitizeRenderedHtml = (html: string): string =>
54
62
  sanitizeHtml(html, {
55
63
  allowedTags: [
@@ -91,6 +99,7 @@ const sanitizeRenderedHtml = (html: string): string =>
91
99
  "*": ["aria-hidden", "aria-label", "class", "data-help-icon", "data-tone", "id", "title"],
92
100
  a: ["href", "name", "rel", "target", "title"],
93
101
  annotation: ["encoding"],
102
+ aside: [{ name: "role", multiple: false, values: ["note"] }],
94
103
  code: ["class"],
95
104
  div: ["class", "data-block-name", "style"],
96
105
  img: ["alt", "class", "height", "loading", "src", "title", "width", "style"],
@@ -146,10 +155,10 @@ const sanitizeRenderedHtml = (html: string): string =>
146
155
  * @see MarkdownView component for displaying the rendered HTML
147
156
  * @see initMarkdownEnhancements for client-side Mermaid support
148
157
  */
149
- export function renderMarkdown(content: string): string {
158
+ export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
150
159
  if (!content || typeof content !== "string") return "";
151
160
 
152
- const html = marked.parse(content);
161
+ const html = markedFor(options).parse(content);
153
162
  if (typeof html !== "string") return "";
154
163
 
155
164
  return sanitizeRenderedHtml(html);
@@ -158,10 +167,10 @@ export function renderMarkdown(content: string): string {
158
167
  /**
159
168
  * Render markdown to HTML synchronously.
160
169
  */
161
- export function renderMarkdownSync(content: string): string {
170
+ export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
162
171
  if (!content || typeof content !== "string") return "";
163
172
 
164
- const html = marked.parse(content);
173
+ const html = markedFor(options).parse(content);
165
174
  if (typeof html !== "string") return "";
166
175
 
167
176
  return sanitizeRenderedHtml(html);