@k2b/cloud 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +14 -4
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/approval-routes.ts +40 -4
  8. package/src/ai/approvals.ts +68 -10
  9. package/src/ai/chat/blocks.tsx +53 -32
  10. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  11. package/src/ai/chat/message-actions.tsx +3 -1
  12. package/src/ai/chat/messages.ts +18 -0
  13. package/src/ai/chat/tool-groups.ts +66 -12
  14. package/src/ai/chat/tool-names.ts +4 -2
  15. package/src/ai/chat/turn-layout.ts +8 -0
  16. package/src/ai/chat/turn-view.tsx +94 -25
  17. package/src/ai/chat/web-tools.tsx +95 -1
  18. package/src/ai/client/controller.ts +6 -3
  19. package/src/ai/code-runtime-tools.ts +50 -17
  20. package/src/ai/code-source-contracts.ts +25 -21
  21. package/src/ai/code-source-tools.ts +3 -1
  22. package/src/ai/executor.ts +170 -14
  23. package/src/ai/fetch-file-tool.ts +41 -4
  24. package/src/ai/firecrawl-tools.ts +87 -4
  25. package/src/ai/index.ts +8 -0
  26. package/src/ai/migrate.ts +113 -2
  27. package/src/ai/protocol.ts +22 -1
  28. package/src/ai/routes.ts +11 -0
  29. package/src/ai/runtime.ts +46 -7
  30. package/src/ai/store.ts +66 -1
  31. package/src/ai/tool-audit.ts +11 -1
  32. package/src/ai/tools.ts +91 -3
  33. package/src/ai/types.ts +39 -0
  34. package/src/ai/web-provenance.ts +103 -0
  35. package/src/api/capabilities.ts +3 -2
  36. package/src/api/widgets.ts +36 -5
  37. package/src/browser/CloudResourceSearch.tsx +1 -1
  38. package/src/browser/command-shortcuts.ts +21 -6
  39. package/src/browser/spotlight-position.ts +4 -1
  40. package/src/browser/widgets.ts +7 -5
  41. package/src/capabilities/executions.ts +3 -0
  42. package/src/cli/commands.ts +20 -0
  43. package/src/cli/index.ts +16 -0
  44. package/src/cli/plugin.ts +30 -0
  45. package/src/contracts/app.ts +34 -2
  46. package/src/contracts/registry.ts +9 -1
  47. package/src/contracts/widgets.ts +47 -2
  48. package/src/desktop/solid.tsx +6 -7
  49. package/src/server/index.ts +1 -0
  50. package/src/server/widget-request.ts +27 -0
  51. package/src/services/approvals/index.ts +225 -0
  52. package/src/services/approvals/migrate.ts +33 -0
  53. package/src/shared/app-presentation.ts +11 -0
  54. package/src/shared/email-html.ts +43 -8
  55. package/src/ssr/AdminLayout.tsx +4 -7
  56. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  57. package/src/ssr/Layout.tsx +38 -14
  58. package/src/ssr/LayoutFooter.tsx +1 -1
  59. package/src/ssr/LayoutHeader.tsx +17 -20
  60. package/src/ssr/LayoutHelp.tsx +1 -1
  61. package/src/ssr/LayoutRail.tsx +1 -2
  62. package/src/ssr/PageTrail.tsx +23 -0
  63. package/src/ssr/PageTrailRow.island.tsx +20 -0
  64. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  65. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  66. package/src/ssr/islands/index.ts +1 -0
  67. package/src/ssr/layout-runtime.ts +60 -0
  68. package/src/ssr/mobile-menu-history.ts +1 -1
  69. package/src/ssr/page-trail-runtime.ts +313 -0
  70. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  71. package/src/ssr/page-trail.ts +59 -0
  72. package/src/ssr/platform-messages.ts +4 -0
  73. package/src/styles/effects.css +21 -4
  74. package/src/styles/global.css +35 -2
  75. package/src/styles/utilities-layout.css +24 -1
  76. package/src/styles/utilities-navigation.css +6 -18
package/src/cli/index.ts CHANGED
@@ -51,6 +51,12 @@ export type CloudCliContext = {
51
51
  profiles?: {
52
52
  saveClientCredentials: (input: CloudCliClientCredentialsProfile) => Promise<void>;
53
53
  };
54
+ /**
55
+ * Open an `http:` or `https:` URL in the user's browser, when the host can.
56
+ * Resolves `false` when nothing was opened, so the command still prints the
57
+ * link. Absent in hosts without a local browser.
58
+ */
59
+ openUrl?: (url: string) => Promise<boolean>;
54
60
  };
55
61
 
56
62
  /** A profile that signs in with the OAuth client-credentials grant. */
@@ -70,6 +76,16 @@ export type CloudCliClientCredentialsProfile = {
70
76
  export type CloudCliModule = {
71
77
  name: string;
72
78
  summary: string;
79
+ /**
80
+ * `cld <alias> …` runs `cld <name> …`. Names match CLOUD_CLI_MODULE_NAME;
81
+ * cld uses them only when exactly one installed module claims them and no built-in or module has that name.
82
+ */
83
+ alias?: string;
84
+ /**
85
+ * Top-level names mapped to space-separated command paths relative to this module, never preset flags or args.
86
+ * Names match CLOUD_CLI_MODULE_NAME; cld uses them only when exactly one installed module claims them and no built-in or module has that name.
87
+ */
88
+ shortcuts?: Readonly<Record<string, string>>;
73
89
  booleanFlags?: readonly string[];
74
90
  requiresCloud?: boolean;
75
91
  requiresCloudFor?: (args: string[], flags: CloudCliFlags) => boolean;
package/src/cli/plugin.ts CHANGED
@@ -67,6 +67,27 @@ export const cloudCliPluginDigest = (files: readonly Pick<CloudCliPluginFile, "p
67
67
 
68
68
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
69
69
 
70
+ /** Whether an alias is a valid extra name for this module. */
71
+ export const isCloudCliAlias = (value: unknown, name: string): value is string =>
72
+ typeof value === "string" && CLOUD_CLI_MODULE_NAME.test(value) && value !== name;
73
+
74
+ /** Validate short names and command-path syntax without loading a module's command tree. */
75
+ export const isCloudCliShortcuts = (value: unknown, name: string, alias?: string): value is Record<string, string> =>
76
+ isRecord(value) &&
77
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) &&
78
+ Object.entries(value).every(
79
+ ([key, target]) =>
80
+ CLOUD_CLI_MODULE_NAME.test(key) &&
81
+ key !== name &&
82
+ key !== alias &&
83
+ typeof target === "string" &&
84
+ target.trim() !== "" &&
85
+ target
86
+ .trim()
87
+ .split(/\s+/)
88
+ .every((segment) => !segment.startsWith("-")),
89
+ );
90
+
70
91
  /** Validate a served manifest, including its digest. Throws an `Error` naming the first problem. */
71
92
  export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
72
93
  if (!isRecord(value)) throw new Error("plugin manifest is not an object");
@@ -111,6 +132,15 @@ export const validateCloudCliModule = (value: unknown): CloudCliModule => {
111
132
  if (typeof value.name !== "string" || !CLOUD_CLI_MODULE_NAME.test(value.name)) {
112
133
  throw new Error("default export needs a lowercase kebab-case name");
113
134
  }
135
+ if (value.alias !== undefined && !isCloudCliAlias(value.alias, value.name)) {
136
+ throw new Error("alias must be a lowercase kebab-case name different from the module name");
137
+ }
138
+ if (
139
+ value.shortcuts !== undefined &&
140
+ !isCloudCliShortcuts(value.shortcuts, value.name, typeof value.alias === "string" ? value.alias : undefined)
141
+ ) {
142
+ throw new Error("shortcuts must be a plain object with unique kebab-case names and non-empty command paths without flags");
143
+ }
114
144
  if (typeof value.summary !== "string") throw new Error("default export needs a string summary");
115
145
  if (typeof value.run !== "function") throw new Error("default export needs a run function");
116
146
  for (const key of ["help", "requiresCloudFor"] as const) {
@@ -2,7 +2,7 @@ import type { Sync } from "@k2b/sync";
2
2
  import type { PlatformPermission } from "./outgoing-mail";
3
3
  import type { AppRegistryHelpSummary } from "./registry";
4
4
  import type { Role } from "./shared";
5
- import type { DashboardWidgetPresentation } from "./widgets";
5
+ import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
6
6
 
7
7
  /**
8
8
  * One link entry contributed by an app to the global legal/info footer
@@ -69,6 +69,13 @@ export type AppPresentationTranslation = {
69
69
  searchLinks?: Readonly<Record<string, string>>;
70
70
  /** Search-link descriptions keyed by their stable same-origin href. */
71
71
  searchLinkDescriptions?: Readonly<Record<string, string>>;
72
+ /** Dashboard widget titles and descriptions keyed by `widgets[].id`. */
73
+ widgets?: Readonly<Record<string, AppWidgetTranslation>>;
74
+ };
75
+
76
+ export type AppWidgetTranslation = {
77
+ title?: string;
78
+ description?: string;
72
79
  };
73
80
 
74
81
  export type AppPresentationCatalog = {
@@ -161,7 +168,32 @@ export type WidgetEndpoint = {
161
168
  id: string;
162
169
  /** Absolute path on the app's HTTP service, e.g. "/api/accounts/widget/open-requests". */
163
170
  path: string;
164
- /** Optional initial layout recommendation. Explicit user choices win. */
171
+ /**
172
+ * Name of the widget in the dashboard and its widget gallery, up to 80 characters, in the app's `baseLocale`;
173
+ * translate it with `presentation.translations[locale].widgets[id].title`. Defaults to the app's name.
174
+ */
175
+ title?: string;
176
+ /** One sentence for the widget gallery, up to 200 characters; translated like `title`. Defaults to the app's description. */
177
+ description?: string;
178
+ /** Sizes the widget offers on the board. Defaults to `["large"]`; offer smaller sizes only when the handler fills them. */
179
+ sizes?: readonly DashboardWidgetSize[];
180
+ /** Size the widget starts in, one of `sizes`. Defaults to the largest offered size. */
181
+ defaultSize?: DashboardWidgetSize;
182
+ /**
183
+ * Put the widget on the default board of everyone who may see it and list it under "Suggested for you" in the
184
+ * gallery. Suggest only widgets that are useful without any setup.
185
+ */
186
+ suggest?: boolean;
187
+ /**
188
+ * Show the widget in the gallery and on the default board only to people with one of these roles, like
189
+ * `nav.requiresRoles`. Visibility only: the handler still authorizes every request and answers `403`.
190
+ */
191
+ requiresRoles?: readonly Role[];
192
+ /**
193
+ * @deprecated The dashboard has no zones or widths any more; declare `sizes` and `defaultSize` instead. Keep an
194
+ * existing value: the dashboard reads it only to convert a board saved before sizes existed into the one the person
195
+ * saw.
196
+ */
165
197
  presentation?: DashboardWidgetPresentation;
166
198
  };
167
199
 
@@ -2,7 +2,7 @@ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPre
2
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
3
3
  import type { PlatformPermission } from "./outgoing-mail";
4
4
  import type { Role } from "./shared";
5
- import type { DashboardWidgetPresentation } from "./widgets";
5
+ import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
6
6
 
7
7
  /**
8
8
  * App-registry entry type. Populated internally by `defineApp()` + the
@@ -52,10 +52,18 @@ export type AppRegistryLegalLink = {
52
52
  icon?: string;
53
53
  };
54
54
 
55
+ /** A widget declaration as the registry carries it; see `WidgetEndpoint`. Older apps send only `id` and `path`. */
55
56
  export type AppRegistryWidget = {
56
57
  id: string;
57
58
  /** Absolute path on the app's HTTP service, e.g. "/api/quotes/widget/random". */
58
59
  path: string;
60
+ title?: string;
61
+ description?: string;
62
+ sizes?: readonly DashboardWidgetSize[];
63
+ defaultSize?: DashboardWidgetSize;
64
+ suggest?: boolean;
65
+ requiresRoles?: readonly Role[];
66
+ /** @deprecated Read only to convert dashboard boards saved before sizes existed. */
59
67
  presentation?: DashboardWidgetPresentation;
60
68
  };
61
69
 
@@ -38,18 +38,63 @@ const WidgetHrefSchema = z
38
38
  export const WidgetToneSchema = z.enum(["emerald", "amber", "red", "blue", "zinc"]);
39
39
  export type WidgetTone = z.infer<typeof WidgetToneSchema>;
40
40
 
41
+ /** @deprecated The dashboard no longer has zones; declare `sizes` and `defaultSize` instead. */
41
42
  export type DashboardWidgetZone = "focus" | "overview" | "context";
43
+ /** @deprecated The dashboard no longer has spans; declare `sizes` and `defaultSize` instead. */
42
44
  export type DashboardWidgetSpan = "standard" | "wide";
43
45
 
44
46
  /**
45
- * Stable, app-owned recommendation for a widget's initial dashboard layout.
46
- * Explicit user layout settings always take precedence.
47
+ * @deprecated The dashboard has no zones or widths any more. Declare `sizes` and `defaultSize` on the widget instead;
48
+ * the dashboard reads this recommendation only to convert a board saved before sizes existed.
47
49
  */
48
50
  export type DashboardWidgetPresentation = {
49
51
  defaultZone?: DashboardWidgetZone;
50
52
  defaultSpan?: DashboardWidgetSpan;
51
53
  };
52
54
 
55
+ /**
56
+ * The three sizes a widget can take on the dashboard board, which has four columns on wider screens and two on
57
+ * phones: `small` is one column and one row, `medium` two columns and one row, and `large` two columns and two rows.
58
+ * On a phone, `small` takes half the width and `medium` and `large` the full width.
59
+ */
60
+ export const DASHBOARD_WIDGET_SIZES = ["small", "medium", "large"] as const;
61
+ export type DashboardWidgetSize = (typeof DASHBOARD_WIDGET_SIZES)[number];
62
+
63
+ /** Longest declared widget title, in characters, the same bound as a shortcut title. */
64
+ export const WIDGET_TITLE_MAX_LENGTH = 80;
65
+ /** Longest declared widget description, in characters: one sentence in the widget gallery. */
66
+ export const WIDGET_DESCRIPTION_MAX_LENGTH = 200;
67
+
68
+ export const isDashboardWidgetSize = (value: unknown): value is DashboardWidgetSize =>
69
+ DASHBOARD_WIDGET_SIZES.some((size) => size === value);
70
+
71
+ /**
72
+ * The sizes a widget offers and the size it starts with. A widget that declares no valid size offers only `large`,
73
+ * the height every widget had before sizes existed; without a valid `defaultSize`, it starts at its largest size.
74
+ */
75
+ export const resolveWidgetSizes = (declaration: {
76
+ sizes?: readonly unknown[];
77
+ defaultSize?: unknown;
78
+ }): { sizes: DashboardWidgetSize[]; defaultSize: DashboardWidgetSize } => {
79
+ const declared = DASHBOARD_WIDGET_SIZES.filter((size) => declaration.sizes?.includes(size));
80
+ const sizes: DashboardWidgetSize[] = declared.length > 0 ? declared : ["large"];
81
+ const defaultSize =
82
+ isDashboardWidgetSize(declaration.defaultSize) && sizes.includes(declaration.defaultSize) ? declaration.defaultSize : sizes.at(-1)!;
83
+ return { sizes, defaultSize };
84
+ };
85
+
86
+ /** `requested` when the widget offers it, otherwise the widget's default size. */
87
+ export const fitWidgetSize = (
88
+ requested: unknown,
89
+ widget: { sizes: readonly DashboardWidgetSize[]; defaultSize: DashboardWidgetSize },
90
+ ): DashboardWidgetSize => (isDashboardWidgetSize(requested) && widget.sizes.includes(requested) ? requested : widget.defaultSize);
91
+
92
+ /** What the dashboard asks a widget handler for, read with `getWidgetRequest(c)`. */
93
+ export type WidgetRequest = {
94
+ /** The size the widget is shown in, always one the widget declares. */
95
+ size: DashboardWidgetSize;
96
+ };
97
+
53
98
  export const WidgetAccentSchema = z
54
99
  .object({
55
100
  tone: WidgetToneSchema,
@@ -1,3 +1,4 @@
1
+ import { suppressTextSelection } from "@k2b/ui";
1
2
  import { deserialize as deserializeProps, serialize as serializeProps } from "seroval";
2
3
  import type { Component, JSX, ParentProps } from "solid-js";
3
4
  import { children, createContext, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
@@ -483,10 +484,11 @@ const startDesktopResize = (event: PointerEvent, onMove: (dx: number, dy: number
483
484
  const startX = event.clientX;
484
485
  const startY = event.clientY;
485
486
  const move = (next: PointerEvent) => onMove(next.clientX - startX, next.clientY - startY);
487
+ const releaseTextSelection = suppressTextSelection(event.pointerId);
486
488
  const stop = () => {
487
489
  window.removeEventListener("pointermove", move);
488
490
  window.removeEventListener("pointerup", stop);
489
- document.body.style.userSelect = "";
491
+ releaseTextSelection();
490
492
  document.body.style.cursor = "";
491
493
  };
492
494
  window.addEventListener("pointermove", move);
@@ -631,10 +633,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
631
633
 
632
634
  const resizeSidebar = (event: PointerEvent) => {
633
635
  const pane = sidebar();
634
- if (!pane?.resizable) return;
636
+ if (!pane?.resizable || event.button !== 0) return;
635
637
  const start = sidebarOpen() ? sidebarSize() : sidebarColumnSize();
636
638
  document.body.style.cursor = "col-resize";
637
- document.body.style.userSelect = "none";
638
639
  startDesktopResize(event, (dx) => {
639
640
  const raw = start + dx;
640
641
  if (shouldRail(pane, raw)) {
@@ -650,10 +651,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
650
651
 
651
652
  const resizeRight = (event: PointerEvent) => {
652
653
  const pane = right();
653
- if (!pane?.resizable) return;
654
+ if (!pane?.resizable || event.button !== 0) return;
654
655
  const start = rightOpen() ? rightSize() : rightColumnSize();
655
656
  document.body.style.cursor = "col-resize";
656
- document.body.style.userSelect = "none";
657
657
  startDesktopResize(event, (dx) => {
658
658
  const raw = start - dx;
659
659
  if (shouldRail(pane, raw)) {
@@ -669,10 +669,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
669
669
 
670
670
  const resizeBottom = (event: PointerEvent) => {
671
671
  const pane = bottom();
672
- if (!pane?.resizable) return;
672
+ if (!pane?.resizable || event.button !== 0) return;
673
673
  const start = bottomOpen() ? bottomSize() : bottomRowSize();
674
674
  document.body.style.cursor = "row-resize";
675
- document.body.style.userSelect = "none";
676
675
  startDesktopResize(event, (_dx, dy) => {
677
676
  const raw = start - dy;
678
677
  if (shouldRail(pane, raw)) {
@@ -94,3 +94,4 @@ export {
94
94
  updateAccess,
95
95
  } from "./services";
96
96
  export { getDateConfig, getTimeZone, TIMEZONE_COOKIE, time } from "./time";
97
+ export { getWidgetRequest } from "./widget-request";
@@ -0,0 +1,27 @@
1
+ import type { Context } from "hono";
2
+ import { type DashboardWidgetSize, fitWidgetSize, resolveWidgetSizes, type WidgetRequest } from "../contracts/widgets";
3
+
4
+ /** The query parameter Core uses to ask a widget handler for one size. */
5
+ export const WIDGET_SIZE_PARAM = "size";
6
+
7
+ const requests = new WeakMap<Request, WidgetRequest>();
8
+
9
+ /**
10
+ * Records what the dashboard asked a declared widget for, already fitted to the declaration. Only the framework's
11
+ * internal widget route calls this, before it runs the handler.
12
+ */
13
+ export const setWidgetRequest = (
14
+ c: Context,
15
+ declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize },
16
+ ): WidgetRequest => {
17
+ const request = { size: fitWidgetSize(c.req.query(WIDGET_SIZE_PARAM), resolveWidgetSizes(declaration)) };
18
+ requests.set(c.req.raw, request);
19
+ return request;
20
+ };
21
+
22
+ /**
23
+ * What the dashboard asks this widget handler for. `size` is always one of the sizes the widget declares; return the
24
+ * content that fits it, for example only the one number in `small`. Outside a dashboard invocation, such as a request
25
+ * to the handler's own public route, the request is `large`, the height every widget had before sizes existed.
26
+ */
27
+ export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large" };
@@ -0,0 +1,225 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { capabilityRequestHash } from "../../capabilities/claims";
3
+ import type { CapabilityActionReview } from "../../contracts/capabilities";
4
+ import { isAppSessionActor } from "../../server/actor";
5
+ import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
6
+ import { audit } from "../audit";
7
+ import { toPgTextArray } from "../postgres";
8
+
9
+ export { migrateApprovals } from "./migrate";
10
+
11
+ // Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
12
+ export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
13
+ export type ApprovalDecisionChannel = "web_session" | "app_session" | "oauth" | "api_key" | "invocation";
14
+ export type ApprovalRequesterKind = "assistant_turn" | "studio_app";
15
+ export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired" | "cancelled" | "consumed";
16
+ export type ApprovalAssurance = "standard" | "confirm";
17
+
18
+ /**
19
+ * `standard` when the person may remember the approval of this call: an Action with `approval: "rememberable"` whose
20
+ * review names a scope for these arguments, a tool whose policy allows remembering, or a website. Otherwise `confirm`.
21
+ */
22
+ export const approvalAssurance = (rememberable: boolean): ApprovalAssurance => (rememberable ? "standard" : "confirm");
23
+ export const approvalArgsDigest = (input: unknown): string => capabilityRequestHash(input);
24
+ export const approvalReviewDigest = (review: CapabilityActionReview | null | undefined): string => capabilityRequestHash(review ?? null);
25
+ export const approvalDecisionChannel = (input: {
26
+ credentialKind?: RequestCredentialKind;
27
+ actor?: RequestActor;
28
+ }): ApprovalDecisionChannel =>
29
+ input.credentialKind === "session"
30
+ ? isAppSessionActor(input.actor)
31
+ ? "app_session"
32
+ : "web_session"
33
+ : (input.credentialKind ?? "invocation");
34
+ export const approvalScopeFromToolName = (name: string): { appId: string; action: string } => {
35
+ const dot = name.indexOf(".");
36
+ return dot < 0 ? { appId: "assistant", action: name } : { appId: name.slice(0, dot), action: name.slice(dot + 1) };
37
+ };
38
+
39
+ export type ApprovalRequestInput = {
40
+ appId: string;
41
+ action: string;
42
+ approvalScope?: string | null;
43
+ requester: { kind: ApprovalRequesterKind; ref: string; appId: string; title?: string | null };
44
+ subjectId: string;
45
+ approverUserId: string;
46
+ assurance: ApprovalAssurance;
47
+ sentence: string;
48
+ review?: CapabilityActionReview | null;
49
+ input: unknown;
50
+ expiresInMs: number;
51
+ };
52
+ export type ApprovalRequest = {
53
+ id: string;
54
+ appId: string;
55
+ action: string;
56
+ approvalScope: string | null;
57
+ requesterKind: ApprovalRequesterKind;
58
+ requesterRef: string;
59
+ requesterAppId: string;
60
+ requesterTitle: string | null;
61
+ subjectId: string;
62
+ approverUserId: string;
63
+ assurance: ApprovalAssurance;
64
+ sentence: string;
65
+ details: CapabilityActionReview | null;
66
+ argsDigest: string;
67
+ reviewDigest: string;
68
+ status: ApprovalStatus;
69
+ decidedVia: ApprovalDecisionChannel | null;
70
+ decidedAt: Date | null;
71
+ consumedAt: Date | null;
72
+ createdAt: Date;
73
+ expiresAt: Date;
74
+ };
75
+ // SQL aliases keep the public row shape identical for reads and locked transaction reads. A function, because a
76
+ // fragment built at import time would bind the default sql handle before a test can choose its database.
77
+ const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
78
+ requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
79
+ requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
80
+ assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
81
+ decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
82
+ created_at AS "createdAt", expires_at AS "expiresAt"`;
83
+
84
+ const transaction = <T>(db: SQL | undefined, run: (tx: SQL) => Promise<T>): Promise<T> => (db && db !== sql ? run(db) : sql.begin(run));
85
+
86
+ export type ApprovalRequestResult = { ok: true; id: string } | { ok: false; code: "CONFLICT" };
87
+ /**
88
+ * Records the question once per requester reference. Asking again, for example after a worker restart, returns the
89
+ * open request unchanged: its snapshot is what the person may already see, so a changed effect fails `consume` and
90
+ * asks again instead of rebinding that decision. A reference whose request is decided, expired, or another person's
91
+ * is a conflict.
92
+ */
93
+ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<ApprovalRequestResult> => {
94
+ // Zero or less records a request that is already expired, such as a call that waited too long before the upgrade.
95
+ const expiresInMs = Number.isNaN(input.expiresInMs)
96
+ ? APPROVAL_MAX_WAIT_MS
97
+ : Math.max(0, Math.min(input.expiresInMs, APPROVAL_MAX_WAIT_MS));
98
+ return transaction(db, async (tx): Promise<ApprovalRequestResult> => {
99
+ const [row] = await tx<{ id: string }[]>`
100
+ INSERT INTO approvals.requests (app_id, action, approval_scope, requester_kind, requester_ref, requester_app_id,
101
+ requester_title, subject_id, approver_user_id, assurance, sentence, details, args_digest, review_digest, expires_at)
102
+ VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
103
+ ${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
104
+ ${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
105
+ ${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
106
+ ON CONFLICT (requester_kind, requester_ref) DO NOTHING
107
+ RETURNING id
108
+ `;
109
+ if (row) return { ok: true, id: row.id };
110
+ const [open] = await tx<{ id: string }[]>`SELECT id FROM approvals.requests
111
+ WHERE requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
112
+ AND status='pending' AND expires_at > now() AND subject_id=${input.subjectId}::uuid
113
+ AND approver_user_id=${input.approverUserId}::uuid AND app_id=${input.appId} AND action=${input.action}`;
114
+ return open ? { ok: true, id: open.id } : { ok: false, code: "CONFLICT" };
115
+ });
116
+ };
117
+
118
+ export const get = async (id: string, db?: SQL): Promise<ApprovalRequest | null> =>
119
+ transaction(db, async (tx) => {
120
+ const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests WHERE id=${id}::uuid`;
121
+ return row ?? null;
122
+ });
123
+
124
+ export type ApprovalDecisionResult =
125
+ | { ok: true; status: ApprovalStatus; replay: boolean }
126
+ | { ok: false; code: "EXPIRED" | "NOT_FOUND" }
127
+ | { ok: false; code: "ALREADY_DECIDED"; status: ApprovalStatus };
128
+ export const decide = async (
129
+ id: string,
130
+ decision: { approved: boolean },
131
+ actor: { userId: string; via: ApprovalDecisionChannel },
132
+ db?: SQL,
133
+ ): Promise<ApprovalDecisionResult> => {
134
+ const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
135
+ const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
136
+ WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
137
+ if (!row) return { ok: false, code: "NOT_FOUND" };
138
+ const status = decision.approved ? "approved" : "rejected";
139
+ const [won] = await tx<ApprovalRequest[]>`
140
+ UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
141
+ WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
142
+ RETURNING ${columns()}
143
+ `;
144
+ if (won) {
145
+ await audit.record(
146
+ {
147
+ action: decision.approved ? "auth.action.approve" : "auth.action.deny",
148
+ outcome: "allowed",
149
+ actor: { userId: actor.userId },
150
+ target: { type: "approval_request", id, label: `${won.appId}.${won.action}` },
151
+ metadata: {
152
+ appId: won.appId,
153
+ action: won.action,
154
+ assurance: won.assurance,
155
+ decidedVia: actor.via,
156
+ requesterKind: won.requesterKind,
157
+ requesterAppId: won.requesterAppId,
158
+ },
159
+ },
160
+ tx,
161
+ );
162
+ return { ok: true, status, replay: false };
163
+ }
164
+ if (row.status === "pending") {
165
+ await tx`UPDATE approvals.requests SET status='expired' WHERE id=${id}::uuid AND status='pending' AND expires_at <= now()`;
166
+ return { ok: false, code: "EXPIRED" };
167
+ }
168
+ if (
169
+ (decision.approved && (row.status === "approved" || row.status === "consumed")) ||
170
+ (!decision.approved && row.status === "rejected")
171
+ )
172
+ return { ok: true, status: row.status, replay: true };
173
+ return { ok: false, code: "ALREADY_DECIDED", status: row.status };
174
+ });
175
+ // Publish a later NATS decision hint only after the owning transaction commits.
176
+ return result;
177
+ };
178
+
179
+ export type ApprovalConsumeResult =
180
+ | { ok: true }
181
+ | { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" };
182
+ export const consume = async (
183
+ id: string,
184
+ input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
185
+ db?: SQL,
186
+ ): Promise<ApprovalConsumeResult> => {
187
+ const argsDigest = approvalArgsDigest(input.input),
188
+ reviewDigest = approvalReviewDigest(input.review);
189
+ return transaction(db, async (tx): Promise<ApprovalConsumeResult> => {
190
+ const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
191
+ WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref} FOR UPDATE`;
192
+ if (!row) return { ok: false, code: "NOT_FOUND" };
193
+ if (row.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
194
+ if (row.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
195
+ if (row.argsDigest !== argsDigest || row.reviewDigest !== reviewDigest) {
196
+ await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
197
+ return { ok: false, code: row.argsDigest !== argsDigest ? "INPUT_CHANGED" : "REVIEW_CHANGED" };
198
+ }
199
+ await tx`UPDATE approvals.requests SET status='consumed', consumed_at=now() WHERE id=${id}::uuid AND status='approved'
200
+ AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
201
+ AND args_digest=${argsDigest} AND review_digest=${reviewDigest}`;
202
+ return { ok: true };
203
+ });
204
+ };
205
+
206
+ export const cancel = async (ids: string[], db?: SQL): Promise<void> => {
207
+ if (!ids.length) return;
208
+ await transaction(db, async (tx) => {
209
+ await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=ANY(${toPgTextArray(ids)}::uuid[]) AND status IN ('pending','approved')`;
210
+ });
211
+ };
212
+
213
+ export const pruneApprovalRequests = async (before: Date): Promise<number> => {
214
+ let total = 0;
215
+ let deleted: number;
216
+ do {
217
+ const result = await sql`WITH expired AS (SELECT id FROM approvals.requests WHERE expires_at < ${before} LIMIT 5000)
218
+ DELETE FROM approvals.requests request USING expired WHERE request.id=expired.id`;
219
+ deleted = result.count;
220
+ total += deleted;
221
+ } while (deleted === 5000);
222
+ return total;
223
+ };
224
+
225
+ export const approvals = { request, get, decide, consume, cancel };
@@ -0,0 +1,33 @@
1
+ import { sql } from "bun";
2
+
3
+ export const migrateApprovals = async (): Promise<void> => {
4
+ await sql`CREATE SCHEMA IF NOT EXISTS approvals`.simple();
5
+ await sql`
6
+ CREATE TABLE IF NOT EXISTS approvals.requests (
7
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
8
+ app_id TEXT NOT NULL,
9
+ action TEXT NOT NULL,
10
+ approval_scope TEXT,
11
+ requester_kind TEXT NOT NULL CHECK (requester_kind IN ('assistant_turn','studio_app')),
12
+ requester_ref TEXT NOT NULL,
13
+ requester_app_id TEXT NOT NULL,
14
+ requester_title TEXT,
15
+ subject_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
16
+ approver_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
17
+ assurance TEXT NOT NULL CHECK (assurance IN ('standard','confirm')),
18
+ sentence TEXT NOT NULL,
19
+ details JSONB,
20
+ args_digest TEXT NOT NULL,
21
+ review_digest TEXT NOT NULL,
22
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','expired','cancelled','consumed')),
23
+ decided_via TEXT CHECK (decided_via IS NULL OR decided_via IN ('web_session','app_session','oauth','api_key','invocation')),
24
+ decided_at TIMESTAMPTZ,
25
+ consumed_at TIMESTAMPTZ,
26
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
+ expires_at TIMESTAMPTZ NOT NULL,
28
+ UNIQUE (requester_kind, requester_ref)
29
+ )
30
+ `.simple();
31
+ await sql`CREATE INDEX IF NOT EXISTS approvals_requests_pending ON approvals.requests(approver_user_id, created_at) WHERE status='pending'`.simple();
32
+ await sql`CREATE INDEX IF NOT EXISTS approvals_requests_expiry ON approvals.requests(expires_at)`.simple();
33
+ };
@@ -23,6 +23,12 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
23
23
  legalLinks: { ...current.legalLinks, ...next.legalLinks },
24
24
  searchLinks: { ...current.searchLinks, ...next.searchLinks },
25
25
  searchLinkDescriptions: { ...current.searchLinkDescriptions, ...next.searchLinkDescriptions },
26
+ widgets: Object.fromEntries(
27
+ [...new Set([...Object.keys(current.widgets ?? {}), ...Object.keys(next.widgets ?? {})])].map((id) => [
28
+ id,
29
+ { ...current.widgets?.[id], ...next.widgets?.[id] },
30
+ ]),
31
+ ),
26
32
  };
27
33
  }, {});
28
34
  };
@@ -55,6 +61,11 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
55
61
  description: translation.searchLinkDescriptions?.[link.href] ?? link.description,
56
62
  })),
57
63
  legalLinks: app.legalLinks?.map((link) => ({ ...link, label: translation.legalLinks?.[link.href] ?? link.label })),
64
+ widgets: app.widgets?.map((widget) => ({
65
+ ...widget,
66
+ title: translation.widgets?.[widget.id]?.title ?? widget.title,
67
+ description: translation.widgets?.[widget.id]?.description ?? widget.description,
68
+ })),
58
69
  };
59
70
  };
60
71
 
@@ -44,11 +44,46 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
44
44
  ul: ["style"],
45
45
  };
46
46
 
47
- export const EMAIL_HTML_ALLOWED_SCHEMES = ["http", "https", "mailto"] as const;
48
-
49
- export const sanitizeEmailHtml = (html: string): string =>
50
- sanitizeHtml(html, {
51
- allowedTags: [...EMAIL_HTML_TAGS],
52
- allowedAttributes: EMAIL_HTML_ALLOWED_ATTRIBUTES,
53
- allowedSchemes: [...EMAIL_HTML_ALLOWED_SCHEMES],
54
- });
47
+ export const EMAIL_HTML_ALLOWED_SCHEMES = ["http", "https", "mailto", "tel"] as const;
48
+
49
+ // An optional leading `+`, at least one digit, the visual separators of RFC 3966 and spaces, and an optional extension.
50
+ const PHONE_NUMBER_HREF = /^tel:\+?(?=[^;]*\d)[\d\-.() ]+(?:;ext=\d+)?$/i;
51
+
52
+ /** Whether a `tel:` link holds a phone number, such as `tel:+49 731 000000` or `tel:+49-731-000000;ext=12`. */
53
+ const isPhoneNumberHref = (href: string): boolean => {
54
+ try {
55
+ return PHONE_NUMBER_HREF.test(decodeURI(href.trim()));
56
+ } catch {
57
+ return false;
58
+ }
59
+ };
60
+
61
+ // Like sanitize-html, ignore control characters and whitespace when reading the scheme.
62
+ const hrefScheme = (href: string): string | undefined =>
63
+ href
64
+ .replace(/[\u0000-\u0020]+/g, "")
65
+ .match(/^([a-z][a-z\d+.-]*):/i)?.[1]
66
+ ?.toLowerCase();
67
+
68
+ /**
69
+ * Drops a `tel:` link that holds no phone number; its text stays. sanitize-html removes HTML comments before
70
+ * it reads a scheme, so a link address with a comment, such as `te<!---->l:`, is dropped too.
71
+ */
72
+ const keepPhoneNumberHref: sanitizeHtml.Transformer = (tagName, attribs) => {
73
+ const href = attribs.href;
74
+ if (href === undefined || (!href.includes("<!--") && (hrefScheme(href) !== "tel" || isPhoneNumberHref(href)))) {
75
+ return { tagName, attribs };
76
+ }
77
+ const { href: _dropped, ...kept } = attribs;
78
+ return { tagName, attribs: kept };
79
+ };
80
+
81
+ /** The sanitize-html options of the email sanitizer. Extend them instead of repeating the allowlists. */
82
+ export const EMAIL_HTML_SANITIZE_OPTIONS = {
83
+ allowedTags: [...EMAIL_HTML_TAGS],
84
+ allowedAttributes: EMAIL_HTML_ALLOWED_ATTRIBUTES,
85
+ allowedSchemes: [...EMAIL_HTML_ALLOWED_SCHEMES],
86
+ transformTags: { a: keepPhoneNumberHref },
87
+ } satisfies sanitizeHtml.IOptions;
88
+
89
+ export const sanitizeEmailHtml = (html: string): string => sanitizeHtml(html, EMAIL_HTML_SANITIZE_OPTIONS);