@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.
- package/package.json +3 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +14 -4
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/chat/blocks.tsx +53 -32
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/messages.ts +18 -0
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +4 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +94 -25
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/executor.ts +170 -14
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +8 -0
- package/src/ai/migrate.ts +113 -2
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +39 -0
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/widgets.ts +36 -5
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- 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) {
|
package/src/contracts/app.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
package/src/contracts/widgets.ts
CHANGED
|
@@ -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
|
-
*
|
|
46
|
-
*
|
|
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,
|
package/src/desktop/solid.tsx
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
package/src/shared/email-html.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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);
|